tnsai
Payments

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 quotesettle. 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:

WalletUse forKey material
InMemoryWallet.forTesting(address, network)Unit tests only — refuses to sign with a real curve, deterministic bytesNone — synthetic
Web3jWallet.fromPrivateKey(hex, network)Local dogfood, smoke tests against testnetRaw private key passed in code
EnvKeystoreWallet.fromEnv(prefix, network)Single-tenant production, single-user Sona, CI testnet runsRaw 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 as Settlement.AlreadySettled with the original transactionId.
  • Same service description → same key by default. If your agent regenerates a Quote for 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:

  1. Reads authorityScope.spendCeilingUSD.
  2. Sums prior successful x402.settle records from the configured LiabilitySink (filtered by payer + scope window).
  3. 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:

OutcomeLiabilityClass
SettledMEDIUM
RejectedHIGH
ExpiredLOW
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:

NetworkFamilyChain idUse for
BASE_MAINNETEVM8453Real USDC. Real money. Configure after testnet validation.
BASE_SEPOLIAEVM84532Faucet USDC. Free testnet. The default for development.
SOLANA_MAINNETSVMStub 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.validFor elapses.
  • 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.