zunivo · x402 on arc
x402 payments on Arc
Charge USDC per API call and let an agent pay for it without a human in the loop. Standard x402, settled on Arc.
server middlewareagent clientEIP-3009 facilitatorArc mainnettestnet sandbox
What is this
x402 is a payment standard built on the HTTP 402 Payment Required status code. The server answers with 402 and a machine-readable price, the client attaches a payment and retries, the server returns the resource. It's governed by the Linux Foundation and used by Coinbase, Circle and others.
The public facilitators cover Base, Polygon, Arbitrum and Solana but not Arc. zunivo-x402-arc fills that gap: an x402 client can pay for an API on Arc, where gas is in USDC and blocks are sub-second.
How x402 works
402 Payment Required with PaymentRequirements: price, token, recipient, network.X-PAYMENT header.X-PAYMENT-RESPONSE receipt header.Quickstart
# install
npm install zunivo-x402-arc
# run a paid API (needs a Zunivo API key from https://api.zunivo.io)
ZUNIVO_API=https://api.zunivo.io ZUNIVO_KEY=zk_… PAY_TO=you.agent npm run example:server
# in another terminal, an agent pays it — one command, one function
AGENT_PK=0x… npm run example:agent
Server — charge USDC per call
Three lines protect any Express route:
import express from "express";
import { paymentRequired } from "zunivo-x402-arc";
const app = express();
const pay = paymentRequired({
price: "0.05", // human USDC
payTo: "you.agent", // a .agent name or 0x address
zunivoApi: "https://api.zunivo.io",
zunivoKey: process.env.ZUNIVO_KEY,
});
app.get("/v1/data", pay, (req, res) => res.json({ premium: "…" }));
Unpaid requests receive a spec-compliant 402. Paid requests pass through with an X-PAYMENT-RESPONSE receipt header. Each payment is consumed exactly once.
The 402 a client sees
{
"x402Version": 1,
"accepts": [{
"scheme": "exact",
"network": "arc", // also listed as CAIP-2 "eip155:5042"; sandbox: "arc-testnet" / "eip155:5042002"
"maxAmountRequired": "50000", // 0.05 USDC in 6-decimal base units
"asset": "0x3600000000000000000000000000000000000000",
"payTo": "you.agent",
"maxTimeoutSeconds": 120,
"extra": {
"name": "USDC", "decimals": 6, "chainId": 5042,
"zunivoOrderId": "02e2b580-…", // order minted for this call — pay it, then retry with proof
"payToAddress": "0x6963…90c8", // payTo resolved to its 20-byte address — settle to THIS, not the name
"payUrl": "https://app.zunivo.io/pay?oid=…" // human-payable fallback for the same order
}
}]
}
extra.payToAddress (the resolved 20-byte address), never to payTo when it is a .agent name — the chain only accepts addresses. Our SDK client does this for you since 0.1.1.Agent — pay automatically
Getting the price, paying on Arc and retrying with proof is one wrapped fetch:
import { createX402Fetch } from "zunivo-x402-arc/client";
const x402fetch = createX402Fetch({
privateKey: process.env.AGENT_PK,
maxPrice: "1", // refuse anything pricier (optional guard)
onEvent: (e) => console.log(e.type),
});
const res = await x402fetch("https://api.example.dev/v1/data");
const data = await res.json(); // paid for, on-chain, no human
.agent service discovery
The holder of a .agent name can publish an agent card (endpoint, x402 manifest, description) through the ZunivoAgentRecords contract. Another agent can then go from the name to a paid call in two lines:
import { connectAgent } from "zunivo-x402-arc";
const svc = await connectAgent("data.agent", { privateKey: process.env.AGENT_PK });
const res = await svc.fetch("/v1/index/crypto"); // 402 handled — USDC paid, receipt on-chain
The recipient is pinned to the address the name resolves to on-chain, so a hostile server cannot redirect funds. To just read a card without paying, use discoverAgent(name). Publish your own card at app.zunivo.io/names; browse the directory at app.zunivo.io/agents.
ZunivoAgentRecords — verified at 0x4f40…306B. ENS-resolver-style text records, keyed by the name's tokenId, writable only by the current NFT holder. Records survive transfers; reads are free.MCP — give your AI a wallet
zunivo-mcp is a Model Context Protocol server: it gives any MCP client (Claude Desktop, Claude Code, …) a budgeted USDC wallet on Arc. The AI can discover .agent services, pay for APIs over x402, and issue payment links — non-custodial, with a hard daily spend cap.
| Tool | Does |
|---|---|
zunivo_resolve_agent | name → on-chain card (address, endpoint, records) |
zunivo_list_agents | the public directory of callable services |
zunivo_paid_fetch | call a paid API — 402 handled, USDC paid within budget, receipt returned |
zunivo_create_payment_link | invoice anyone — returns a pay URL |
zunivo_check_order | order status + settling payments |
zunivo_spend_status | today's budget: cap, spent, remaining |
Add it to Claude Desktop's claude_desktop_config.json:
{
"mcpServers": {
"zunivo": {
"command": "npx",
"args": ["-y", "zunivo-mcp"],
"env": {
"AGENT_PK": "0x…", // agent wallet — never shown to the model
"ZUNIVO_RECORDS_ADDRESS": "0x4f40…306B",
"ZUNIVO_DAILY_CAP": "5", // hard daily USDC budget
"ZUNIVO_MAX_PER_CALL": "1",
"ZUNIVO_SPEND_FILE": "~/.zunivo-spend.json" // budget survives restarts — use it
}
}
}
}
ZUNIVO_SPEND_FILE). Content returned by paid endpoints is untrusted input to your model — keep the cap small.Two settlement backends
The middleware's verify is pluggable. Two paths ship:
| Backend | How it settles | Status |
|---|---|---|
| Zunivo orders (default) | Payment settles through our verified Arc router 0x4210…Ea55. X-PAYMENT payload is { zunivoOrderId }. | works today |
| EIP-3009 facilitator | Agent signs a TransferWithAuthorization; the facilitator submits it to Arc's USDC. Fully standard, gasless. | reference |
EIP-3009 (gasless, advanced)
Arc's USDC is issued by Circle and supports EIP-3009 (TransferWithAuthorization) — the same mechanism x402 uses elsewhere. The agent signs off-chain; a facilitator submits on-chain; no prior approval, no gas for the agent.
import { signPayment, createFacilitator } from "zunivo-x402-arc/facilitator";
// agent side — sign, no gas, no transaction
const payload = await signPayment({ privateKey, to: "you.agent", price: "0.05" });
// facilitator side — verify signature, then submit to Arc USDC
const fac = createFacilitator({ submitterKey });
const { settled, txHash } = await fac.settle(payload);
0x3600…0000) exposes the transferWithAuthorization / receiveWithAuthorization selectors and its EIP-712 domain (name, version) on-chain. Until then, use the Zunivo-order backend as the tested path.Decimals on Arc (read this)
toUsdcBaseUnits() / fromUsdcBaseUnits().Going to mainnet (money-safety)
Since 1.0.0 the SDK defaults to Arc mainnet (network: "arc"); pass "arc-testnet" for the free sandbox. On mainnet you are moving real USDC, so the library refuses unsafe defaults and requires you to opt in to three guards explicitly. Zunivo is non-custodial — funds move wallet → on-chain router → recipient; the platform never holds them — but these guards protect against the mistakes and hostile inputs that can cost real money.
1. Select the network explicitly
Nothing is hardcoded to one environment. Pass network on both the server and the agent ("arc" is the default, "arc-testnet" the sandbox; the CAIP-2 forms "eip155:5042" / "eip155:5042002" are accepted too). The agent rejects a 402 whose network doesn't match its own, so it can't be tricked onto the wrong chain — a mainnet agent will never pay a testnet quote or vice-versa.
// server
const pay = paymentRequired({ price: "0.05", payTo: "you.agent", network: "arc", consumedStore });
// agent
const x402fetch = createX402Fetch({ privateKey, network: "arc", expectRecipient });
2. Provide a durable replay store
The default replay guard is in-memory and single-process — fine for testnet, unsafe for a real deployment (a restart or a second instance would let one payment be consumed twice). On mainnet the middleware throws unless you pass a shared, durable consumedStore (Redis, a database, etc). Any object with an atomic reserve(key) → boolean (returns true only the first time) works.
const consumedStore = {
async reserve(key) { // return true iff this key was never seen (atomic, e.g. Redis SET NX)
return await redis.set(key, "1", "NX") === "OK";
},
};
3. Pin the recipient the agent expects
The paid endpoint tells the agent which address to pay. A compromised or malicious server could name an attacker's address. If your agent knows who it intends to pay, pass expectRecipient — the client refuses to settle to any other address. Set requireExpectRecipient: true to make that pin mandatory.
const x402fetch = createX402Fetch({
privateKey, network: "arc",
expectRecipient: "0x…", // refuse to pay anyone else
requireExpectRecipient: true, // and refuse to run without a pin
});
ZUNIVO_MAINNET_RPC_URL to your own endpoint; the SDK puts it first.Arc constants
| Field | Mainnet ("arc", default) | Testnet sandbox ("arc-testnet") |
|---|---|---|
| Chain ID | 5042 · eip155:5042 | 5042002 · eip155:5042002 |
| RPC | https://rpc.mainnet.arc.io | https://rpc.testnet.arc.network |
| Explorer | arc.etherscan.io | https://testnet.arcscan.app |
| USDC (ERC-20, 6 dp) | 0x3600000000000000000000000000000000000000 (same on both) | |
| Zunivo router | 0xAa8c293495446d04a51A32e2e4557EDE3BfC7119 | 0x4210D40a9899e42b4946B9dC7E0C35d3cf14Ea55 |
| Zunivo names (.agent) | 0x824218447E8Dbf10E535dC7fB6ab7b68105c7dDa | 0x244e0c8bE1Ed59636901F98920413d414B158cc5 |
| Agent records | 0xFE9fca63CaA64FBf0B2F089786A706f0C99dCbB1 | 0x4f405f0aA04FD6FaE0838DeE6FD184B1f3cC306B |
| Zunivo API | https://api.zunivo.io | https://testnet-api.zunivo.io |
| App | https://app.zunivo.io | https://testnet.zunivo.io |
| Funding | real USDC — bridge in via CCTP | faucet.circle.com |