x402 Payments
x402 is a Coinbase-led standard for HTTP-native micropayments. A server signals "this resource costs money" with a real HTTP 402; the client signs a one-time USDC transfer authorisation (EIP-3009), replays the request with the signature in a header, and the server validates and serves the response. Settlement happens on Base (EVM L2) at fractions of a cent per call.
The tnsai-payments module implements this end-to-end behind the PaymentBroker SPI. Your agent code calls broker.quote(...) then broker.settle(...) — the EIP-712 typed-data hashing, secp256k1 signing, header construction, and idempotency tracking happen below the line.
Module dependency
tnsai-payments is opt-in — depend on it only when you actually settle payments. The crypto stack (org.web3j:crypto — the slice of web3j without JSON-RPC or contract bindings, ~1.5 MB transitive) lives in this module so the rest of the framework stays light.
<dependency>
<groupId>io.github.tansuasici</groupId>
<artifactId>tnsai-payments</artifactId>
</dependency>Version is inherited from the BOM — pin the BOM, not the module.
Quickstart
The minimum agent flow has three pieces: build a wallet, configure a broker, call quote → settle. Below is the smallest working snippet against Base Sepolia (testnet):
import com.tnsai.payment.*;
import com.tnsai.payment.x402.*;
import com.tnsai.identity.*;
import java.math.BigDecimal;
import java.net.URI;
import java.util.Map;
import java.util.Optional;
// 1. Wallet — see "Wallet options" below for production guidance
Wallet wallet = EnvKeystoreWallet.fromEnv("SONA_X402", Network.BASE_SEPOLIA);
// 2. Broker configured with the wallet + network
PaymentBroker broker = X402PaymentBroker.create(
X402Config.builder()
.wallet(wallet)
.network(Network.BASE_SEPOLIA)
.facilitator(URI.create("https://facilitator.x402.org"))
.build());
// 3. Describe what the payer is paying for. The resource URL goes in
// service.metadata under the special key METADATA_RESOURCE_URL — the
// broker probes it for the 402 response.
Service embed = new Service(
"embed.text",
"request",
BigDecimal.ONE,
Optional.of(new BigDecimal("0.001")), // expected price, drift-tolerant
Map.of(X402PaymentBroker.METADATA_RESOURCE_URL,
"https://api.example.com/embed"));
Quote quote = broker.quote(payer, payee, embed);
Settlement result = broker.settle(quote);
switch (result) {
case Settlement.Settled s -> System.out.println("paid: " + s.transactionId());
case Settlement.AlreadySettled a -> System.out.println("dedup: " + a.transactionId());
case Settlement.Rejected r -> System.err.println("rejected: " + r.reason());
case Settlement.Expired e -> System.err.println("quote expired");
}That's the whole agent-side surface. The broker handles the HTTP 402 probe, the EIP-712 signature, and the second request with the X-PAYMENT header internally.
How the dance works
For reference — the broker walks this protocol on every quote/settle pair:
Agent Broker Resource Server
│ │ │
│ quote(payer, payee, service) │
├────────────────────►│ │
│ │ GET /embed │
│ ├────────────────────────►│
│ │ HTTP 402 + { accepts } │
│ │◄────────────────────────┤
│ │ pick scheme, │
│ │ validate slippage, │
│ │ mint Quote │
│ Quote │ │
│◄────────────────────┤ │
│ │ │
│ settle(quote) │ │
├────────────────────►│ │
│ │ build EIP-3009 │
│ │ transferWithAuth, │
│ │ sign EIP-712 (wallet) │
│ │ GET /embed │
│ │ X-PAYMENT: <base64> │
│ ├────────────────────────►│
│ │ HTTP 200 + body │
│ │ X-PAYMENT-RESPONSE │
│ │◄────────────────────────┤
│ Settlement.Settled│ emit liability record │
│◄────────────────────┤ │Wallet options
Three Wallet implementations ship today, each with a different security posture:
| Wallet | Use for | Key material |
|---|---|---|
InMemoryWallet.forTesting(address, network) | Unit tests only — refuses to sign with a real curve, deterministic bytes | None — synthetic |
Web3jWallet.fromPrivateKey(hex, network) | Local dogfood, smoke tests against testnet | Raw private key passed in code |
EnvKeystoreWallet.fromEnv(prefix, network) | Single-tenant production, single-user Sona, CI testnet runs | Raw private key in <PREFIX>_PRIVATE_KEY env var |
EnvKeystoreWallet
The recommended starter wallet. With prefix SONA_X402:
export SONA_X402_PRIVATE_KEY=0x...Wallet wallet = EnvKeystoreWallet.fromEnv("SONA_X402", Network.BASE_SEPOLIA);Whitespace is trimmed; the prefix lookup is case-insensitive; missing / blank / malformed inputs all throw with operator-actionable messages.
The env var is a real attack surface — it's visible to anyone with process-listing access and lands in the env table of any child process. Treat it like a password: inject from a secret manager at process start; never check it into source.
What about encrypted JSON keystore files?
The standard Ethereum JSON keystore (the file format Geth, MetaMask export, and WalletUtils.generateNewWalletFile produce) is scrypt-encrypted and passphrase-protected. Decrypting it needs org.web3j:core, which drags in JSON-RPC + contract-binding code that the payments module otherwise doesn't need.
The deferred decision is whether to inline scrypt + AES (keep the slim profile) or pull the larger artifact when there's demonstrated demand. If you need keystore-file support today, you can either:
- Decrypt the keystore in your own bootstrap and pass the raw key to
Web3jWallet.fromPrivateKey, or - Open an issue describing your use case — we'll prioritise the call based on actual demand.
Production: zero in-JVM key material
For deployments where keeping the private key out of JVM memory is a hard requirement (regulated environments, multi-tenant fleets), DelegatedWallet will proxy signing to an external service — KMS, HSM, a signing daemon, or a hardware wallet. The Wallet SPI surface stays identical; only the construction path changes. Tracked as a follow-up issue.
Idempotency
Every Quote carries an idempotencyKey derived deterministically from the service description + resource URL. The broker maps that key to the EIP-3009 nonce via Keccak-256:
nonce = keccak256(idempotencyKey)Two consequences:
- Same key → same nonce → facilitator dedups. A retried
settle(quote)against a flaky network won't double-charge; the facilitator returns the prior transaction, and the broker surfaces it asSettlement.AlreadySettledwith the originaltransactionId. - Same service description → same key by default. If your agent regenerates a
Quotefor the exact same logical operation (e.g. retrying after a process crash), it will land on the same idempotency key and settle exactly once.
If you need to force a different key — for example, two genuinely distinct invocations that happen to have identical service descriptions — supply your own:
Service embed = new Service(
"embed.text", "request", BigDecimal.ONE, Optional.empty(),
Map.of(
X402PaymentBroker.METADATA_RESOURCE_URL, resourceUrl,
"idempotencyKey", "explicit-key-" + UUID.randomUUID()));Mandate enforcement
Wire an AuthorityScope into the broker config to cap how much an agent can spend within a validity window:
import com.tnsai.accountability.AuthorityScope;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
AuthorityScope dailyCap = new AuthorityScope(
Set.of(), // allowedActions: empty = any type
Set.of(), // targetSystems: empty = any target
Optional.of(new BigDecimal("0.50")), // $0.50 daily cap
Duration.ofDays(1),
java.time.Instant.now());
X402Config cfg = X402Config.builder()
.wallet(wallet)
.network(Network.BASE_SEPOLIA)
.facilitator(URI.create("https://facilitator.x402.org"))
.liabilitySink(new FilesystemLiabilitySink(Path.of("/var/log/sona-audit.jsonl")))
.authorityScope(dailyCap)
.build();Before each settle, the broker:
- Reads
authorityScope.spendCeilingUSD. - Sums prior successful
x402.settlerecords from the configuredLiabilitySink(filtered by payer + scope window). - Rejects with
Settlement.Rejected("mandate ceiling exceeded: ...")if the next settlement would push cumulative spend past the cap.
Conservative default
A configured ceiling with no LiabilitySink wired falls back to "block on first settle". The alternative — assume zero prior spend — would silently bypass the ceiling after every process restart, which is the wrong default for a financial control. If you want a ceiling, wire a sink.
Liability records
When a LiabilitySink is configured, every terminal Settlement produces one AgentLiabilityRecord:
| Outcome | LiabilityClass |
|---|---|
Settled | MEDIUM |
Rejected | HIGH |
Expired | LOW |
AlreadySettled | (no new record — the original settlement already produced one) |
The record's action.parameters carries quoteId, priceUSD, idempotencyKey, network, payeeId, serviceName, outcomeType — so audit queries don't have to join through the broker. correlationId is the quoteId, for end-to-end traceability with OpenTelemetry spans and LLMCallLog entries.
A sink that throws is logged at WARN and swallowed: a sink outage must not mask the settlement outcome itself.
Slippage protection
If you pass Service.expectedPriceUSD, the broker enforces a slippage budget (default 50 basis points, configurable via X402Config.maxSlippageBps). When the server's quoted price drifts past tolerance, quote() throws QuoteRejectedException instead of accepting a runaway charge. Tighten the tolerance for budget-sensitive agents:
X402Config.builder()
// ...
.maxSlippageBps(new BigDecimal("10")) // 10 bps (0.1%)
.build();Networks
The Network enum maps each shipped settlement chain to its canonical x402 slug + EVM chain id:
| Network | Family | Chain id | Use for |
|---|---|---|---|
BASE_MAINNET | EVM | 8453 | Real USDC. Real money. Configure after testnet validation. |
BASE_SEPOLIA | EVM | 84532 | Faucet USDC. Free testnet. The default for development. |
SOLANA_MAINNET | SVM | — | Stub today. Ed25519 signing scheme; not yet wired. |
Adding a network here is a deliberate choice — implies a tested chain RPC, a working facilitator, and a settlement-asset address (USDC etc.) the adapter trusts.
Troubleshooting
QuoteRejectedException: service.metadata['x402.resource'] is required for x402 quoting
You called broker.quote(...) with a Service that didn't have x402.resource in its metadata. The broker needs the resource URL to probe for the 402 response. Set it explicitly:
Service s = new Service(name, unit, qty, expected,
Map.of(X402PaymentBroker.METADATA_RESOURCE_URL, "https://api.example.com/..."));QuoteRejectedException: expected HTTP 402 from ..., got 200
The server returned a successful response without asking for payment — probably because the resource is free for the agent identity that called it (allowlisted, signed-in, anonymous tier, etc.). x402 only kicks in when the server returns a real 402. If you're hitting an x402-enabled endpoint and seeing 200, you may have already paid (check for an existing session cookie / API key in the request) or the endpoint is not actually paid.
QuoteRejectedException: price ... outside slippage tolerance
The server's quoted price drifted past maxSlippageBps away from your Service.expectedPriceUSD. Either widen the tolerance, update your expected price, or treat this as a signal that the server pricing changed and refuse to pay.
Settlement.Rejected: mandate ceiling exceeded
The configured AuthorityScope.spendCeilingUSD would be exceeded by this settlement. Either:
- Wait — the ceiling resets after
AuthorityScope.validForelapses. - Issue a new scope with a higher ceiling (typically requires elevated permission in your agent).
- Investigate cumulative spend in the liability sink —
AuditQuery.builder().agentId(payerId).build()returns every recorded settlement.
Settlement.Rejected: facilitator rejected payment: ...
The facilitator validated the signed authorisation and refused it. Common causes: payer wallet has insufficient USDC balance, the EIP-3009 validBefore window already closed, or the nonce was already consumed by an earlier settlement (idempotency replay should show as AlreadySettled, not Rejected — if you see Rejected here, the broker's idempotency cache may have been cleared).
Build error: cannot find symbol: class WalletUtils
You added tnsai-payments to your dependencies and tried to use WalletUtils.loadCredentials. That class lives in org.web3j:core, which tnsai-payments deliberately does not pull in. See Wallet options → What about encrypted JSON keystore files? for the path forward.
Related
- Payments index — broader payments overview.
- Security: Accountability —
AgentLiabilityRecord,LiabilitySink,AuthorityScope. - Security: Cost Governance — broader LLM/agent spend budgeting.
- Reference: SPI —
PaymentBrokerinterface. - TNS-449 — the implementation issue.
- x402 protocol spec — the underlying standard.