Version: 0.2 (2026-05-12) Status: Working spec — implementation matches; subject to audit revision Implementer: Cadence (github.com/Ccheh/arc402) Live deployment: Arc Testnet,
0xc95b1b20f91901206ba3ea94bbc7313e7cd82f8d
Arc402 is an open protocol for streaming USDC micropayments between any payer (an "agent") and any service over HTTP. It defines:
- An on-chain escrow contract that holds payer balances
- An EIP-712 message schema for off-chain payment authorizations ("claims")
- An HTTP-header convention (modeled on HTTP 402 Payment Required) for service-payer negotiation
- A batched on-chain settlement mechanism that amortizes gas
Cadence is the reference implementation: contracts + TypeScript SDK + Python SDK + examples.
| Goal | Mechanism |
|---|---|
| Sub-cent economics | Batched settlement: ~32k gas per claim at batch ≥ 10 |
| Zero per-call on-chain overhead | Off-chain EIP-712 claim signature, verified locally in middleware |
| No native-token gas burden on payers | Native USDC gas on Arc + depositFor() sponsorship pattern |
| Upgrade safety | EIP-712 domain version isolates signatures across protocol versions |
| Standard HTTP integration | HTTP 402 response + x-arc402-required / x-arc402-claim headers |
| Cross-language tooling | Identical wire format between TypeScript and Python SDKs |
Reference deployment (Arc Testnet):
| Param | Value |
|---|---|
| Chain ID | 5042002 |
| RPC | https://rpc.testnet.arc.network (primary), https://rpc.blockdaemon.testnet.arc.network (fallback) |
| Block explorer | https://testnet.arcscan.app |
| USDC contract | 0x3600000000000000000000000000000000000000 |
| USDC decimals on Arc | 18 (not 6 as on mainnet; USDC is native gas) |
| PaymentEscrow V2 | 0xc95b1b20f91901206ba3ea94bbc7313e7cd82f8d |
| PaymentEscrow V1 (legacy) | 0x55aFA5Cf28B98DD6DC550F15c075F46B5eaf2a98 |
| ERC-8004 IdentityRegistry | 0x8004A818BFB912233c491871b3d84c89A494BD9e (used for optional identity lookup) |
- Agent: a wallet (EOA or smart account) that wishes to pay for services. Holds USDC; deposits a balance into the escrow; signs claims.
- Service: a wallet that operates an API endpoint. Receives signed claims; verifies them locally; submits them on-chain in batch to receive payment.
- Sponsor (optional): a third party that pre-funds an agent's escrow on its behalf via
depositFor. Enables zero-gas onboarding for the agent. - Session key (optional): a key delegated by the agent (via
authorizeSession) to sign claims on its behalf. Bounded by expiry.
// Escrow management
function deposit() external payable;
function depositFor(address agent) external payable;
function withdraw(uint256 amount) external;
function balanceOf(address agent) external view returns (uint256);
// Session keys
function authorizeSession(address sessionKey, uint64 expiry) external;
function revokeSession(address sessionKey) external;
function sessionValid(address agent, address sessionKey) external view returns (bool);
// Settlement
function claim(
address agent,
uint256 amount,
uint256 nonce,
uint256 expiry,
bytes calldata signature
) external;
struct Claim {
address agent;
uint256 amount;
uint256 nonce;
uint256 expiry;
bytes signature;
}
function claimBatch(Claim[] calldata claims) external;
// Replay query
function isNonceUsed(address agent, address service, uint256 nonce) external view returns (bool);| Selector | Name | When raised |
|---|---|---|
0x1fb09b80 |
NonceAlreadyUsed |
Claim's (agent, service, nonce) tuple already settled |
0x82a49d9e |
ClaimExpired |
block.timestamp > expiry |
0x8baa579f |
InvalidSignature |
Recovered signer is neither agent nor an authorized session key |
0x3feb5d70 |
SessionExpiredOrUnknown |
Session signer found but expired or revoked |
0xf4d678b8 |
InsufficientBalance |
balanceOf[agent] < amount |
0x7c946ed7 |
ZeroAmount |
amount == 0 (deposit or claim) |
0xd571a96e |
EmptyBatch |
claimBatch(claims) called with claims.length == 0 |
0x90b8ec18 |
TransferFailed |
Native USDC transfer to recipient reverted |
EIP712Domain(
string name,
string version,
uint256 chainId,
address verifyingContract
)
Reference values:
name = "Arc402"version = "2"(V2 contract);"1"was V1chainId = 5042002(Arc Testnet)verifyingContract = 0xc95b1b20f91901206ba3ea94bbc7313e7cd82f8d(V2 reference deployment)
Domain isolation guarantees a signature for one (contract, version) cannot replay against another. This is the upgrade-safety mechanism.
Claim(
address agent,
address service,
uint256 amount,
uint256 nonce,
uint256 expiry
)
Field semantics:
agent: the address whose escrow balance will be debited. Must match the recovered signer OR be the delegator of an authorized session key.service: the address that will be credited. Must matchmsg.senderat settlement.amount: USDC to transfer, in 18-decimal wei. Must be > 0.nonce: a uint256 chosen by the signer. Must be unique per (agent, service) pair, or the claim reverts asNonceAlreadyUsed. Random 128-bit values recommended.expiry: unix timestamp after which the claim is invalid.
Signers use standard EIP-712 signTypedData_v4:
- Compute
domain_separator = keccak256(abi.encode(EIP712Domain_typehash, name_hash, version_hash, chainId, verifyingContract)) - Compute
struct_hash = keccak256(abi.encode(Claim_typehash, agent, service, amount, nonce, expiry)) - Compute
digest = keccak256(0x1901 ++ domain_separator ++ struct_hash) - Sign with secp256k1:
(r, s, v) = ECDSA.sign(digest, private_key) - Concatenate:
signature = r ++ s ++ v(65 bytes)
The reference implementations (@arc402/sdk via viem, cadence-sdk via eth-account) produce identical signatures for identical inputs.
The protocol layers on top of standard HTTP request/response semantics.
The agent calls the service endpoint as normal:
POST /v1/chat/completions HTTP/1.1
Host: api.example.com
Content-Type: application/json
{ "model": "gpt-4o-mini", "messages": [...] }If the service requires payment and no valid claim is attached, it responds:
HTTP/1.1 402 Payment Required
Content-Type: application/json
x-arc402-required: <base64(JSON)>
{
"error": "payment_required",
"requirements": {
"scheme": "arc402",
"chainId": 5042002,
"escrow": "0xc95b1b20...82f8d",
"service": "0x...",
"amount": "5000000000000000"
}
}The requirements body and the x-arc402-required header carry equivalent information; agents may consume either.
The agent signs a Claim covering amount, encodes it, and retries with the x-arc402-claim header:
POST /v1/chat/completions HTTP/1.1
Host: api.example.com
Content-Type: application/json
x-arc402-claim: <base64(JSON)>
{ "model": "gpt-4o-mini", "messages": [...] }The header value is base64(JSON({...})) where the JSON has fields:
{
"agent": "0x...",
"service": "0x...",
"amount": "5000000000000000",
"nonce": "123456789012345",
"expiry": "1778565700",
"signature": "0x..."
}All uint256 fields are string-encoded to avoid JS number precision loss.
The service verifies the claim (signature, amount ≥ required, service matches, expiry > now, nonce not yet used). If valid, it proceeds with normal endpoint logic and may include settlement metadata in its response body:
HTTP/1.1 200 OK
Content-Type: application/json
{
...,
"cadence": {
"paid_by": "0x...",
"paid_amount_usdc": "0.005",
"settlement": "queued (batched)",
"protocol": "Arc402 v2"
}
}Services accumulate verified claims in an in-memory queue and settle in batches.
- Flush on count: when queue size ≥ N (10 recommended)
- Flush on time: when oldest claim has been queued > T seconds (60 recommended)
- Flush on shutdown: drain on service stop / graceful restart
claimBatch is atomic: either all N claims settle or the transaction reverts and no state changes. Services that prefer partial-success must pre-filter claims off-chain before submission.
Recommended pre-flight checks per claim before queueing:
- Verify signature (always — cheap, prevents bad-faith spam)
- Optionally read
isNonceUsed(agent, service, nonce)(one RPC; catches replays from out-of-order claim arrival) - Optionally read
balanceOf(agent)(one RPC; catches insolvent claims before submission)
Measured on Arc Testnet (Foundry test + live runs at 20-gwei effective price):
| Batch size | Total gas | Per-claim gas | Per-claim cost |
|---|---|---|---|
| 1 | ~69,000 | 69,000 | $0.00138 |
| 10 | ~330,000 | 33,000 | $0.00066 |
| 50 | ~1,600,000 | 32,000 | $0.00064 |
| 100 | ~3,200,000 | 32,000 | $0.00064 |
Practical floor: ~32k gas per claim (signature verification + 2 storage writes). Beyond batch=10 the marginal benefit is negligible; the optimal automatic flush trigger is count=10.
The agent may authorize a delegated key to sign claims on its behalf:
escrow.authorizeSession(sessionKey, expiry);Once authorized, a claim signed by sessionKey is accepted at settlement as if signed by the agent, provided:
block.timestamp <= sessionExpiry[sessionKey]sessionOf[sessionKey] == agent- All other claim invariants pass
Revocation: escrow.revokeSession(sessionKey) clears both sessionOf and sessionExpiry, immediately invalidating any subsequent claims from that key.
Use cases:
- Agent's cold key delegates to a hot key for short-lived high-frequency operations
- Multi-agent workflows where a coordinator signs on behalf of sub-agents within a bounded window
- ERC-4337-style smart-account hot/cold key separation without needing the full AA stack
Protocol upgrades take the form of new contract deployments with bumped version strings.
- V1 → V2 used
version="1"→version="2". Signatures do NOT cross-replay (verified on chain). - V2 → V3 will follow the same pattern.
Migration: balances do NOT auto-migrate. Agents withdraw from V_n via withdraw() then deposit into V_{n+1}. Services update their middleware's escrow config and EIP-712 domain version.
This policy avoids the security surface of an upgradeability proxy while accepting an operational chore at migration time.
A primitive that distinguishes Cadence from Circle Nanopayments / Coinbase x402 / Gateway: the seller can offer a reduced price to agents whose on-chain identity meets a threshold, with identity discovery happening inline in the middleware.
A Cadence-protected endpoint may advertise two amounts in its 402 response:
{
"scheme": "arc402",
"chainId": 5042002,
"escrow": "0x...",
"service": "0x...",
"amount": "5000000000000000", // base tier: 0.005 USDC
"reputationAmount": "1000000000000000", // discounted tier: 0.001 USDC
"reputation": {
"identityRegistry": "0x8004A818BFB912233c491871b3d84c89A494BD9e",
"minTokens": 1,
"chainId": 5042002
}
}The Cadence agent SDK reads the requirements, calls IdentityRegistry.balanceOf(agent) inline, and signs a claim for reputationAmount if tokenCount >= reputation.minTokens; otherwise for amount.
When the seller's middleware receives the claim, it checks claim.amount:
- If
claim.amount >= amount, accept (base tier). - Else if
claim.amount >= reputationAmount, re-verify the agent's identity inline; if qualified, accept (discount tier); else 402.
This makes reputation a payment primitive, not just an off-chain label.
- Compatible with the rest of Arc402 — same EIP-712 claim signature, same settlement path
- Composes with Circle's published ERC-8004 standard rather than inventing a new registry
- Not in Circle Nanopayments / Gateway — those settle based on USDC balance only and have no native identity-awareness
- Enables a class of features Circle's hosted stack structurally cannot: per-identity rate limits, KYC-gated pricing, reputation-as-collateral patterns
Not yet in V2 contract. Documented here as a design proposal for v0.3 / W4 work.
Today, once an agent signs a claim and the service settles, the agent has no on-chain recourse if the service delivered garbage. AI outputs in particular are uneven; an agent paying 0.005 USDC for a useless LLM response has no native way to claw it back.
This is not addressed by:
- Circle Nanopayments (pure-payment layer, no quality awareness)
- x402 (same)
- Lightning (HTLCs are simple locks, no AI-output semantics)
- ERC-8183 (evaluator-arbitrated, but too heavy for per-call streaming)
struct RefundableClaim {
address agent;
uint256 amount;
uint256 nonce;
uint256 expiry;
uint64 refundWindow; // seconds after settle within which agent can dispute
bytes32 serviceCommitment; // hash of (e.g.) the agent's intended use, or the service's quality pledge
bytes signature;
}
function claimWithRefundWindow(RefundableClaim calldata c) external;
function disputeAndRefund(uint256 nonce, bytes32 disputeAttestation) external;
function collectAfterWindow(uint256 nonce) external;Flow:
- Service calls
claimWithRefundWindow. USDC is debited from agent's escrow but escrowed inside the contract forrefundWindowseconds, not transferred to the service yet. - During the window:
- Agent can call
disputeAndRefund(nonce, attestation). Funds return to the agent. - The dispute attestation hash is stored on chain — for accountability / off-chain arbitration.
- Agent can call
- After the window expires, service calls
collectAfterWindow(nonce)to receive the funds.
- Honest service + honest agent: window expires without dispute; service collects. Same outcome as V2 with a delay.
- Bad service + honest agent: agent disputes; agent recovers funds. Service can be blacklisted off-chain.
- Honest service + dishonest agent (dispute spam): service stops serving them. Dispute frequency becomes a reputation signal (writable to ERC-8004 ReputationRegistry — future work).
- No mutual cooperation: cancels out the same way HTLCs cancel out on Lightning — no party gets the funds.
- Service deposit / slashing for repeated dispute losses?
- Standardised dispute attestation format?
- Integration with ERC-8004 ReputationRegistry (write-side, not just read)?
- Refund-window batching: can
claimBatchWithRefundexist, and how does it accounting?
This section is a design proposal, not implementation. The author commits to drafting an EIP-style proposal alongside the formal v0.3 spec once V2 audit is complete.
Deliberately not specified yet (slated for v0.4+):
- Multi-asset support: EURC and other Arc-native stablecoins
- Sub-millicent settlement: state channels or Merkle-batched proofs for service prices below $0.002/call
- Cross-chain settlement: an agent on chain A pays a service on chain B via CCTP-bridged claims
- ERC-8004 write-side integration: writing reputation events on disputes (see §12)
- EIP-3009 interop layer: translator so the same payload can be presented to Circle Gateway and Arc402
Contributions welcome via the GitHub repo.