zunivo · the payment layer for ai agents on arc
x402 payments on Arc
Charge USDC per API call. Let AI agents pay for it autonomously — no card, no account, no human. Standard x402 on Arc's USDC-native chain.
server middlewareagent clientEIP-3009 facilitatorArc Testnet
What is this
x402 is the HTTP-native payment standard that revives the dormant 402 Payment Required status code. A server answers a request with 402 and a machine-readable price; a client attaches a signed payment and retries; the resource is served. It's governed by the Linux Foundation and backed by Coinbase, Circle, Visa, Stripe, and Google — tens of millions of agent payments already run on it.
But its facilitators cover Base, Polygon, Arbitrum, and Solana — not Arc. zunivo-x402-arc is that missing piece: it lets an x402-speaking agent pay for an API on Arc, using Arc's USDC-native gas and sub-second finality. Same standard the agent already knows; a new chain it can now pay on.
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-testnet",
"maxAmountRequired": "50000", // 0.05 USDC in 6-decimal base units
"asset": "0x3600000000000000000000000000000000000000",
"payTo": "you.agent",
"maxTimeoutSeconds": 120,
"extra": {
"name": "USDC", "decimals": 6, "chainId": 5042002,
"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
The whole dance — discover price, pay on Arc, retry 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
A .agent name is more than a payment address — its holder can publish an on-chain agent card (endpoint, x402 manifest, description) via the ZunivoAgentRecords contract. That turns a name into a discoverable, callable, payable service. An agent goes from a human-readable 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)
On testnet the SDK defaults are convenient. 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. The SDK refuses to run on "arc" until the mainnet constants (chain id, RPC, USDC address, router) are filled in from Arc's mainnet docs — a half-configured mainnet can never silently move funds. The agent also rejects a 402 whose network doesn't match its own, so it can't be tricked onto the wrong chain.
// 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
});
Arc Testnet constants
| Field | Value |
|---|---|
| Chain ID | 5042002 |
| RPC | https://rpc.testnet.arc.network |
| Explorer | https://testnet.arcscan.app |
| USDC (ERC-20, 6 dp) | 0x3600000000000000000000000000000000000000 |
| Zunivo router | 0x4210D40a9899e42b4946B9dC7E0C35d3cf14Ea55 |
| Zunivo names (.agent) | 0x244e0c8bE1Ed59636901F98920413d414B158cc5 |
| x402 network id | arc-testnet |
| Faucet | faucet.circle.com |