An autonomous agent pays per API call over the x402 pattern
(HTTP 402 Payment Required), with the payment settled on-chain in USDC on Arc using
StreamPay as the settlement rail. No human in the loop, no API keys, no off-chain
invoicing, the 402 → 200 transition is bound to a live on-chain payment.
This is the missing piece for the agentic economy: a server can charge a machine per request and get paid per second of use, and the buyer agent only spends for what it actually consumes.
The “model” behind
/inferencehere is a deterministic stub, this demo proves the payment rail and the 402 gate, not a language model. SwaprunInference()inserver.jsfor any real engine and the billing is already done.
buyer agent x402 server (payee) Arc Testnet
----------- ------------------- -----------
GET /inference ───────▶ 402 Payment Required
◀─────── { scheme: streampay, payTo, asset: USDC, … }
createStream(payTo, 0.30 USDC, 60s) ───────────────────────────────────▶ StreamPay #N
GET /inference?stream=N ───────▶ reads stream on-chain (active? recipient? vested?)
withdraw() the vested seconds ─────▶ USDC moves
◀─────── 200 { result, settlementTx } (real Arc tx)
…repeat per call (server pulls the seconds vested since the last call)…
cancel(N) ───────────────────────────────────────────▶ reclaim unused
402 plus a JSON
x402 body (and a WWW-Authenticate: x402 … header) describing the scheme, the
settlement contract, the payTo address, and the asset. An agent reads it and pays.withdraw()s the seconds that have vested each time it
serves a call, that is genuine pay-per-second-of-use, not a flat charge.200 is returned only after a successful on-chain
withdraw(); if nothing has vested, the server answers 402 again. The settlement
tx hash is returned in the 200 body.cancel()s the stream and the unspent
remainder returns to it. In the sample run: committed 0.30 USDC,
0.185 to the payee, 0.115 back on cancel.Why streaming instead of one transfer per call? Because the agentic billing model is
metered: a stream lets the server pull continuously while it works, settles many calls
against a single committed budget, and lets the buyer cap and reclaim its spend, all
properties a bare per-call transfer does not give you.
Needs Node 18+ and two funded Arc-Testnet burner keys (USDC is the gas token; get test USDC at https://faucet.circle.com). The server wallet auto-tops-up from the agent on first run.
npm install
cp .env.example .env # fill AGENT_PRIVATE_KEY + SERVER_PRIVATE_KEY (dedicated burners)
./run.sh # bootstrap → start server → run agent → tee to demo-run.log
Or drive the two sides yourself:
SERVER_PRIVATE_KEY=0x… node server.js # terminal 1, the paid endpoint
AGENT_PRIVATE_KEY=0x… node agent.js # terminal 2, the buyer agent
See a verified end-to-end transcript with live arcscan links in SAMPLE_RUN.md.
| File | What |
|---|---|
server.js |
The 402-gated /inference endpoint; verifies the stream on-chain and settles via StreamPay withdraw(). |
agent.js |
The autonomous buyer: hits 402, opens the stream, polls paid calls, reclaims the remainder. |
bootstrap.js |
Funds the server wallet’s gas (USDC) from the agent if it is low. |
run.sh |
One-command end-to-end demo. |
| RPC | https://rpc.testnet.arc.network |
| Chain ID | 5042002 |
| Gas token | USDC (native), 0x3600000000000000000000000000000000000000 |
| StreamPay | 0x6C2Ae6f8Ba7c0259EABa8ef4048C8BFc68BAB262 |
| Explorer | https://testnet.arcscan.app |
| Faucet | https://faucet.circle.com |
.env and node_modules are gitignored. No key is ever
committed or logged.../stream-pay; this demo adds
no custody of its own (the server only ever pulls what has vested to it).ts + sig, where sig is the stream sender’s personal_sign
over x402-inference:<chainId>:<settlementContract>:<streamId>:<keccak256(prompt)>:<ts>.
The server serves only when the recovered signer equals the on-chain stream sender, the
timestamp is within SIG_MAX_AGE_S (default 120s), and the signature has not been seen
before (single-use, anti-replay). A third party observing an open stream on-chain cannot
spend its vested balance.withdraw actually pulled (parsed from the Withdrawn event, not the pre-tx read)
means concurrent calls on one stream cannot each be served off a single vested minimum.