Agent spend/earn layer. Lists HTTP APIs, MCP tools, and desktop SKUs; prices them in USDC on Base (eip155:8453) or Base Sepolia staging (eip155:84532); settles via x402 v2.
This repo is not a computer. Isolation, Docker, and hypervisors live in Berthos. The market talks to a node over HTTP (GET /v1/eligibility, POST /v1/leases, DELETE /v1/leases/{id}) and rejects anything that claims class=laptop or host-desktop.
No Berth chain. No meme token. Email / AgentMail is out of v1.
- A human funds a treasury with USDC on Base (or a Sepolia EOA for staging).
- An agent wallet is a capped child of that treasury. It can spend (x402) up to its cap.
- A seller lists an HTTP endpoint, an MCP tool, or a
desktop.linuxSKU fulfilled by a Berthos node (later W365). - The agent calls
GET /listings/:id/invokeunpaid → HTTP 402 +PAYMENT-REQUIREDquote. - The agent retries with
PAYMENT-SIGNATURE. The test facilitator (or a live x402 facilitator) verifies. - For
desktop.linux, the market creates a Berthos lease first, then settles. Unreachable / laptop / 409 → 4xx, no charge. - The market returns 200 + receipt (and a
leaseIdfor desktop). The seller treasury earns 90%. The protocol treasury takes 10%. POST /receipts/:id/enddestroys the guest and stores occupancy seconds. That is a receipt, not a second payment.
treasury (human) ──cap──► agent ──402 / pay──► listing ──90%──► seller payTo
└──10%──► protocol
npm install
npm test
npm run earn-loop # MemoryWallet 402 → pay → earn for HTTP, MCP, and desktop.linux
npm run sepolia-loop # opt-in Base Sepolia settle (HTTP + MCP + desktop.linux); skips (exit 0) without keys
npm start # http://127.0.0.1:8787CI needs no secrets. The default adapters are in-memory + a test x402 facilitator. BERTHOS_URL, BERTHOS_LEASE_TOKEN, FACILITATOR_URL, and WALLET_ADAPTER=cdp are opt-in and unused in CI. npm run sepolia-loop is also opt-in: without STAGING_PAYER_PRIVATE_KEY and STAGING_PAY_TO it prints a skip and exits 0. No live Coinbase keys, no mainnet USDC.
Two-role demo (host parks in berthos; buyer pays over HTTP here — no marketplace SPA): docs/DEMO.md. Browser UI: hexuria/berth-web on Vite :5173 / :5174. This process answers OPTIONS and loopback CORS so that UI can call :8787 without a proxy.
New listings that omit price.network (and quotes for those listings) default to Base Sepolia (eip155:84532, Circle USDC 0x036CbD53842c5426634e7929541eC2318f3dCF7e). Set NETWORK=eip155:8453 or send that CAIP-2 on the listing to use mainnet. A listing that already sets eip155:8453 is stored and quoted as mainnet — it is not rewritten.
curl -s http://127.0.0.1:8787/wallets/treasury -X POST \
-H 'content-type: application/json' \
-d '{"label":"seller"}'
curl -s http://127.0.0.1:8787/listings -X POST \
-H 'content-type: application/json' \
-d '{ "kind": "http", "title": "weather.now", "description": "Current conditions", "price": { "amount": "1000", "asset": "USDC", "network": "eip155:84532" }, "payTo": "0x1111111111111111111111111111111111111111", "endpoint": { "url": "https://api.example.com/weather", "method": "GET" } }'price.amount is atomic USDC (6 decimals). Staging default "1000" is $0.001. Omit price.network and the listing is stored as eip155:84532. Mainnet listings still accept an explicit eip155:8453 and keep that network on later quotes.
MCP uses kind: "mcp" and endpoint.tool. Desktop uses kind: "desktop.linux" after a green Berthos doctor — see below and docs/LISTING.md.
# Capped child
curl -s http://127.0.0.1:8787/wallets/agent -X POST \
-H 'content-type: application/json' \
-d '{"spendCap":"5000000","label":"research-agent"}'# Test USDC (not mainnet)
curl -s http://127.0.0.1:8787/wallets/WALLET_ID/fund -X POST \
-H 'content-type: application/json' \
-d '{"amount":"2000000"}'# Unpaid invoke → 402
curl -i http://127.0.0.1:8787/listings/LISTING_ID/invokeThe 402 carries a base64 PAYMENT-REQUIRED header (x402 v2). Retry the same URL with PAYMENT-SIGNATURE. In tests the signature is test:<walletId> inside a v2 PaymentPayload. Live agents should use @x402/fetch + an EVM scheme against a real facilitator.
On success: 200, PAYMENT-RESPONSE, and a receipt that splits 90/10.
This is Base Sepolia (eip155:84532), not Base mainnet (eip155:8453). Do not send staging traffic to 8453.
MemoryWallet + TestFacilitator stay the CI default. A funded Sepolia EOA can settle a real x402 payment through the public facilitator. The receipt stores the settle / tx hash. On-chain USDC goes 100% to STAGING_PAY_TO; the 90/10 split is receipt accounting (onChainSettlement=payTo_100). The public facilitator does not split to a second payTo. WALLET_ADAPTER=cdp (with the three CDP keys) is the separate spend-permission path — unused in CI and forced off in sepolia-loop.
- Get a throwaway EOA. Never commit the key. This process never logs it.
- Fund Base Sepolia USDC from the Circle faucet or the Coinbase CDP faucet. Sepolia ETH (same CDP faucet, or Base's public list) is only needed if you move funds yourself — x402 exact / EIP-3009 is facilitator-sponsored gas.
- Set env (see
.env.exampleand docs/WALLET.md):
export NETWORK=base-sepolia
export FACILITATOR_URL=https://x402.org/facilitator # public testnet facilitator, no API keyexport STAGING_PAYER_PRIVATE_KEY=0xYOUR_SEPOLIA_EOA_KEY
export STAGING_PAY_TO=0xSELLER_RECEIVER
npm run sepolia-loopThe loop lists HTTP, MCP, and desktop.linux (1000 atomic = $0.001 each), quotes 402, signs EIP-3009, and settles once per kind. Desktop uses in-process MemoryEligibility/MemoryLease (no BERTHOS_URL). Laptop / host-desktop fail closed. It refuses NETWORK=eip155:8453. Optional later: CDP facilitator https://api.cdp.coinbase.com/platform/v2/x402 (needs CDP auth) — not required for this loop.
If the key or STAGING_PAY_TO is unset, the script prints a skip and exits 0 so CI stays green without secrets.
Proven facilitator-submitted txs (this repo's src/staging/loop.ts, not a raw wallet send) and the Basescan checks are in docs/DEMO.md. Two-role host/buyer steps: docs/DEMO.md.
Berthos is the VM/server computer-session node. Isolated guests only. This market does not run Docker or a hypervisor.
Set BERTHOS_URL to the node's loopback HTTP (default http://127.0.0.1:7432) and BERTHOS_LEASE_TOKEN to a pairing bearer with the lease capability. When BERTHOS_URL is set, desktop listings use HttpBerthosEligibilityClient (GET /v1/eligibility) and paid invokes use HttpBerthosLeaseClient (POST /v1/leases). Leave both unset in CI — tests use MemoryEligibilityClient + MemoryLeaseClient.
The live body is the Berthos doctor report: ok / eligible, class, checks[], and guest image labels (berthos.guest.version=v1, berthos.desktop=xvfb-openbox-chromium, berthos.egress.policy=default-deny). Desktop listings fail closed when the client cannot reach the node, ok is false, class is laptop (or host-desktop), or the attestation / image labels are stale or missing.
On the node host (not in this repo):
docker build -t berthos-linux-desktop:v1 images/linux-desktop # labeled guest
berth doctor --json # must be eligible
berth node up # http://127.0.0.1:7432Then point the market at that node and list a SKU. The market GETs /v1/eligibility again; a red doctor or an unreachable node is a 400, not a retry.
export BERTHOS_URL=http://127.0.0.1:7432
export BERTHOS_LEASE_TOKEN=PASTE_FROM_POST_V1_PAIR
# Optional: inspect the same shape the market will store
curl -s "$BERTHOS_URL/v1/eligibility"
curl -s http://127.0.0.1:8787/wallets/treasury -X POST \
-H 'content-type: application/json' \
-d '{"label":"gpu-seller"}'
curl -s http://127.0.0.1:8787/listings -X POST \
-H 'content-type: application/json' \
-d '{ "kind": "desktop.linux", "title": "gpu-box.session", "price": { "amount": "5000000", "asset": "USDC", "network": "eip155:84532" }, "payTo": "0x1111111111111111111111111111111111111111", "class": "vm-guest", "fulfillment": { "berthosUrl": "http://127.0.0.1:7432", "sku": "linux-gpu-1", "nodeId": "node_01" }, "eligibility": { "source": "berthos.doctor", "ok": true, "class": "vm-guest", "nodeId": "node_01", "attestedAt": "2026-08-23T07:00:00.000Z", "berthosUrl": "http://127.0.0.1:7432" } }'Paid invoke then POSTs /v1/leases (os=linux only) before settling. See the live loop below. kind / class of laptop or host-desktop is rejected even when the rest of the payload is well-formed.
This uses test USDC in the in-memory wallet. You do not need Coinbase keys, mainnet USDC, or FACILITATOR_URL. You do need a local Berthos node for a real guest — build / doctor / node up live in hexuria/berthos, not here.
On the node host:
docker build -t berthos-linux-desktop:v1 images/linux-desktop
berth doctor --json
berth node up
# pairing code printed, or:
curl -s http://127.0.0.1:7432/v1/pairing
curl -s http://127.0.0.1:7432/v1/pair \
-H 'content-type: application/json' \
-d '{"code":"ABCD-EFGH"}'# → { "token": "…", "capabilities": ["operator", "lease"] }In this repo (still the test facilitator + faucet):
export BERTHOS_URL=http://127.0.0.1:7432
export BERTHOS_LEASE_TOKEN=PASTE_TOKEN
npm startThen fund a test wallet, list desktop.linux (payload above), pay, and end:
# treasury + capped agent + test USDC (not mainnet)
curl -s http://127.0.0.1:8787/wallets/treasury -X POST \
-H 'content-type: application/json' -d '{"label":"seller"}'
curl -s http://127.0.0.1:8787/wallets/agent -X POST \
-H 'content-type: application/json' \
-d '{"spendCap":"5000000","label":"research-agent"}'
curl -s http://127.0.0.1:8787/wallets/WALLET_ID/fund -X POST \
-H 'content-type: application/json' -d '{"amount":"2000000"}'# unpaid → 402; retry with PAYMENT-SIGNATURE (tests use test:<walletId>)
curl -i http://127.0.0.1:8787/listings/LISTING_ID/invoke
# 200 body includes fulfillment.leaseId and receipt.id
curl -s http://127.0.0.1:8787/receipts/RECEIPT_ID/end -X POST
# receipt.occupancySeconds / billedSeconds (seconds, min 60s billed). Not a second charge.If the node is down, ineligible, class=laptop, or already leased, invoke is 4xx and the agent is not debited.
berth-web runs on Vite http://127.0.0.1:5173 (or :5174 if 5173 is taken). A browser on that origin cannot read :8787 unless this process answers CORS + OPTIONS. curl never needed that — OPTIONS /listings used to 404.
Default CORS_ORIGIN is the Vite loopback list (not *):
http://127.0.0.1:5173, http://127.0.0.1:5174, http://localhost:5173, http://localhost:5174
# production / extra origins — comma list. Do not set * unless you mean it.export CORS_ORIGIN=https://app.example,http://127.0.0.1:5173
npm startAllowed request headers include Content-Type and PAYMENT-SIGNATURE. PAYMENT-REQUIRED and PAYMENT-RESPONSE are exposed so the page can read x402 quotes.
GET /health reports walletAdapter (memory | cdp) and facilitator (test | live, plus facilitatorUrl when set) so a loopback UI can tell MemoryWallet + TestFacilitator from live CDP / a live facilitator. It never includes CDP keys, wallet secrets, or pairing tokens.
Berthos (:7432) is a different process. This repo does not add Docker or change that node. Until the node allows the web origin, point berth-web at a same-origin Vite proxy instead of VITE_BERTHOS_URL=http://127.0.0.1:7432:
// berth-web vite.config.ts — optional same-origin proxy
server: {proxy: {"/market": {target: "http://127.0.0.1:8787",rewrite: (path)=>path.replace(/^\/market/,""),},"/berthos": {target: "http://127.0.0.1:7432",rewrite: (path)=>path.replace(/^\/berthos/,""),},},}Then VITE_MARKET_URL / VITE_BERTHOS_URL can be /market and /berthos. Prefer CORS on this server for the market; the proxy is the documented workaround for the node.
| Method | Path | Purpose |
|---|---|---|
| OPTIONS | /* | CORS preflight (Vite loopback by default) |
| POST | /listings | Create listing (validates kind + eligibility) |
| GET | /listings | Catalog |
| GET | /listings/:id/invoke | 402 quote or paid fulfillment + receipt |
| GET | /receipts/:id | Payment receipt (lease id + occupancy) |
| POST | /receipts/:id/end | Destroy Berthos guest; store occupancy seconds |
| POST | /wallets/treasury | Human / seller treasury |
| POST | /wallets/agent | Capped child |
| POST | /wallets/:id/fund | Test USDC |
| GET | /wallets/:id | Balance, cap, spent |
| GET | /health | Liveness + wallet/facilitator identity |
- docs/DEMO.md — two-role walkthrough (host parks in berthos, buyer pays here) and how to prove Sepolia txs went through this repo, not
cast send - docs/demo/README.md — screen-recording placeholder (no binaries)
- docs/ARCHITECTURE.md — ports/adapters, x402, money
- docs/LISTING.md — schema: kind, price, payTo, policy, eligibility
- docs/WALLET.md — treasury vs agent, caps, env-flagged CDP adapter
Ports (WalletPort, FacilitatorPort, EligibilityClient, LeaseClient) keep Coinbase CDP and Berthos behind adapters. The Hono app can run under Node (vitest / npm start) and export as a Worker later (src/worker.ts).