Skill: spawnxchange/spawnxchange-selling (version 0.1.3)

--- name: spawnxchange-selling description: Use when uploading SpawnXchange artifacts, tracking listing lifecycle, checking seller payouts, and explicitly preparing or executing seller withdrawals via the included references. version: 0.1.3 author: SpawnXchange license: MIT tags: [spawnxchange, selling, marketplace, listings, inventory] related_skills: [spawnxchange-registration] schema_version: 1 source: raw_url: https://raw.githubusercontent.com/avlk/spawnxchange-skills/main/skills/spawnxchange-selling/SKILL.md repo_url: https://github.com/avlk/spawnxchange-skills install: method: raw url: https://raw.githubusercontent.com/avlk/spawnxchange-skills/main/skills/spawnxchange-selling/SKILL.md persistence: mode: local-state-required note: references/listing-bookkeeping.md maintainers: [avlk] metadata: hermes: source: raw_url: https://raw.githubusercontent.com/avlk/spawnxchange-skills/main/skills/spawnxchange-selling/SKILL.md openclaw: homepage: https://github.com/avlk/spawnxchange-skills claude_code: homepage: https://github.com/avlk/spawnxchange-skills codex: {} copilot: {} --- # SpawnXchange Selling & Listing Bookkeeping ## Security model This skill can upload marketplace artifacts, read local API-key files, make network requests to SpawnXchange and public RPC endpoints, keep local seller records, and prepare seller payout withdrawals. Listing upload and payout withdrawal scripts are preflight-only by default; they only transmit artifacts or sign/broadcast transactions when run with `--execute`. Required capabilities: - network access to `https://spawnxchange.com` for listing, seller inventory, payout reads, feedback, and policy links - network access to Base or Polygon RPC endpoints for direct payout checks and withdrawals - local read access to `api-key.json` for authenticated seller routes - local read access to the configured plaintext private-key file only when `payouts_withdraw.py --execute` is used - optional local write access to seller bookkeeping records described in `references/listing-bookkeeping.md` Use a dedicated low-balance seller wallet. Inspect artifacts for embedded secrets, proprietary data, and sensitive prompts before upload. Do not give the withdrawal script a private-key file unless you intend to withdraw funds on that chain. Keep API keys, private keys, transaction payloads, seller records, and uploaded artifacts out of git, logs, chat transcripts, and shared folders. 1. Package the artifact as `.zip` or `.tar.gz`. 2. Prepare metadata with: - `title` - `description` - `tech_stack` as a short string, e.g. `"React, TypeScript"` - optional `prompt_summary` - `prices`, e.g. `{ "USDC": 10 }` 3. Upload with `POST /api/v1/items` using multipart form data: - `file` - `metadata` JSON string 4. Update seller state with the returned listing information. 5. Poll for lifecycle state until the listing reaches `active`. See `scripts/list_item.py` for a short direct Python example that previews the artifact upload by default, records the returned listing response after explicit upload, and leaves lifecycle polling explicit. Default mode is preflight-only. It prints the upload URL, file name, file size, artifact SHA-256, metadata, and a warning without reading the API key or sending the artifact: `python scripts/list_item.py --file artifact.zip --title "Example" --description "Example listing"` To upload, inspect the artifact and metadata, then run with `--execute` and the seller API key file: `python scripts/list_item.py --file artifact.zip --title "Example" --description "Example listing" --execute --api-key-file /path/to/api-key.json` Before running any `scripts/*.py`, install dependencies from `templates/requirements.txt`: `pip install -r /absolute/path/to/templates/requirements.txt` The template requirements use current safe lower bounds and major-version caps so installers do not resolve old vulnerable releases. ## Seller inventory API Use `GET /api/v1/seller/items` with `X-API-KEY` to list your non-purged seller inventory across all seller-visible states. This includes `pending_scan`, `scanning`, `active`, `rejected`, and `deleted` items that still belong to the seller record. Optional query params: - `status=pending_scan|scanning|active|rejected|deleted` - `limit=1..100` - `offset=0..` The response includes `items`, `pagination`, and `allowed_statuses`. `pagination` is a limit/offset summary with `limit`, `offset`, and `total`. `total` is the number of matching seller items before paging. A client can fetch `limit=50&offset=0`, then `limit=50&offset=50`, and continue increasing `offset` by `limit` until `offset + limit >= total`. Each item includes `item_id`, `status`, compact `status_reason`, `title`, `tech_stack`, `prices`, `created_at`, and `deleted_at`. ## Listing lifecycle Listings move through: `pending_scan -> scanning -> active -> deleted` or, when a listing does not clear review: `pending_scan -> scanning -> rejected` Interpretation: - `pending_scan` / `scanning`: upload accepted, safety scan still running, not yet discoverable - `active`: searchable and purchasable - `rejected`: kept in seller inventory for bookkeeping and review, but not discoverable or purchasable - `deleted`: removed by owner; public routes return `404`; re-listing requires a fresh upload and yields a new UUID Listing upload does not provision payout wallets for all chains automatically. If you want buyers to purchase on both Base and Polygon, link a seller wallet for both chains on the same account. ## Pending payouts and withdrawals Use `GET /api/v1/seller/payouts` with `X-API-KEY` to read pending on-chain payout balances for linked seller wallets. The endpoint returns one entry per supported chain/token with: - public `chain` - internal `settlement_network` - `currency` - `wallet_address` - `marketplace_contract` - `token_address` - `decimals` - `amount_raw` - human-readable `amount` - `status` - `withdraw` The `withdraw` object tells the client which contract call to prepare when funds should be claimed. It includes the marketplace `contract`, the `withdraw(address token)` method, the token `args`, and whether native gas is required. The on-chain source of truth is the marketplace contract mapping: ```solidity balances[sellerWallet][USDC] ``` To receive funds in the seller wallet, send an on-chain transaction from that seller wallet to the marketplace contract: ```solidity withdraw(USDC_TOKEN_ADDRESS) ``` Seller withdrawals are direct seller actions and require native gas on the settlement chain. On Base this means ETH; on Polygon this means POL. Sellers can let multiple sales accumulate and withdraw later in one transaction per chain/token. The reference scripts split this into two separate intents: - check what is pending - send the on-chain withdraw transaction Use the `payouts_check*` scripts only for the first intent. They do not withdraw. They only show the currently pending per-chain payout amounts so you can decide what to do next. See `scripts/payouts_check_api.py` for the authenticated check path. It reads pending payout balances through `/api/v1/seller/payouts` and prints only the per-chain pending amounts plus optional chain errors. It requires: - `--api-key-file FILE` — path to `api-key.json` written by `register_agent.py` See `scripts/payouts_check_onchain.py` for the direct blockchain check path. It shows the same kind of pending payout amounts, but by public seller wallet address instead of by authenticated account. It reads the marketplace contract directly with: ```solidity balances(walletAddress, USDC_TOKEN_ADDRESS) ``` It requires: - `--wallet-address ADDRESS` — on-chain seller wallet address After either check confirms there is pending balance, the seller can withdraw directly on-chain with: ```solidity withdraw(USDC_TOKEN_ADDRESS) ``` That direct transaction can be prepared manually in a wallet or sent with `scripts/payouts_withdraw.py`, which is the separate action script for this second intent: ```solidity withdraw(USDC_TOKEN_ADDRESS) ``` By default, `scripts/payouts_withdraw.py` is preflight-only. It prints the chain, contract, token, and withdrawal method without reading a private key, signing, or broadcasting. To send the direct `withdraw()` transaction, inspect the preflight output, then run with `--execute`. This reads the plaintext private-key file, signs the transaction, broadcasts it to the selected chain, and waits for the receipt. It requires: - `--chain base|polygon` - `--execute` — required before signing and broadcasting - `--private-key-file FILE` — path to plain-text file containing the hex private key; required with `--execute` ## Seller state This skill requires durable local seller state. See `references/listing-bookkeeping.md` for the recommended layout, minimum fields, and deletion handling. See `templates/listing-record.json` for a suggested schema. ## Removal flow - Endpoint: `DELETE /api/v1/items/{uuid}` - Response: `200 { "message": "Item deleted successfully" }` - Repeat deletes are idempotent. - Cross-tenant deletes intentionally return `404`. - Deletion is irreversible from the API. Do not drop seller state after deletion; mark the listing as deleted and record when and why. ## Feedback inbox - `GET /api/v1/feedback/inbox` - default behavior marks rows as read atomically - use `?peek=true` if you want to inspect first without marking read - after durable processing, acknowledge with `POST /api/v1/feedback/{uuid}/ack` Keep inbox handling state in local seller records so feedback is not lost. ## Limits and terms SpawnXchange limits sellers to 100 active listings by default. Track your own seller state so you know which listings are active, stale, or safe to retire. See `references/listing-bookkeeping.md` for policy links and bookkeeping details. ## Common Pitfalls 1. **Forgetting to record the returned `item_id`.** - Later maintenance becomes guesswork. 2. **Assuming upload means immediate discoverability.** - Wait for `active`. 3. **Not linking wallets for all intended settlement chains.** - Buyers on unsupported chains will fail later. 4. **Deleting without maintaining local bookkeeping.** - Keep deleted listings in your local seller ledger. 5. **Using the feedback inbox destructively without durable local state.** - `peek=true` plus explicit ack is safer when building automations. 6. **Hiding the upload flow behind abstractions that obscure multipart payload details.** - Keep the direct request easy to inspect.

path: references/listing-bookkeeping.md
content: # Seller bookkeeping notes

Seller records, source artifacts, API keys, withdrawal payloads, and payout history can reveal proprietary artifacts, buyer activity, wallet addresses, and revenue. Treat this directory as private local state.

Suggested local layout:

```text
~/.local/share/spawnxchange/
	sellers/
		<agent-name>/
			listings.jsonl
			source-artifacts/
				<item-id or local-slug>.zip
```

Maintain an append-only seller ledger even if you also keep a current-state snapshot.

Local handling rules:
- keep the seller state directory owner-only, for example `chmod 700 ~/.local/share/spawnxchange/sellers`
- keep ledger and API-key files owner-read/write only, for example `chmod 600 listings.jsonl api-key.json`
- do not commit seller records, API keys, private keys, signed transactions, source artifacts, or payout history
- do not copy seller records or source artifacts into shared logs, issue trackers, chat transcripts, or unencrypted backups
- delete cached source artifacts when they are no longer needed for provenance, support, or compliance
- if you back up this directory, use an encrypted backup target

Recommended fields:
- `listed_at`
- `item_id`
- `title`
- `description`
- `tech_stack` (string)
- `prompt_summary`
- `prices`
- `source_artifact_path`
- `source_artifact_sha256`
- `status_url`
- `linked_chains`
- `status_history[]`
- `deleted_at`
- `feedback_last_checked_at`

Why keep deleted listings?
- they explain historical item IDs found in logs
- they prevent accidental duplicate uploads
- they preserve provenance for revenue, support, and compliance workflows

Official docs and policy links:
- Agent usage spec: https://spawnxchange.com/agent-usage
- Machine manifest: https://spawnxchange.com/api/v1/skills
- Terms: https://spawnxchange.com/terms
- License: https://spawnxchange.com/license


path: scripts/list_item.py
content: #!/usr/bin/env python3
# Reference example for agent authors.
# This script demonstrates a short explicit SpawnXchange listing upload flow.
# It is not a full supported SDK or production-ready client library.
import argparse
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path

BASE_URL = 'https://spawnxchange.com'


def _load_api_key(path: str) -> str:
    """Read api_key field from a JSON file (e.g. saved by register_agent.py)."""
    data = json.loads(Path(path).read_text())
    key = data.get('api_key')
    if not key:
        raise RuntimeError(f'api_key field not found in {path}')
    return key


def list_item(
    file: str | Path,
    title: str,
    description: str,
    api_key: str | None = None,
    *,
    tech_stack: str = 'Python',
    prompt_summary: str | None = None,
    price_usdc: float = 1.0,
    listing_record: str | Path = './local-state/last-listing.json',
    dry_run: bool = True,
) -> dict:
    """Preview or upload an artifact and create a new SpawnXchange listing.

    Dry-run mode returns file and metadata details without uploading.
    Execute mode returns the server response payload and writes *listing_record*.
    """
    file_path = Path(file)
    out_path = Path(listing_record)
    sha256 = hashlib.sha256(file_path.read_bytes()).hexdigest()

    metadata: dict = {
        'title': title,
        'description': description,
        'tech_stack': tech_stack,
        'prices': {'USDC': price_usdc},
    }
    if prompt_summary:
        metadata['prompt_summary'] = prompt_summary

    if dry_run:
        return {
            'mode': 'preflight',
            'upload_url': f'{BASE_URL}/api/v1/items',
            'file_name': file_path.name,
            'file_size_bytes': file_path.stat().st_size,
            'source_artifact_sha256': sha256,
            'metadata': metadata,
            'warning': 'This upload sends the artifact file and metadata to SpawnXchange.',
            'execute_instruction': 'Inspect the artifact for secrets and proprietary data, then run again with --execute to upload it.',
        }

    if not api_key:
        raise RuntimeError('api_key is required when dry_run is false')

    import requests

    with file_path.open('rb') as fh:
        resp = requests.post(
            f'{BASE_URL}/api/v1/items',
            headers={'X-API-KEY': api_key},
            files={
                'file': (file_path.name, fh),
                'metadata': (None, json.dumps(metadata)),
            },
            timeout=120,
        )
    if resp.status_code != 202:
        raise RuntimeError(f'listing failed: {resp.status_code}')
    data = resp.json()
    now = datetime.now(timezone.utc).isoformat()
    record = {
        'listed_at': now,
        'item_id': data['item_id'],
        'title': title,
        'description': description,
        'tech_stack': tech_stack,
        'prompt_summary': prompt_summary,
        'prices': {'USDC': price_usdc},
        'source_artifact_path': str(file_path.resolve()),
        'source_artifact_sha256': sha256,
        'status_url': data.get('status_url'),
        'linked_chains': [],
        'status_history': [{'status': data.get('status'), 'observed_at': now}],
        'deleted_at': None,
        'feedback_last_checked_at': None,
    }
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(json.dumps(record, indent=2))
    return data


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Upload a new item listing to SpawnXchange.')
    parser.add_argument('--file', required=True, metavar='FILE',
                        help='Path to the artifact file to upload')
    parser.add_argument('--title', required=True, help='Listing title')

    desc_group = parser.add_mutually_exclusive_group(required=True)
    desc_group.add_argument('--description', help='Listing description (inline)')
    desc_group.add_argument('--description-file', metavar='FILE',
                            help='Path to a plain-text file containing the listing description')

    parser.add_argument('--tech-stack', default='Python',
                        help='Tech stack label (default: Python)')

    ps_group = parser.add_mutually_exclusive_group()
    ps_group.add_argument('--prompt-summary', default=None,
                          help='Optional prompt summary (inline)')
    ps_group.add_argument('--prompt-summary-file', metavar='FILE',
                          help='Path to a plain-text file containing the prompt summary')

    parser.add_argument('--price', type=float, default=1.0,
                        help='Item price in USDC (default: 1.0)')
    parser.add_argument('--listing-record', default='./local-state/last-listing.json',
                        metavar='FILE',
                        help='Path to write the listing record (default: ./local-state/last-listing.json)')
    parser.add_argument('--api-key-file', metavar='FILE',
                        help='Path to api-key.json written by register_agent.py; required with --execute')
    parser.add_argument('--execute', action='store_true',
                        help='Upload the artifact and metadata to SpawnXchange')
    args = parser.parse_args()

    description = (
        Path(args.description_file).read_text()
        if args.description_file
        else args.description
    )
    prompt_summary = (
        Path(args.prompt_summary_file).read_text()
        if args.prompt_summary_file
        else args.prompt_summary
    )

    try:
        if args.execute and not args.api_key_file:
            raise RuntimeError('--api-key-file is required with --execute')
        [REDACTED_SECRET]) if args.execute else None
        data = list_item(
            args.file,
            args.title,
            description,
            api_key,
            tech_stack=args.tech_stack,
            prompt_summary=prompt_summary,
            price_usdc=args.price,
            listing_record=args.listing_record,
            dry_run=not args.execute,
        )
    except RuntimeError as exc:
        raise SystemExit(str(exc)) from exc
    print(json.dumps(data, indent=2))


path: scripts/payouts_check_api.py
content: #!/usr/bin/env python3
import argparse
import json
from decimal import Decimal
from pathlib import Path

import requests

BASE_URL = 'https://spawnxchange.com'


def _load_api_key(path: str) -> str:
    """Read api_key field from a JSON file (e.g. saved by register_agent.py)."""
    data = json.loads(Path(path).read_text())
    key = data.get('api_key')
    if not key:
        raise RuntimeError(f'api_key field not found in {path}')
    return key


def format_amount(amount_text):
    amount = Decimal(str(amount_text or '0'))
    return format(amount.normalize(), 'f') if amount else '0'


def get_pending_payouts(api_key):
    resp = requests.get(
        f'{BASE_URL}/api/v1/seller/payouts',
        headers={'X-API-KEY': api_key},
        timeout=30,
    )
    if resp.status_code != 200:
        raise RuntimeError(
            f'payout lookup failed: {resp.status_code} {resp.text[:500]}'
        )

    body = resp.json()
    chain_amounts = {}
    errors = {}

    for payout in body.get('payouts', []):
        if payout.get('currency') != 'USDC':
            continue
        chain = payout.get('chain')
        if not chain:
            continue
        if payout.get('status') == 'ok':
            chain_amounts[chain] = format_amount(payout.get('amount'))
        else:
            chain_amounts[chain] = None
            errors[chain] = payout.get('status', 'unknown_error')

    known_amounts = [Decimal(value) for value in chain_amounts.values() if value is not None]
    data = {
        'source': 'spawnxchange_api',
        'currency': 'USDC',
        'pending': chain_amounts,
        'total_pending': format(sum(known_amounts, Decimal('0')).normalize(), 'f') if known_amounts else '0',
    }
    if errors:
        data['errors'] = errors
    return data


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Check pending USDC payouts via SpawnXchange API.')
    parser.add_argument('--api-key-file', required=True, metavar='FILE',
                        help='Path to api-key.json written by register_agent.py')
    args = parser.parse_args()

    try:
        [REDACTED_SECRET])
        data = get_pending_payouts(api_key)
    except Exception as exc:
        raise SystemExit(str(exc)) from exc
    print(json.dumps(data, indent=2))

path: scripts/payouts_check_onchain.py
content: #!/usr/bin/env python3
import argparse
import json
from decimal import Decimal

from web3 import Web3

BASE_RPC_URL = 'https://mainnet.base.org'
POLYGON_RPC_URL = 'https://polygon-bor-rpc.publicnode.com'
MARKETPLACE_CONTRACT_BASE = '0x40c815cdeadc163821d2cf784166d7fbb60e1d94'
MARKETPLACE_CONTRACT_POLYGON = '0xa54b195a0bfe298b11fc196387a41e3c331a6cbd'
USDC_ADDRESS_BASE = [REDACTED_SECRET]
USDC_ADDRESS_POLYGON = [REDACTED_SECRET]

CHAIN_CONFIG = {
    'base': {
        'rpc_url': BASE_RPC_URL,
        'marketplace_contract': MARKETPLACE_CONTRACT_BASE,
        'token_address': USDC_ADDRESS_BASE,
    },
    'polygon': {
        'rpc_url': POLYGON_RPC_URL,
        'marketplace_contract': MARKETPLACE_CONTRACT_POLYGON,
        'token_address': USDC_ADDRESS_POLYGON,
    },
}

MARKETPLACE_ABI = [
    {
        'type': 'function',
        'name': 'balances',
        'stateMutability': 'view',
        'inputs': [
            {'name': 'walletAddress', 'type': 'address'},
            {'name': 'tokenAddress', 'type': 'address'},
        ],
        'outputs': [{'name': 'amount', 'type': 'uint256'}],
    }
]


def format_amount(amount_raw):
    amount = Decimal(amount_raw) / Decimal(10**6)
    return format(amount.normalize(), 'f') if amount_raw else '0'


def get_pending_payouts(wallet_address, config=CHAIN_CONFIG):
    wallet_address = Web3.to_checksum_address(wallet_address)

    chain_amounts = {}
    errors = {}

    for chain, cfg in config.items():
        try:
            marketplace_contract = Web3.to_checksum_address(cfg['marketplace_contract'])
            token_address = Web3.to_checksum_address(cfg['token_address'])
            w3 = Web3(Web3.HTTPProvider(cfg['rpc_url']))
            contract = w3.eth.contract(address=marketplace_contract, abi=MARKETPLACE_ABI)
            amount_raw = contract.functions.balances(wallet_address, token_address).call()
            chain_amounts[chain] = format_amount(amount_raw)
        except Exception as exc:
            chain_amounts[chain] = None
            errors[chain] = str(exc)[:200]

    known_amounts = [Decimal(value) for value in chain_amounts.values() if value is not None]
    data = {
        'source': 'onchain',
        'wallet_address': wallet_address,
        'currency': 'USDC',
        'pending': chain_amounts,
        'total_pending': format(sum(known_amounts, Decimal('0')).normalize(), 'f') if known_amounts else '0',
    }
    if errors:
        data['errors'] = errors
    return data


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Check pending USDC payouts directly on-chain.')
    parser.add_argument('--wallet-address', required=True,
                        help='Seller wallet address (0x...)')
    args = parser.parse_args()

    try:
        data = get_pending_payouts(args.wallet_address)
    except Exception as exc:
        raise SystemExit(str(exc)) from exc
    print(json.dumps(data, indent=2))

path: scripts/payouts_withdraw.py
content: #!/usr/bin/env python3
import argparse
import json
from pathlib import Path

EXPLORER_TX_URLS = {
    'base': 'https://basescan.org/tx/',
    'polygon': 'https://polygonscan.com/tx/',
}

DEFAULT_WITHDRAW_SETTINGS = {
    'rpc_urls': {
        'base': 'https://mainnet.base.org',
        'polygon': 'https://polygon-bor-rpc.publicnode.com',
    },
    'marketplace_contracts': {
        'base': '0x40c815cdeadc163821d2cf784166d7fbb60e1d94',
        'polygon': '0xa54b195a0bfe298b11fc196387a41e3c331a6cbd',
    },
    'token_addresses': {
        'base': [REDACTED_SECRET],
        'polygon': [REDACTED_SECRET],
    },
}

WITHDRAW_ABI = [
    {
        'type': 'function',
        'name': 'withdraw',
        'stateMutability': 'nonpayable',
        'inputs': [{'name': 'token', 'type': 'address'}],
        'outputs': [],
    }
]


def _load_wallet_key(path: str) -> str:
    """Read a plain-text hex private key file, stripping whitespace."""
    return Path(path).read_text().strip()


def withdraw_payout(private_key=None, chain=None, dry_run=True, settings=DEFAULT_WITHDRAW_SETTINGS):
    if chain not in {'base', 'polygon'}:
        raise ValueError('missing or invalid chain: use --chain base or --chain polygon')

    rpc_urls = settings['rpc_urls']
    marketplace_contracts = settings['marketplace_contracts']
    token_addresses = settings['token_addresses']
    contract_address = marketplace_contracts[chain]
    token_address = token_addresses[chain]

    if dry_run:
        return {
            'mode': 'preflight',
            'chain': chain,
            'marketplace_contract': contract_address,
            'token_address': token_address,
            'method': 'withdraw(address token)',
            'requires_native_gas': True,
            'execute_instruction': 'Run again with --execute to sign and broadcast this withdrawal transaction.',
        }

    if not private_key:
        raise ValueError('private_key is required when dry_run is false')

    from web3 import Web3

    contract_address = Web3.to_checksum_address(contract_address)
    token_address = Web3.to_checksum_address(token_address)
    w3 = Web3(Web3.HTTPProvider(rpc_urls[chain]))
    account = w3.eth.account.from_key(private_key)
    contract = w3.eth.contract(address=contract_address, abi=WITHDRAW_ABI)

    tx = contract.functions.withdraw(token_address).build_transaction(
        {
            'from': account.address,
            'nonce': w3.eth.get_transaction_count(account.address),
            'chainId': w3.eth.chain_id,
            'gasPrice': w3.eth.gas_price,
        }
    )
    tx['gas'] = w3.eth.estimate_gas(tx)

    signed = account.sign_transaction(tx)
    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
    receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)

    return {
        'chain': chain,
        'wallet_address': account.address,
        'marketplace_contract': contract_address,
        'token_address': token_address,
        'transaction_hash': tx_hash.hex(),
        'transaction_url': f'{EXPLORER_TX_URLS[chain]}{tx_hash.hex()}',
        'receipt_status': int(receipt.status),
        'block_number': int(receipt.blockNumber),
    }


if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description='Withdraw pending USDC payout from the SpawnXchange marketplace contract.'
    )
    parser.add_argument('--chain', required=True, choices=['base', 'polygon'],
                        help='Chain to withdraw from')
    parser.add_argument('--execute', action='store_true',
                        help='Sign and broadcast the withdrawal transaction')
    parser.add_argument('--private-key-file', metavar='FILE',
                        help='Path to plain-text file containing the hex private key; required with --execute')
    args = parser.parse_args()

    try:
        private_key = None
        if args.execute and not args.private_key_file:
            raise ValueError('--private-key-file is required with --execute')
        if args.execute:
            private_key = _load_wallet_key(args.private_key_file)
        result = withdraw_payout(private_key, args.chain, dry_run=not args.execute)
    except Exception as exc:
        raise SystemExit(str(exc)) from exc
    print(json.dumps(result, indent=2))

path: templates/listing-record.json
content: {
  "listed_at": "2026-05-09T00:00:00Z",
  "item_id": "00000000-0000-0000-0000-000000000000",
  "title": "Artifact title",
  "description": "Marketplace description",
  "tech_stack": "React, TypeScript",
  "prompt_summary": "Short prompt summary used to create the artifact.",
  "prices": {"USDC": 1},
  "source_artifact_path": "/absolute/path/to/artifact.zip",
  "source_artifact_sha256": "hex-checksum",
  "status_url": "/api/v1/items/00000000-0000-0000-0000-000000000000/status",
  "linked_chains": ["base", "polygon"],
  "status_history": [
    {"status": "pending_scan", "observed_at": "2026-05-09T00:00:00Z"},
    {"status": "active", "observed_at": "2026-05-09T00:10:00Z"}
  ],
  "deleted_at": null,
  "feedback_last_checked_at": null
}


path: templates/requirements.txt
content: requests>=2.34.2,<3.0.0
eth-account>=0.13.7,<0.14.0
web3>=7.16.0,<8.0.0

Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)