Skip to content

Repository files navigation

Berth Market

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.

Spend / earn story

  1. A human funds a treasury with USDC on Base (or a Sepolia EOA for staging).
  2. An agent wallet is a capped child of that treasury. It can spend (x402) up to its cap.
  3. A seller lists an HTTP endpoint, an MCP tool, or a desktop.linux SKU fulfilled by a Berthos node (later W365).
  4. The agent calls GET /listings/:id/invoke unpaid → HTTP 402 + PAYMENT-REQUIRED quote.
  5. The agent retries with PAYMENT-SIGNATURE. The test facilitator (or a live x402 facilitator) verifies.
  6. For desktop.linux, the market creates a Berthos lease first, then settles. Unreachable / laptop / 409 → 4xx, no charge.
  7. The market returns 200 + receipt (and a leaseId for desktop). The seller treasury earns 90%. The protocol treasury takes 10%.
  8. POST /receipts/:id/end destroys 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

Quick start

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:8787

CI 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.

List an HTTP endpoint

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.

How an agent pays

# 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/invoke

The 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.

Base Sepolia staging (real testnet USDC)

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.

  1. Get a throwaway EOA. Never commit the key. This process never logs it.
  2. 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.
  3. Set env (see .env.example and 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-loop

The 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.

How this talks to Berthos

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.

Desktop listing after berth doctor

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:7432

Then 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.

Live loop: fund, list, pay, see a lease, end, occupancy

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 start

Then 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.

Browser CORS (berth-web)

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 start

Allowed 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.

API

MethodPathPurpose
OPTIONS/*CORS preflight (Vite loopback by default)
POST/listingsCreate listing (validates kind + eligibility)
GET/listingsCatalog
GET/listings/:id/invoke402 quote or paid fulfillment + receipt
GET/receipts/:idPayment receipt (lease id + occupancy)
POST/receipts/:id/endDestroy Berthos guest; store occupancy seconds
POST/wallets/treasuryHuman / seller treasury
POST/wallets/agentCapped child
POST/wallets/:id/fundTest USDC
GET/wallets/:idBalance, cap, spent
GET/healthLiveness + wallet/facilitator identity

Docs

Design

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).

About

Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
GitHub - hexuria/berth-market: Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node. · GitHub
Skip to content

Repository files navigation

Berth Market

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.

Spend / earn story

  1. A human funds a treasury with USDC on Base (or a Sepolia EOA for staging).
  2. An agent wallet is a capped child of that treasury. It can spend (x402) up to its cap.
  3. A seller lists an HTTP endpoint, an MCP tool, or a desktop.linux SKU fulfilled by a Berthos node (later W365).
  4. The agent calls GET /listings/:id/invoke unpaid → HTTP 402 + PAYMENT-REQUIRED quote.
  5. The agent retries with PAYMENT-SIGNATURE. The test facilitator (or a live x402 facilitator) verifies.
  6. For desktop.linux, the market creates a Berthos lease first, then settles. Unreachable / laptop / 409 → 4xx, no charge.
  7. The market returns 200 + receipt (and a leaseId for desktop). The seller treasury earns 90%. The protocol treasury takes 10%.
  8. POST /receipts/:id/end destroys 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

Quick start

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:8787

CI 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.

List an HTTP endpoint

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.

How an agent pays

# 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/invoke

The 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.

Base Sepolia staging (real testnet USDC)

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.

  1. Get a throwaway EOA. Never commit the key. This process never logs it.
  2. 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.
  3. Set env (see .env.example and 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-loop

The 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.

How this talks to Berthos

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.

Desktop listing after berth doctor

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:7432

Then 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.

Live loop: fund, list, pay, see a lease, end, occupancy

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 start

Then 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.

Browser CORS (berth-web)

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 start

Allowed 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.

API

MethodPathPurpose
OPTIONS/*CORS preflight (Vite loopback by default)
POST/listingsCreate listing (validates kind + eligibility)
GET/listingsCatalog
GET/listings/:id/invoke402 quote or paid fulfillment + receipt
GET/receipts/:idPayment receipt (lease id + occupancy)
POST/receipts/:id/endDestroy Berthos guest; store occupancy seconds
POST/wallets/treasuryHuman / seller treasury
POST/wallets/agentCapped child
POST/wallets/:id/fundTest USDC
GET/wallets/:idBalance, cap, spent
GET/healthLiveness + wallet/facilitator identity

Docs

Design

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).

About

Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - hexuria/berth-market: Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node. · GitHub
Skip to content

Repository files navigation

Berth Market

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.

Spend / earn story

  1. A human funds a treasury with USDC on Base (or a Sepolia EOA for staging).
  2. An agent wallet is a capped child of that treasury. It can spend (x402) up to its cap.
  3. A seller lists an HTTP endpoint, an MCP tool, or a desktop.linux SKU fulfilled by a Berthos node (later W365).
  4. The agent calls GET /listings/:id/invoke unpaid → HTTP 402 + PAYMENT-REQUIRED quote.
  5. The agent retries with PAYMENT-SIGNATURE. The test facilitator (or a live x402 facilitator) verifies.
  6. For desktop.linux, the market creates a Berthos lease first, then settles. Unreachable / laptop / 409 → 4xx, no charge.
  7. The market returns 200 + receipt (and a leaseId for desktop). The seller treasury earns 90%. The protocol treasury takes 10%.
  8. POST /receipts/:id/end destroys 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

Quick start

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:8787

CI 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.

List an HTTP endpoint

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.

How an agent pays

# 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/invoke

The 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.

Base Sepolia staging (real testnet USDC)

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.

  1. Get a throwaway EOA. Never commit the key. This process never logs it.
  2. 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.
  3. Set env (see .env.example and 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-loop

The 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.

How this talks to Berthos

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.

Desktop listing after berth doctor

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:7432

Then 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.

Live loop: fund, list, pay, see a lease, end, occupancy

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 start

Then 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.

Browser CORS (berth-web)

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 start

Allowed 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.

API

MethodPathPurpose
OPTIONS/*CORS preflight (Vite loopback by default)
POST/listingsCreate listing (validates kind + eligibility)
GET/listingsCatalog
GET/listings/:id/invoke402 quote or paid fulfillment + receipt
GET/receipts/:idPayment receipt (lease id + occupancy)
POST/receipts/:id/endDestroy Berthos guest; store occupancy seconds
POST/wallets/treasuryHuman / seller treasury
POST/wallets/agentCapped child
POST/wallets/:id/fundTest USDC
GET/wallets/:idBalance, cap, spent
GET/healthLiveness + wallet/facilitator identity

Docs

Design

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).

About

Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - hexuria/berth-market: Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node. · GitHub
Skip to content

Repository files navigation

Berth Market

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.

Spend / earn story

  1. A human funds a treasury with USDC on Base (or a Sepolia EOA for staging).
  2. An agent wallet is a capped child of that treasury. It can spend (x402) up to its cap.
  3. A seller lists an HTTP endpoint, an MCP tool, or a desktop.linux SKU fulfilled by a Berthos node (later W365).
  4. The agent calls GET /listings/:id/invoke unpaid → HTTP 402 + PAYMENT-REQUIRED quote.
  5. The agent retries with PAYMENT-SIGNATURE. The test facilitator (or a live x402 facilitator) verifies.
  6. For desktop.linux, the market creates a Berthos lease first, then settles. Unreachable / laptop / 409 → 4xx, no charge.
  7. The market returns 200 + receipt (and a leaseId for desktop). The seller treasury earns 90%. The protocol treasury takes 10%.
  8. POST /receipts/:id/end destroys 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

Quick start

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:8787

CI 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.

List an HTTP endpoint

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.

How an agent pays

# 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/invoke

The 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.

Base Sepolia staging (real testnet USDC)

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.

  1. Get a throwaway EOA. Never commit the key. This process never logs it.
  2. 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.
  3. Set env (see .env.example and 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-loop

The 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.

How this talks to Berthos

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.

Desktop listing after berth doctor

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:7432

Then 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.

Live loop: fund, list, pay, see a lease, end, occupancy

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 start

Then 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.

Browser CORS (berth-web)

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 start

Allowed 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.

API

MethodPathPurpose
OPTIONS/*CORS preflight (Vite loopback by default)
POST/listingsCreate listing (validates kind + eligibility)
GET/listingsCatalog
GET/listings/:id/invoke402 quote or paid fulfillment + receipt
GET/receipts/:idPayment receipt (lease id + occupancy)
POST/receipts/:id/endDestroy Berthos guest; store occupancy seconds
POST/wallets/treasuryHuman / seller treasury
POST/wallets/agentCapped child
POST/wallets/:id/fundTest USDC
GET/wallets/:idBalance, cap, spent
GET/healthLiveness + wallet/facilitator identity

Docs

Design

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).

About

Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' GitHub - hexuria/berth-market: Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node. · GitHub
Skip to content

Repository files navigation

Berth Market

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.

Spend / earn story

  1. A human funds a treasury with USDC on Base (or a Sepolia EOA for staging).
  2. An agent wallet is a capped child of that treasury. It can spend (x402) up to its cap.
  3. A seller lists an HTTP endpoint, an MCP tool, or a desktop.linux SKU fulfilled by a Berthos node (later W365).
  4. The agent calls GET /listings/:id/invoke unpaid → HTTP 402 + PAYMENT-REQUIRED quote.
  5. The agent retries with PAYMENT-SIGNATURE. The test facilitator (or a live x402 facilitator) verifies.
  6. For desktop.linux, the market creates a Berthos lease first, then settles. Unreachable / laptop / 409 → 4xx, no charge.
  7. The market returns 200 + receipt (and a leaseId for desktop). The seller treasury earns 90%. The protocol treasury takes 10%.
  8. POST /receipts/:id/end destroys 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

Quick start

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:8787

CI 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.

List an HTTP endpoint

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.

How an agent pays

# 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/invoke

The 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.

Base Sepolia staging (real testnet USDC)

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.

  1. Get a throwaway EOA. Never commit the key. This process never logs it.
  2. 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.
  3. Set env (see .env.example and 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-loop

The 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.

How this talks to Berthos

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.

Desktop listing after berth doctor

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:7432

Then 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.

Live loop: fund, list, pay, see a lease, end, occupancy

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 start

Then 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.

Browser CORS (berth-web)

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 start

Allowed 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.

API

MethodPathPurpose
OPTIONS/*CORS preflight (Vite loopback by default)
POST/listingsCreate listing (validates kind + eligibility)
GET/listingsCatalog
GET/listings/:id/invoke402 quote or paid fulfillment + receipt
GET/receipts/:idPayment receipt (lease id + occupancy)
POST/receipts/:id/endDestroy Berthos guest; store occupancy seconds
POST/wallets/treasuryHuman / seller treasury
POST/wallets/agentCapped child
POST/wallets/:id/fundTest USDC
GET/wallets/:idBalance, cap, spent
GET/healthLiveness + wallet/facilitator identity

Docs

Design

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).

About

Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - hexuria/berth-market: Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node. · GitHub
Skip to content

Repository files navigation

Berth Market

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.

Spend / earn story

  1. A human funds a treasury with USDC on Base (or a Sepolia EOA for staging).
  2. An agent wallet is a capped child of that treasury. It can spend (x402) up to its cap.
  3. A seller lists an HTTP endpoint, an MCP tool, or a desktop.linux SKU fulfilled by a Berthos node (later W365).
  4. The agent calls GET /listings/:id/invoke unpaid → HTTP 402 + PAYMENT-REQUIRED quote.
  5. The agent retries with PAYMENT-SIGNATURE. The test facilitator (or a live x402 facilitator) verifies.
  6. For desktop.linux, the market creates a Berthos lease first, then settles. Unreachable / laptop / 409 → 4xx, no charge.
  7. The market returns 200 + receipt (and a leaseId for desktop). The seller treasury earns 90%. The protocol treasury takes 10%.
  8. POST /receipts/:id/end destroys 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

Quick start

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:8787

CI 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.

List an HTTP endpoint

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.

How an agent pays

# 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/invoke

The 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.

Base Sepolia staging (real testnet USDC)

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.

  1. Get a throwaway EOA. Never commit the key. This process never logs it.
  2. 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.
  3. Set env (see .env.example and 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-loop

The 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.

How this talks to Berthos

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.

Desktop listing after berth doctor

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:7432

Then 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.

Live loop: fund, list, pay, see a lease, end, occupancy

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 start

Then 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.

Browser CORS (berth-web)

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 start

Allowed 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.

API

MethodPathPurpose
OPTIONS/*CORS preflight (Vite loopback by default)
POST/listingsCreate listing (validates kind + eligibility)
GET/listingsCatalog
GET/listings/:id/invoke402 quote or paid fulfillment + receipt
GET/receipts/:idPayment receipt (lease id + occupancy)
POST/receipts/:id/endDestroy Berthos guest; store occupancy seconds
POST/wallets/treasuryHuman / seller treasury
POST/wallets/agentCapped child
POST/wallets/:id/fundTest USDC
GET/wallets/:idBalance, cap, spent
GET/healthLiveness + wallet/facilitator identity

Docs

Design

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).

About

Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' GitHub - hexuria/berth-market: Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node. · GitHub
Skip to content

Repository files navigation

Berth Market

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.

Spend / earn story

  1. A human funds a treasury with USDC on Base (or a Sepolia EOA for staging).
  2. An agent wallet is a capped child of that treasury. It can spend (x402) up to its cap.
  3. A seller lists an HTTP endpoint, an MCP tool, or a desktop.linux SKU fulfilled by a Berthos node (later W365).
  4. The agent calls GET /listings/:id/invoke unpaid → HTTP 402 + PAYMENT-REQUIRED quote.
  5. The agent retries with PAYMENT-SIGNATURE. The test facilitator (or a live x402 facilitator) verifies.
  6. For desktop.linux, the market creates a Berthos lease first, then settles. Unreachable / laptop / 409 → 4xx, no charge.
  7. The market returns 200 + receipt (and a leaseId for desktop). The seller treasury earns 90%. The protocol treasury takes 10%.
  8. POST /receipts/:id/end destroys 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

Quick start

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:8787

CI 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.

List an HTTP endpoint

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.

How an agent pays

# 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/invoke

The 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.

Base Sepolia staging (real testnet USDC)

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.

  1. Get a throwaway EOA. Never commit the key. This process never logs it.
  2. 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.
  3. Set env (see .env.example and 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-loop

The 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.

How this talks to Berthos

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.

Desktop listing after berth doctor

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:7432

Then 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.

Live loop: fund, list, pay, see a lease, end, occupancy

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 start

Then 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.

Browser CORS (berth-web)

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 start

Allowed 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.

API

MethodPathPurpose
OPTIONS/*CORS preflight (Vite loopback by default)
POST/listingsCreate listing (validates kind + eligibility)
GET/listingsCatalog
GET/listings/:id/invoke402 quote or paid fulfillment + receipt
GET/receipts/:idPayment receipt (lease id + occupancy)
POST/receipts/:id/endDestroy Berthos guest; store occupancy seconds
POST/wallets/treasuryHuman / seller treasury
POST/wallets/agentCapped child
POST/wallets/:id/fundTest USDC
GET/wallets/:idBalance, cap, spent
GET/healthLiveness + wallet/facilitator identity

Docs

Design

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).

About

Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); GitHub - hexuria/berth-market: Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node. · GitHub
Skip to content

Repository files navigation

Berth Market

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.

Spend / earn story

  1. A human funds a treasury with USDC on Base (or a Sepolia EOA for staging).
  2. An agent wallet is a capped child of that treasury. It can spend (x402) up to its cap.
  3. A seller lists an HTTP endpoint, an MCP tool, or a desktop.linux SKU fulfilled by a Berthos node (later W365).
  4. The agent calls GET /listings/:id/invoke unpaid → HTTP 402 + PAYMENT-REQUIRED quote.
  5. The agent retries with PAYMENT-SIGNATURE. The test facilitator (or a live x402 facilitator) verifies.
  6. For desktop.linux, the market creates a Berthos lease first, then settles. Unreachable / laptop / 409 → 4xx, no charge.
  7. The market returns 200 + receipt (and a leaseId for desktop). The seller treasury earns 90%. The protocol treasury takes 10%.
  8. POST /receipts/:id/end destroys 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

Quick start

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:8787

CI 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.

List an HTTP endpoint

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.

How an agent pays

# 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/invoke

The 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.

Base Sepolia staging (real testnet USDC)

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.

  1. Get a throwaway EOA. Never commit the key. This process never logs it.
  2. 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.
  3. Set env (see .env.example and 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-loop

The 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.

How this talks to Berthos

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.

Desktop listing after berth doctor

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:7432

Then 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.

Live loop: fund, list, pay, see a lease, end, occupancy

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 start

Then 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.

Browser CORS (berth-web)

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 start

Allowed 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.

API

MethodPathPurpose
OPTIONS/*CORS preflight (Vite loopback by default)
POST/listingsCreate listing (validates kind + eligibility)
GET/listingsCatalog
GET/listings/:id/invoke402 quote or paid fulfillment + receipt
GET/receipts/:idPayment receipt (lease id + occupancy)
POST/receipts/:id/endDestroy Berthos guest; store occupancy seconds
POST/wallets/treasuryHuman / seller treasury
POST/wallets/agentCapped child
POST/wallets/:id/fundTest USDC
GET/wallets/:idBalance, cap, spent
GET/healthLiveness + wallet/facilitator identity

Docs

Design

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).

About

Agent spend/earn market: USDC wallets, x402 listings (APIs, MCP, desktop SKUs). Not the computer node.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages