diff --git a/CHANGELOG.md b/CHANGELOG.md index 2532ccf..6c1f698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,47 @@ All notable changes to Agents.KT are documented here. The format follows [Keep a ## [Unreleased] +### Changed — x402 buyer trust hardening: guardrails are now mandatory and bind more (#4528) + +An external audit flagged that the "guardrails-first" buyer had **optional** guardrails (an empty +`X402SpendPolicy` defaulted in), checked only amount/network/payTo, and paid the seller's *first* offer. +Hardened (breaking, pre-1.0): + +- **Policy is mandatory** — `X402Account.fromPrivateKey` no longer defaults the policy; an intentionally + unbounded wallet must pass the explicitly-named `X402SpendPolicy.unsafeAllowAllForTesting()`. +- **Stronger binding** — `X402SpendPolicy` gains `allowedAssets` (pin the token), `allowedResourceOrigins` + (pin the endpoint `scheme://host[:port]`), and `maxAuthorizationLifetimeSeconds` (the signed `validBefore` + is clamped to it, so a seller's `maxTimeoutSeconds` can't mint a long-lived authorization). A policy-approved + recipient no longer implies any token, any URL, or any duration. +- **Deterministic offer selection** — new `X402OfferSelector` (`X402Client(account, selector = …)`); the + default `LowestAmount` pays the cheapest permitted offer instead of the seller's first, so a seller can't + order `accepts[]` to steer the buyer to the costliest. `X402OfferSelector.FirstAllowed` restores the prior + behavior explicitly. + +Migration: pass a real `X402SpendPolicy` (or `unsafeAllowAllForTesting()`) to `fromPrivateKey`. 6 new tests. + +### Added — x402 buyer: cross-payment limits + a signer seam (#4528) + +- **Session/velocity limits** — `X402Client(account, sessionLimits = X402SessionLimits(maxPayments, + maxTotalValue, maxPaymentsPerPayee, cooldownMillis), spendStore = …)` bounds the *aggregate* a buyer may + spend across many calls (the per-payment policy only bounds one). Settled payments are recorded in an + `X402SpendStore` (default per-process `InMemorySpendStore`; back it with a durable store in production so a + restart can't reset a cumulative cap). A limit-exceeding payment raises `X402PaymentDeniedException` before + any signature. +- **`X402Signer` seam** — `X402Account.fromSigner(signer, policy)` signs through an `X402Signer` instead of + owning a raw private key, so a deployment can sign with a **KMS / HSM / wallet-service / scoped session key** + and keep permanent keys out of the application heap. `fromPrivateKey` now wraps a `LocalKeySigner` (the + default in-process key). 9 new tests. + +### Added — x402 accepts CAIP-2 network ids (v2 interop step) (#4528) + +x402 **v2** identifies networks with CAIP-2 ids (`eip155:84532`) instead of casual strings (`base-sepolia`). +The buyer now resolves any EVM `eip155:` network on an offer, so it can pay a v2 seller's offers. The +current wire is otherwise **x402 v1** (`X-PAYMENT` / `X-PAYMENT-RESPONSE` headers, `x402Version: 1`); full v2 +transport (the `PAYMENT-REQUIRED` / `PAYMENT-SIGNATURE` / `PAYMENT-RESPONSE` headers + v2 payload envelope) is +deferred until the v2 payload schema is verified against a live v2 facilitator — we don't ship a wire we can't +test against the spec. So: **experimental, x402 v1-compatible (CAIP-2 network ids accepted).** + ## [0.8.1] - 2026-06-20 ### Added — x402 buyer side: agents can autonomously pay (experimental) (#4528, epic #4526) diff --git a/docs/x402.md b/docs/x402.md index 85586fa..8efbe91 100644 --- a/docs/x402.md +++ b/docs/x402.md @@ -45,24 +45,30 @@ settle. ```kotlin val account = X402Account.fromPrivateKey( privateKeyHex = System.getenv("X402_KEY"), // below the model layer — never in a prompt - policy = X402SpendPolicy( + policy = X402SpendPolicy( // REQUIRED — there is no default "allow all" maxValuePerPayment = BigInteger.valueOf(10_000), // hard cap per payment (atomic units) allowedPayTo = setOf("0xKnownSeller"), // pin recipients (neutralizes redirected-payTo injection) allowedNetworks = setOf("base", "base-sepolia"), - confirm = { plan -> humanApproves(plan) }, // optional HITL gate; sees network/payTo/value/resource + allowedAssets = setOf("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), // pin the token — not any token + allowedResourceOrigins = setOf("https://seller.example"), // pin the endpoint — not any URL + maxAuthorizationLifetimeSeconds = 120, // cap how long the signed authorization stays valid + confirm = { plan -> humanApproves(plan) }, // optional HITL gate; sees network/payTo/value/asset/resource ), ) -val client = X402Client(account) +val client = X402Client(account) // offer selector defaults to LowestAmount val resp = client.get("https://seller.example/premium") // 402 -> sign -> retry -> 200 // read the settlement receipt: resp.headers().firstValue("X-PAYMENT-RESPONSE") ``` -What happens on a `402`: the client parses the seller's `accepts[]`, picks the **first** offer the policy -permits (supported scheme, known token domain + chainId, within caps), builds an EIP-3009 -`transferWithAuthorization`, signs the EIP-712 digest, and replays the request with a base64 `X-PAYMENT` -header. Nothing acceptable → `X402PaymentDeniedException` listing why each offer was skipped — **no signature, -no money moved**. +The policy is **mandatory** — `fromPrivateKey` has no default; an intentionally unbounded wallet must pass the +explicitly-named `X402SpendPolicy.unsafeAllowAllForTesting()`. What happens on a `402`: the client parses the +seller's `accepts[]`, keeps every offer the policy permits (supported scheme, known token domain + chainId, +within caps + the asset/origin allowlists), and the **`X402OfferSelector`** chooses which to pay — the default +`LowestAmount` picks the cheapest, so a seller cannot order `accepts[]` to steer the buyer to the costliest +offer (`X402OfferSelector.FirstAllowed` restores the old first-acceptable behavior if you want it). It then +signs the EIP-3009 `transferWithAuthorization` (with `validBefore` clamped to `maxAuthorizationLifetimeSeconds`) +and replays the request. Nothing acceptable → `X402PaymentDeniedException` — **no signature, no money moved**. ### The guardrails are not optional theater @@ -71,8 +77,13 @@ x402 moves irreversible money and the canonical failure is a prompt-injected age - The **key lives in `X402Account`**, constructed in operator code — never serialized, logged, or placed in a prompt. The LLM drives the *request*; it cannot read the key or widen the policy. -- **`X402SpendPolicy` is checked before any signature.** `maxValuePerPayment` bounds the blast radius; - `allowedPayTo` / `allowedNetworks` pin the counterparty; `confirm` adds a human in the loop. +- **`X402SpendPolicy` is mandatory and checked before any signature.** `maxValuePerPayment` bounds the blast + radius; `allowedPayTo` / `allowedNetworks` / `allowedAssets` / `allowedResourceOrigins` pin *who*, *where*, + *which token*, and *which endpoint*; `maxAuthorizationLifetimeSeconds` caps how long a signed authorization + lives; `confirm` adds a human in the loop. A policy-approved recipient does **not** imply any token, any URL, + or any duration. +- **The buyer, not the seller, picks the offer.** The seller orders `accepts[]`; the `X402OfferSelector` + (default `LowestAmount`) chooses which permitted offer to pay. - The buyer is **gasless** — EIP-3009 means the *facilitator* submits the tx and pays gas, so a wallet needs USDC but not necessarily native gas. @@ -118,10 +129,38 @@ class RecoveringFacilitator(val expected: String) : FacilitatorClient { | LLM touches money? | No (HTTP-layer gate) | No (drives the request; can't sign or widen policy) | | Failure mode | Fails closed → `402` | `X402PaymentDeniedException` → no payment | +## Cross-payment limits + a signer seam + +The per-payment policy bounds *one* authorization; **session limits** bound the *aggregate* across many calls: + +```kotlin +val client = X402Client( + account, + sessionLimits = X402SessionLimits(maxPayments = 20, maxTotalValue = BigInteger.valueOf(50_000), + maxPaymentsPerPayee = 5, cooldownMillis = 1_000), + spendStore = InMemorySpendStore(), // back with a durable store in production +) +``` + +Settled payments are recorded in the `X402SpendStore`; a payment that would cross a cap raises +`X402PaymentDeniedException` before any signature. And the signing key need not live in heap — +`X402Account.fromSigner(signer, policy)` signs through an **`X402Signer`** (a KMS / HSM / wallet-service / +scoped session key); `fromPrivateKey` is just the `LocalKeySigner` convenience. + +## Protocol version + +The wire is **x402 v1** (`X-PAYMENT` / `X-PAYMENT-RESPONSE` headers, `x402Version: 1`). The one v2 change wired +in so far is **CAIP-2 network ids** — the buyer resolves an offer whose `network` is `eip155:` (e.g. +`eip155:84532`), so it can pay a v2 seller. Full v2 transport (the `PAYMENT-REQUIRED` / `PAYMENT-SIGNATURE` / +`PAYMENT-RESPONSE` headers and the v2 payload envelope) is deferred until the v2 payload schema is verified +against a live v2 facilitator — so describe support as **experimental, x402 v1-compatible (CAIP-2 accepted)**, +not "x402 v2". + ## Not yet -Scoped ERC-4337 session keys (on-chain caps — the strongest guardrail), the `upto` metered scheme, Solana, -cross-payment velocity limits, and an agent-tool wrapper (`payForResource`). +Full x402 v2 transport (header + payload envelope, pending spec verification), scoped ERC-4337 session keys +(on-chain caps — the strongest guardrail), the `upto` metered scheme, Solana, and an agent-tool wrapper +(`payForResource`). ## Related diff --git a/src/main/kotlin/agents_engine/x402/InMemorySpendStore.kt b/src/main/kotlin/agents_engine/x402/InMemorySpendStore.kt new file mode 100644 index 0000000..6598a21 --- /dev/null +++ b/src/main/kotlin/agents_engine/x402/InMemorySpendStore.kt @@ -0,0 +1,29 @@ +package agents_engine.x402 + +import java.math.BigInteger + +/** + * `agents_engine/x402/InMemorySpendStore.kt` — #4528 (PRD §12.8). Per-process [X402SpendStore] default — + * thread-safe, but **not durable**: counters reset on restart. Fine for a single short-lived session; back + * production deployments with a persistent store so a crash can't reset a cumulative spend cap. + */ +class InMemorySpendStore : X402SpendStore { + private data class Entry(val payee: String, val value: BigInteger, val atMillis: Long) + + private val entries = mutableListOf() + private val lock = Any() + + override fun record(payee: String, value: BigInteger, atMillis: Long) { + synchronized(lock) { entries += Entry(payee.lowercase(), value, atMillis) } + } + + override fun count(): Int = synchronized(lock) { entries.size } + + override fun total(): BigInteger = + synchronized(lock) { entries.fold(BigInteger.ZERO) { acc, e -> acc + e.value } } + + override fun countForPayee(payee: String): Int = + synchronized(lock) { entries.count { it.payee == payee.lowercase() } } + + override fun lastPaymentMillis(): Long? = synchronized(lock) { entries.maxOfOrNull { it.atMillis } } +} diff --git a/src/main/kotlin/agents_engine/x402/LocalKeySigner.kt b/src/main/kotlin/agents_engine/x402/LocalKeySigner.kt new file mode 100644 index 0000000..8624c61 --- /dev/null +++ b/src/main/kotlin/agents_engine/x402/LocalKeySigner.kt @@ -0,0 +1,24 @@ +package agents_engine.x402 + +import agents_engine.x402.crypto.Hex +import agents_engine.x402.crypto.Secp256k1 +import java.math.BigInteger + +/** + * `agents_engine/x402/LocalKeySigner.kt` — #4528 (PRD §12.8). The default [X402Signer]: an in-process secp256k1 + * private key. Derives the payer [address] from the key and signs EIP-712 digests with it (RFC-6979 + low-s + + * recovery byte — see [Secp256k1]). + * + * Simplest and most common, but the key lives in heap — for production, prefer a KMS/HSM/session-key signer + * behind the [X402Signer] seam. Construct via [X402Account.fromPrivateKey] (which wraps a key in this) or + * directly for [X402Account.fromSigner]. + */ +class LocalKeySigner(privateKeyHex: String) : X402Signer { + private val privateKey: BigInteger = BigInteger(1, Hex.decode(privateKeyHex)).also { + require(it.signum() > 0) { "private key must be non-zero" } + } + + override val address: String = Secp256k1.deriveAddress(privateKey) + + override fun sign(digest: ByteArray): String = Secp256k1.sign(digest, privateKey).toHex() +} diff --git a/src/main/kotlin/agents_engine/x402/X402Account.kt b/src/main/kotlin/agents_engine/x402/X402Account.kt index 3f6936d..014a181 100644 --- a/src/main/kotlin/agents_engine/x402/X402Account.kt +++ b/src/main/kotlin/agents_engine/x402/X402Account.kt @@ -3,21 +3,20 @@ package agents_engine.x402 import agents_engine.mcp.McpJson import agents_engine.x402.crypto.Eip712 import agents_engine.x402.crypto.Eip712Domain -import agents_engine.x402.crypto.Hex -import agents_engine.x402.crypto.Secp256k1 import java.math.BigInteger import java.time.Instant import java.util.Base64 /** - * `agents_engine/x402/X402Account.kt` — #4528 (PRD §12.8). The **buyer's signing key holder**. Given a + * `agents_engine/x402/X402Account.kt` — #4528 (PRD §12.8). The **buyer's payment authorizer**. Given a * seller's [PaymentRequirements], it builds an [EIP-3009](https://eips.ethereum.org/EIPS/eip-3009) - * `TransferWithAuthorization`, signs it (EIP-712, [Secp256k1]/[Eip712]), and packs the x402 `X-PAYMENT` - * header — but only after [policy] permits the payment. + * `TransferWithAuthorization`, builds the EIP-712 digest ([Eip712]), delegates the signature to an + * [X402Signer], and packs the x402 `X-PAYMENT` header — but only after [policy] permits the payment. * - * **Below the model layer (the whole point of #4528).** The private key is held here in operator code and is - * never serialized, logged, or placed in a prompt; the LLM drives the *request* but can neither read the key - * nor widen [policy]. Construct one per wallet and inject it into an [X402Client]. + * **Below the model layer (the whole point of #4528).** The signing key lives in the [X402Signer] (a local + * key, or a KMS/HSM/session-key), never serialized, logged, or placed in a prompt; the LLM drives the + * *request* but can neither reach the key nor widen [policy]. Construct one per wallet and inject it into an + * [X402Client]. * * The token's EIP-712 domain (`name`/`version`) is read from `requirements.extra` (sellers advertise it, e.g. * USDC = `"USD Coin"`/`"2"`); the [chainId] for the network comes from the built-in map plus any @@ -25,12 +24,14 @@ import java.util.Base64 * to sign (see [reasonCannotPay]). */ class X402Account private constructor( - private val privateKey: BigInteger, - val address: String, + private val signer: X402Signer, val policy: X402SpendPolicy, private val chainIds: Map, private val clockSeconds: () -> Long, ) { + /** The payer's address (from the [X402Signer]). */ + val address: String get() = signer.address + /** * Why this account will not pay [requirements], or null if it will. Checks scheme support, the token EIP-712 * domain, the network → chainId mapping, then the [policy] guardrails. An [X402Client] uses this to pick a @@ -59,12 +60,17 @@ class X402Account private constructor( reasonCannotPay(requirements)?.let { throw X402PaymentDeniedException("refusing to pay: $it") } val now = clockSeconds() + // Clamp the authorization lifetime to the policy cap — a seller's maxTimeoutSeconds cannot mint a + // longer-lived authorization against the buyer's key than the policy allows (shorter is settle-safe). + val lifetime = policy.maxAuthorizationLifetimeSeconds + ?.let { minOf(requirements.maxTimeoutSeconds.toLong(), it) } + ?: requirements.maxTimeoutSeconds.toLong() val authorization = PaymentAuthorization( from = address, to = requirements.payTo, value = parseValue(requirements.maxAmountRequired)!!, validAfter = BigInteger.ZERO, - validBefore = BigInteger.valueOf(now + requirements.maxTimeoutSeconds), + validBefore = BigInteger.valueOf(now + lifetime), nonce = PaymentAuthorization.randomNonce(), ) val domain = Eip712Domain( @@ -73,7 +79,7 @@ class X402Account private constructor( chainId = chainIdFor(requirements.network)!!, verifyingContract = requirements.asset, ) - val signature = Secp256k1.sign(Eip712.digest(domain, authorization), privateKey).toHex() + val signature = signer.sign(Eip712.digest(domain, authorization)) val header = encodeHeader(requirements, authorization, signature, x402Version) return SignedPayment(authorization, signature, header) } @@ -98,7 +104,12 @@ class X402Account private constructor( private fun domainName(r: PaymentRequirements): String? = r.extra["name"] as? String private fun domainVersion(r: PaymentRequirements): String? = r.extra["version"] as? String - private fun chainIdFor(network: String): Long? = chainIds[network.lowercase()] + private fun chainIdFor(network: String): Long? { + val n = network.lowercase() + chainIds[n]?.let { return it } + // x402 v2 uses CAIP-2 network ids; resolve any EVM `eip155:` directly (e.g. eip155:84532). + return if (n.startsWith(CAIP2_EVM_PREFIX)) n.removePrefix(CAIP2_EVM_PREFIX).toLongOrNull() else null + } private fun parseValue(raw: String): BigInteger? = raw.toBigIntegerOrNull()?.takeIf { it.signum() >= 0 } private fun planFor(r: PaymentRequirements, value: BigInteger): PaymentPlan = @@ -106,6 +117,7 @@ class X402Account private constructor( companion object { private const val SCHEME_EXACT = "exact" + private const val CAIP2_EVM_PREFIX = "eip155:" /** x402's common EVM networks → chainId. Buyers on other chains pass `extraChainIds`. */ private val DEFAULT_CHAIN_IDS: Map = mapOf( @@ -124,19 +136,30 @@ class X402Account private constructor( /** * An account from a raw secp256k1 private key (`0x…`, 32 bytes). The buyer address is derived from it. - * [policy] defaults to no guardrails — **set at least `maxValuePerPayment` for any real wallet.** + * [policy] is **required** — guardrails-first is the whole point of the buyer side. For an intentionally + * unbounded wallet (tests, or a deliberate choice) pass `X402SpendPolicy.unsafeAllowAllForTesting()`. * [extraChainIds] augments/overrides the built-in network → chainId map. */ fun fromPrivateKey( privateKeyHex: String, - policy: X402SpendPolicy = X402SpendPolicy(), + policy: X402SpendPolicy, + extraChainIds: Map = emptyMap(), + clockSeconds: () -> Long = { Instant.now().epochSecond }, + ): X402Account = fromSigner(LocalKeySigner(privateKeyHex), policy, extraChainIds, clockSeconds) + + /** + * An account that signs through an arbitrary [X402Signer] (KMS / HSM / wallet-service / scoped session + * key) — so a permanent private key need never live in the application heap. [policy] is required (see + * [fromPrivateKey]). + */ + fun fromSigner( + signer: X402Signer, + policy: X402SpendPolicy, extraChainIds: Map = emptyMap(), clockSeconds: () -> Long = { Instant.now().epochSecond }, ): X402Account { - val key = BigInteger(1, Hex.decode(privateKeyHex)) - require(key.signum() > 0) { "private key must be non-zero" } val chains = DEFAULT_CHAIN_IDS + extraChainIds.mapKeys { it.key.lowercase() } - return X402Account(key, Secp256k1.deriveAddress(key), policy, chains, clockSeconds) + return X402Account(signer, policy, chains, clockSeconds) } } } diff --git a/src/main/kotlin/agents_engine/x402/X402Client.kt b/src/main/kotlin/agents_engine/x402/X402Client.kt index 5e41fe9..c774d06 100644 --- a/src/main/kotlin/agents_engine/x402/X402Client.kt +++ b/src/main/kotlin/agents_engine/x402/X402Client.kt @@ -10,24 +10,34 @@ import java.time.Duration /** * `agents_engine/x402/X402Client.kt` — #4528 (PRD §12.8). The **buyer side** of x402: drives the * `request → 402 → pay → retry` handshake so an agent can autonomously pay for a resource. Wraps a JDK - * [HttpClient]; on a `402 Payment Required` it parses the seller's `accepts[]`, picks the first offer its - * [X402Account] will pay (policy-permitted, supported scheme, known token domain), signs an `X-PAYMENT` - * header, and replays the original request once. + * [HttpClient]; on a `402 Payment Required` it parses the seller's `accepts[]`, keeps every offer its + * [X402Account] will pay (policy-permitted, supported scheme, known token domain), lets the [selector] choose + * **which** to pay, signs an `X-PAYMENT` header, and replays the original request once. * * **The risk lives here**, so the guardrails are not optional theater: the [account]'s [X402SpendPolicy] is - * consulted before any signature, signing stays in [X402Account] (below the model layer), and a rejected - * payment raises [X402PaymentDeniedException] rather than silently overpaying. EXPERIMENTAL — real, + * consulted before any signature, the seller does not get to pick the offer (the [selector] does — + * default [X402OfferSelector.LowestAmount]), signing stays in [X402Account] (below the model layer), and a + * rejected payment raises [X402PaymentDeniedException] rather than silently overpaying. EXPERIMENTAL — real, * irreversible USDC moves when this is pointed at a live facilitator-backed seller. * * @property account the signing wallet + spend policy. * @property http the underlying client (inject one with a proxy/timeout to taste). * @property x402Version protocol version echoed into the `X-PAYMENT` payload. + * @property selector chooses which policy-permitted offer to pay (default: lowest amount, not the seller's first). + * @property sessionLimits optional cross-payment caps (count / total value / per-payee / cooldown) enforced + * against [spendStore]; null = no aggregate limits. + * @property spendStore records settled payments for [sessionLimits] (default: per-process [InMemorySpendStore]; + * use a durable store in production so a restart can't reset a cumulative cap). */ class X402Client( private val account: X402Account, private val http: HttpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS)).build(), private val x402Version: Int = 1, + private val selector: X402OfferSelector = X402OfferSelector.LowestAmount, + private val sessionLimits: X402SessionLimits? = null, + private val spendStore: X402SpendStore = InMemorySpendStore(), + private val clockMillis: () -> Long = { System.currentTimeMillis() }, ) { /** The buyer address that will pay (derived from the account's key). */ val payerAddress: String get() = account.address @@ -35,8 +45,9 @@ class X402Client( /** * Send [request]; if the seller answers `402`, pay and retry once, returning the paid response (read its * `X-PAYMENT-RESPONSE` header for the settlement receipt). A non-`402` first response is returned - * unchanged. Throws [X402PaymentDeniedException] when no offer is payable and [X402Exception] if the `402` - * body can't be parsed. + * unchanged. A settled payment (HTTP 200) is recorded against the [spendStore] for [sessionLimits]. Throws + * [X402PaymentDeniedException] when no offer is payable or a session limit is hit, and [X402Exception] if + * the `402` body can't be parsed. */ fun send(request: HttpRequest): HttpResponse { val first = http.send(request, HttpResponse.BodyHandlers.ofString()) @@ -46,26 +57,41 @@ class X402Client( val paidRequest = HttpRequest.newBuilder(request) { _, _ -> true } .header("X-PAYMENT", payment.header) .build() - return http.send(paidRequest, HttpResponse.BodyHandlers.ofString()) + val paid = http.send(paidRequest, HttpResponse.BodyHandlers.ofString()) + if (paid.statusCode() == HTTP_OK) { + // settled — record for cross-payment limits + spendStore.record(payment.authorization.to, payment.authorization.value, clockMillis()) + } + return paid } /** Convenience: GET [uri], paying if challenged. */ fun get(uri: String): HttpResponse = send(HttpRequest.newBuilder().uri(URI.create(uri)).GET().build()) - /** Parse the `402` offers, choose a payable one, and sign it — or explain why nothing was paid. */ + /** Parse the `402` offers, keep the policy-permitted ones, let the selector choose, and sign — or explain. */ private fun payFor(body: String): SignedPayment { val offers = parseAccepts(body) - if (offers.isEmpty()) throw X402PaymentDeniedException("402 body carried no usable accepts[] offer") - val reasons = mutableListOf() - for (offer in offers) { + val permitted = offers.filter { offer -> val reason = account.reasonCannotPay(offer) - if (reason == null) return account.authorize(offer, x402Version) - reasons += "[${offer.network} ${offer.maxAmountRequired} -> ${offer.payTo}] $reason" + if (reason != null) reasons += "[${offer.network} ${offer.maxAmountRequired} -> ${offer.payTo}] $reason" + reason == null + } + if (permitted.isEmpty()) { + val detail = if (offers.isEmpty()) "no usable accepts[] offer" else reasons.joinToString("; ") + throw X402PaymentDeniedException("no acceptable x402 offer (of ${offers.size}): $detail") } - val detail = reasons.joinToString("; ") - throw X402PaymentDeniedException("no acceptable x402 offer; tried ${offers.size}: $detail") + val chosen = selector.select(permitted) + ?: throw X402PaymentDeniedException("offer selector declined all ${permitted.size} permitted offers") + enforceSessionLimits(chosen) + return account.authorize(chosen, x402Version) + } + + /** Cross-payment caps (count / total / per-payee / cooldown) — checked before any signature. */ + private fun enforceSessionLimits(offer: PaymentRequirements) { + sessionLimits?.reject(offer.payTo, offer.maxAmountRequired.toBigInteger(), spendStore, clockMillis()) + ?.let { throw X402PaymentDeniedException("session limit: $it") } } private fun parseAccepts(body: String): List { @@ -77,5 +103,6 @@ class X402Client( private companion object { const val CONNECT_TIMEOUT_SECONDS = 10L + const val HTTP_OK = 200 } } diff --git a/src/main/kotlin/agents_engine/x402/X402OfferSelector.kt b/src/main/kotlin/agents_engine/x402/X402OfferSelector.kt new file mode 100644 index 0000000..3ab29ac --- /dev/null +++ b/src/main/kotlin/agents_engine/x402/X402OfferSelector.kt @@ -0,0 +1,25 @@ +package agents_engine.x402 + +import java.math.BigInteger + +/** + * `agents_engine/x402/X402OfferSelector.kt` — #4528 (PRD §12.8). Chooses *which* of a `402`'s policy-permitted + * `accepts[]` offers to pay. The seller controls the order of `accepts[]`, so paying the **first** acceptable + * offer lets a seller steer the buyer toward the costliest one — the selector makes the choice the buyer's, + * deterministically. + * + * [select] receives only offers the [X402SpendPolicy] already permits; returning null declines them all. + */ +fun interface X402OfferSelector { + fun select(offers: List): PaymentRequirements? + + companion object { + /** Default — least financial exposure: the lowest `maxAmountRequired`; ties resolve to the first. */ + val LowestAmount: X402OfferSelector = X402OfferSelector { offers -> + offers.minByOrNull { it.maxAmountRequired.toBigIntegerOrNull() ?: BigInteger.ZERO } + } + + /** Compatibility — the seller's first permitted offer (the pre-hardening behavior); opt in explicitly. */ + val FirstAllowed: X402OfferSelector = X402OfferSelector { offers -> offers.firstOrNull() } + } +} diff --git a/src/main/kotlin/agents_engine/x402/X402SessionLimits.kt b/src/main/kotlin/agents_engine/x402/X402SessionLimits.kt new file mode 100644 index 0000000..0960750 --- /dev/null +++ b/src/main/kotlin/agents_engine/x402/X402SessionLimits.kt @@ -0,0 +1,43 @@ +package agents_engine.x402 + +import java.math.BigInteger + +/** + * `agents_engine/x402/X402SessionLimits.kt` — #4528 (PRD §12.8). **Cross-payment** velocity/exposure caps, + * evaluated against an [X402SpendStore] before each payment. The per-payment [X402SpendPolicy] bounds *one* + * authorization; these bound the *aggregate* — so a run of individually-permitted payments can't quietly + * exceed the operator's total intended exposure. Enforced by [X402Client]. + * + * Every limit is opt-in; all set limits must pass. + * + * @property maxPayments cap on the number of settled payments in the session/window; null = no cap. + * @property maxTotalValue cap on cumulative settled value (atomic units); a payment that would cross it is + * refused; null = no cap. + * @property maxPaymentsPerPayee cap on settled payments to any single recipient; null = no cap. + * @property cooldownMillis minimum gap between payments; a payment within the cooldown of the last is refused; + * null = no cooldown. + */ +data class X402SessionLimits( + val maxPayments: Int? = null, + val maxTotalValue: BigInteger? = null, + val maxPaymentsPerPayee: Int? = null, + val cooldownMillis: Long? = null, +) { + /** Why a payment of [value] to [payee] now ([nowMillis]) is refused given [store], or null if allowed. */ + internal fun reject(payee: String, value: BigInteger, store: X402SpendStore, nowMillis: Long): String? { + if (maxPayments != null && store.count() >= maxPayments) { + return "session payment-count limit ($maxPayments) reached" + } + if (maxTotalValue != null && store.total() + value > maxTotalValue) { + return "payment would push session total ${store.total() + value} over maxTotalValue $maxTotalValue" + } + if (maxPaymentsPerPayee != null && store.countForPayee(payee) >= maxPaymentsPerPayee) { + return "per-payee payment limit ($maxPaymentsPerPayee) for '$payee' reached" + } + val last = store.lastPaymentMillis() + if (cooldownMillis != null && last != null && nowMillis - last < cooldownMillis) { + return "cooldown active — ${cooldownMillis - (nowMillis - last)}ms remaining" + } + return null + } +} diff --git a/src/main/kotlin/agents_engine/x402/X402Signer.kt b/src/main/kotlin/agents_engine/x402/X402Signer.kt new file mode 100644 index 0000000..9d0c17d --- /dev/null +++ b/src/main/kotlin/agents_engine/x402/X402Signer.kt @@ -0,0 +1,19 @@ +package agents_engine.x402 + +/** + * `agents_engine/x402/X402Signer.kt` — #4528 (PRD §12.8). The seam between [X402Account] and the actual signing + * key. [X402Account] holds the *policy* and builds the EIP-712 digest; it delegates the secp256k1 signature to + * an `X402Signer`, so the private key need not live as a raw `BigInteger` in the application heap. + * + * The default is [LocalKeySigner] (an in-process key, the simplest case). The seam exists so a deployment can + * sign with a **KMS / HSM / wallet-service / scoped ERC-4337 session key** instead — keeping permanent keys out + * of ordinary memory, which is exactly where the irreversible-money risk wants them not to be. + * + * @property address the payer's Ethereum address (`0x…`, lowercase) — the public half; safe to expose/log. + */ +interface X402Signer { + val address: String + + /** Sign a 32-byte EIP-712 digest, returning the packed `0x r‖s‖v` (65-byte) signature an EIP-3009 carries. */ + fun sign(digest: ByteArray): String +} diff --git a/src/main/kotlin/agents_engine/x402/X402SpendPolicy.kt b/src/main/kotlin/agents_engine/x402/X402SpendPolicy.kt index 8fa8702..cda8d03 100644 --- a/src/main/kotlin/agents_engine/x402/X402SpendPolicy.kt +++ b/src/main/kotlin/agents_engine/x402/X402SpendPolicy.kt @@ -1,6 +1,7 @@ package agents_engine.x402 import java.math.BigInteger +import java.net.URI /** * `agents_engine/x402/X402SpendPolicy.kt` — #4528 (PRD §12.8). The buyer-side **spend guardrails**. x402 moves @@ -9,13 +10,26 @@ import java.math.BigInteger * that this policy doesn't permit. The policy lives **below the model layer** — it is configured in code by * the operator, never carried in a prompt, and the LLM cannot widen it. * - * Every limit is opt-in but composes: a payment must satisfy *all* set constraints. + * **A policy is mandatory** (`X402Account.fromPrivateKey` requires one). An empty allow-set means "no + * restriction on that dimension", so the all-empty policy is unrestricted — to make that posture impossible to + * reach by accident, the only way to build it is the explicitly-named [unsafeAllowAllForTesting]. + * + * Every limit is opt-in but composes: a payment must satisfy *all* set constraints. A policy-approved + * recipient does **not** automatically mean any token, any URL, or any authorization duration — bind those + * with [allowedAssets] / [allowedResourceOrigins] / [maxAuthorizationLifetimeSeconds]. * * @property maxValuePerPayment hard per-payment cap in the token's atomic units (USDC = 6 decimals); null = no * cap. The single most important guardrail — bound the blast radius of one signed authorization. * @property allowedNetworks settlement networks the buyer will pay on (e.g. `"base"`); empty = any network. * @property allowedPayTo recipient address allowlist (case-insensitive); empty = any recipient. Pin this to * known sellers to neutralize a redirected-`payTo` injection. + * @property allowedAssets token-contract allowlist (case-insensitive); empty = any token. Pin this so an + * approved recipient cannot be paid in an unexpected (e.g. worthless or malicious) token. + * @property allowedResourceOrigins `scheme://host[:port]` origins of the paid resource URL; empty = any. Pin + * this so a payment can only be made to a known endpoint, not an arbitrary attacker-supplied URL. + * @property maxAuthorizationLifetimeSeconds cap on how long a signed authorization stays valid; null = accept + * the seller's `maxTimeoutSeconds` as-is. [X402Account] clamps the signed `validBefore` to this cap, so a + * seller cannot mint a long-lived authorization against the buyer's key. * @property confirm human-in-the-loop gate, run after the static checks pass: return false to veto. null = * auto-approve within the static limits. Wire this to a real prompt for high-value or untrusted flows. */ @@ -23,27 +37,57 @@ class X402SpendPolicy( val maxValuePerPayment: BigInteger? = null, val allowedNetworks: Set = emptySet(), val allowedPayTo: Set = emptySet(), + val allowedAssets: Set = emptySet(), + val allowedResourceOrigins: Set = emptySet(), + val maxAuthorizationLifetimeSeconds: Long? = null, val confirm: ((PaymentPlan) -> Boolean)? = null, ) { /** * Approve or reject [plan]. Returns null when permitted; otherwise a human-readable reason the payment was * refused (so the caller can surface *why* nothing was paid). Static limits are checked before [confirm] - * runs, so an HITL prompt only ever sees an already-bounded payment. + * runs, so an HITL prompt only ever sees an already-bounded payment. (The authorization-lifetime cap is + * enforced separately by [X402Account] at signing time, since the plan carries no lifetime.) */ internal fun reject(plan: PaymentPlan): String? { - val value = plan.value - if (maxValuePerPayment != null && value > maxValuePerPayment) { + if (maxValuePerPayment != null && plan.value > maxValuePerPayment) { return "amount ${plan.value} exceeds maxValuePerPayment $maxValuePerPayment" } if (allowedNetworks.isNotEmpty() && plan.network !in allowedNetworks) { return "network '${plan.network}' is not in the allowed set $allowedNetworks" } - if (allowedPayTo.isNotEmpty() && plan.payTo.lowercase() !in allowedPayTo.map { it.lowercase() }.toSet()) { + if (allowedPayTo.isNotEmpty() && !containsIgnoreCase(allowedPayTo, plan.payTo)) { return "payTo '${plan.payTo}' is not in the allowed recipients" } + if (allowedAssets.isNotEmpty() && !containsIgnoreCase(allowedAssets, plan.asset)) { + return "asset '${plan.asset}' is not in the allowed assets" + } + if (allowedResourceOrigins.isNotEmpty()) { + val origin = originOf(plan.resource) + if (origin == null || !containsIgnoreCase(allowedResourceOrigins, origin)) { + return "resource origin of '${plan.resource}' is not in the allowed origins $allowedResourceOrigins" + } + } if (confirm != null && !confirm.invoke(plan)) { return "payment was declined by the confirm() gate" } return null } + + private fun containsIgnoreCase(set: Set, value: String): Boolean = + set.any { it.equals(value, ignoreCase = true) } + + private fun originOf(url: String): String? = runCatching { + val u = URI(url) + val scheme = u.scheme ?: return null + val host = u.host ?: return null + "$scheme://$host${if (u.port == -1) "" else ":${u.port}"}" + }.getOrNull() + + companion object { + /** + * The all-permissive policy (no caps, no allowlists, no HITL). Named to make the unsafe posture an + * explicit, greppable choice — use only in tests or when you genuinely intend an unbounded wallet. + */ + fun unsafeAllowAllForTesting(): X402SpendPolicy = X402SpendPolicy() + } } diff --git a/src/main/kotlin/agents_engine/x402/X402SpendStore.kt b/src/main/kotlin/agents_engine/x402/X402SpendStore.kt new file mode 100644 index 0000000..6ccc36d --- /dev/null +++ b/src/main/kotlin/agents_engine/x402/X402SpendStore.kt @@ -0,0 +1,28 @@ +package agents_engine.x402 + +import java.math.BigInteger + +/** + * `agents_engine/x402/X402SpendStore.kt` — #4528 (PRD §12.8). Records settled payments so [X402SessionLimits] + * can enforce **cross-payment** caps (a sequence of individually-permitted payments must not exceed an + * operator's intended total exposure — the per-payment [X402SpendPolicy] can't see the running total). + * + * The default [InMemorySpendStore] is per-process; a production deployment should back this with a **durable** + * store so the counters survive a restart (otherwise a crash-loop resets the cumulative cap). + */ +interface X402SpendStore { + /** Record a settled payment of [value] to [payee] at [atMillis]. */ + fun record(payee: String, value: BigInteger, atMillis: Long) + + /** Total number of settled payments in this session/window. */ + fun count(): Int + + /** Cumulative settled value in this session/window (atomic units). */ + fun total(): BigInteger + + /** Number of settled payments to [payee] (case-insensitive). */ + fun countForPayee(payee: String): Int + + /** Epoch-millis of the most recent settled payment, or null if none yet. */ + fun lastPaymentMillis(): Long? +} diff --git a/src/test/kotlin/agents_engine/x402/X402AccountTest.kt b/src/test/kotlin/agents_engine/x402/X402AccountTest.kt index 5e5123e..1fed9ab 100644 --- a/src/test/kotlin/agents_engine/x402/X402AccountTest.kt +++ b/src/test/kotlin/agents_engine/x402/X402AccountTest.kt @@ -20,23 +20,29 @@ class X402AccountTest { private val pk = "0x0000000000000000000000000000000000000000000000000000000000000001" private val payerAddress = "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf" + private val usdc = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + private fun requirements( value: String = "1000", network: String = "base-sepolia", payTo: String = "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", scheme: String = "exact", + asset: String = usdc, + resource: String = "https://seller.example/premium", + maxTimeoutSeconds: Int = 60, extra: Map = mapOf("name" to "USD Coin", "version" to "2"), ) = PaymentRequirements( network = network, maxAmountRequired = value, payTo = payTo, - asset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - resource = "/premium", + asset = asset, + resource = resource, scheme = scheme, + maxTimeoutSeconds = maxTimeoutSeconds, extra = extra, ) - private fun account(policy: X402SpendPolicy = X402SpendPolicy()) = + private fun account(policy: X402SpendPolicy = X402SpendPolicy.unsafeAllowAllForTesting()) = X402Account.fromPrivateKey(pk, policy, clockSeconds = { 1_750_000_000L }) @Test @@ -97,11 +103,78 @@ class X402AccountTest { assertTrue("unsupported scheme" in account().reasonCannotPay(requirements(scheme = "upto"))!!) } + @Test + fun `a CAIP-2 EVM network id (eip155 chainId) resolves — x402 v2 interop`() { + // a v2 seller advertises the network as eip155:84532 rather than "base-sepolia" + assertNull(account().reasonCannotPay(requirements(network = "eip155:84532"))) + assertNull(account().reasonCannotPay(requirements(network = "eip155:8453"))) + // a non-EVM / unparseable CAIP-2 network is still refused (no EVM chainId) + assertTrue("no chainId is known" in account().reasonCannotPay(requirements(network = "solana:abc"))!!) + } + @Test fun `an unknown network is refused`() { assertTrue("no chainId is known" in account().reasonCannotPay(requirements(network = "dogechain"))!!) } + @Test + fun `a payment in a non-allowed asset is refused`() { + val acct = account(X402SpendPolicy(allowedAssets = setOf(usdc))) + val reason = acct.reasonCannotPay(requirements(asset = "0xDEADbeefdeadBEEFdeAdBEefDeAdbEEFdeAdbeEf")) + assertTrue("not in the allowed assets" in reason!!, reason) + } + + @Test + fun `an approved asset is permitted`() { + val acct = account(X402SpendPolicy(allowedAssets = setOf(usdc.uppercase()))) // case-insensitive + assertNull(acct.reasonCannotPay(requirements(asset = usdc))) + } + + @Test + fun `a resource outside the allowed origins is refused`() { + val acct = account(X402SpendPolicy(allowedResourceOrigins = setOf("https://seller.example"))) + val reason = acct.reasonCannotPay(requirements(resource = "https://evil.example/drain")) + assertTrue("resource origin" in reason!!, reason) + // the allowed origin (any path under it) passes + assertNull(acct.reasonCannotPay(requirements(resource = "https://seller.example/anything"))) + } + + @Test + fun `the authorization lifetime is clamped to the policy cap`() { + // seller asks for a 1-hour authorization; policy caps lifetimes at 120s + val acct = account(X402SpendPolicy(maxAuthorizationLifetimeSeconds = 120)) + val signed = acct.authorize(requirements(maxTimeoutSeconds = 3600), x402Version = 1) + assertEquals(BigInteger.valueOf(1_750_000_000L + 120), signed.authorization.validBefore) + } + + @Test + fun `unsafeAllowAllForTesting permits everything`() { + val acct = account(X402SpendPolicy.unsafeAllowAllForTesting()) + assertNull(acct.reasonCannotPay(requirements(value = "999999999", payTo = "0xAnyone"))) + } + + @Test + fun `fromSigner delegates signing to the injected X402Signer`() { + val captured = mutableListOf() + val signer = object : X402Signer { + override val address = "0x1111111111111111111111111111111111111111" // valid 20-byte hex + override fun sign(digest: ByteArray): String { captured += digest; return "0x" + "ab".repeat(65) } + } + val acct = X402Account.fromSigner( + signer, X402SpendPolicy.unsafeAllowAllForTesting(), clockSeconds = { 1_750_000_000L }, + ) + assertEquals("0x1111111111111111111111111111111111111111", acct.address) + val signed = acct.authorize(requirements(), x402Version = 1) + assertEquals("0x" + "ab".repeat(65), signed.signature) // the signer's signature is used + assertEquals(1, captured.size) + assertEquals(32, captured.single().size) // it was handed a 32-byte EIP-712 digest + } + + @Test + fun `LocalKeySigner derives the same address as fromPrivateKey`() { + assertEquals(payerAddress, LocalKeySigner(pk).address) + } + @Test fun `authorize produces a verifiable X-PAYMENT header`() { val signed = account().authorize(requirements(value = "1000"), x402Version = 1) diff --git a/src/test/kotlin/agents_engine/x402/X402ClientTest.kt b/src/test/kotlin/agents_engine/x402/X402ClientTest.kt index b6275b6..c6230c6 100644 --- a/src/test/kotlin/agents_engine/x402/X402ClientTest.kt +++ b/src/test/kotlin/agents_engine/x402/X402ClientTest.kt @@ -158,6 +158,21 @@ class X402ClientTest { } } + @Test + fun `session limits stop a second payment after the cap`() { + val (url, stop) = serve(RecoveringFacilitator(payerAddress)) + try { + val policy = X402SpendPolicy(maxValuePerPayment = BigInteger.valueOf(10_000)) + val account = X402Account.fromPrivateKey(pk, policy) + val client = X402Client(account, sessionLimits = X402SessionLimits(maxPayments = 1)) + assertEquals(200, client.get(url).statusCode()) // first payment settles + records + val ex = assertFailsWith { client.get(url) } // second is over the cap + assertTrue("session limit" in ex.message!!, ex.message!!) + } finally { + stop() + } + } + @Test fun `payerAddress exposes the buyer wallet`() { assertEquals(payerAddress, client().payerAddress) diff --git a/src/test/kotlin/agents_engine/x402/X402OfferSelectorTest.kt b/src/test/kotlin/agents_engine/x402/X402OfferSelectorTest.kt new file mode 100644 index 0000000..9bea519 --- /dev/null +++ b/src/test/kotlin/agents_engine/x402/X402OfferSelectorTest.kt @@ -0,0 +1,71 @@ +package agents_engine.x402 + +import agents_engine.generation.LenientJsonParser +import com.sun.net.httpserver.HttpHandler +import com.sun.net.httpserver.HttpServer +import java.math.BigInteger +import java.net.InetSocketAddress +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals + +// #4528 (PRD §12.8) — deterministic offer selection. The seller orders accepts[]; paying the FIRST acceptable +// offer lets a seller steer the buyer to the costliest. The selector makes the choice the buyer's. Default = +// lowest amount; the buyer is not at the mercy of the seller's ordering. +class X402OfferSelectorTest { + + private val usdc = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + private val pk = "0x0000000000000000000000000000000000000000000000000000000000000001" + + private fun offer(amount: String, payTo: String) = PaymentRequirements( + network = "base-sepolia", maxAmountRequired = amount, payTo = payTo, asset = usdc, + resource = "https://seller.example/x", extra = mapOf("name" to "USD Coin", "version" to "2"), + ) + + @Test + fun `LowestAmount picks the cheapest offer, FirstAllowed picks the seller's first`() { + val offers = listOf(offer("5000", "0xExpensive"), offer("1000", "0xCheap"), offer("3000", "0xMid")) + assertEquals("1000", X402OfferSelector.LowestAmount.select(offers)?.maxAmountRequired) + assertEquals("5000", X402OfferSelector.FirstAllowed.select(offers)?.maxAmountRequired) + } + + // A seller that lists an EXPENSIVE offer first, then a cheaper one; on the paid retry it echoes the signed + // amount so the test can prove the client paid the cheaper one rather than the seller's first. + private fun serveTwoOffers(): Pair Unit> { + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + val accepts = listOf(offer("5000", "0x209693Bc6afc0C5328bA36FaF03C514EF312287C"), + offer("1000", "0x209693Bc6afc0C5328bA36FaF03C514EF312287C")) + .map { it.toJsonObject() } + server.createContext("/x", HttpHandler { ex -> + val header = ex.requestHeaders.getFirst("X-PAYMENT") + if (header == null) { + val body = agents_engine.mcp.McpJson.encode(linkedMapOf("x402Version" to 1, "accepts" to accepts)) + .toByteArray() + ex.sendResponseHeaders(402, body.size.toLong()); ex.responseBody.use { it.write(body) } + } else { + @Suppress("UNCHECKED_CAST") + val payload = LenientJsonParser.parse(String(Base64.getDecoder().decode(header))) as Map + @Suppress("UNCHECKED_CAST") + val auth = (payload["payload"] as Map)["authorization"] as Map + val body = "paid:${auth["value"]}".toByteArray() + ex.sendResponseHeaders(200, body.size.toLong()); ex.responseBody.use { it.write(body) } + } + }) + server.start() + return "http://127.0.0.1:${server.address.port}/x" to { server.stop(0) } + } + + @Test + fun `the client pays the cheaper of two seller offers (not the first)`() { + val (url, stop) = serveTwoOffers() + try { + val policy = X402SpendPolicy(maxValuePerPayment = BigInteger.valueOf(10_000)) + val account = X402Account.fromPrivateKey(pk, policy) + val resp = X402Client(account).get(url) // default selector = LowestAmount + assertEquals(200, resp.statusCode()) + assertEquals("paid:1000", resp.body()) // chose 1000, not the seller's first (5000) + } finally { + stop() + } + } +} diff --git a/src/test/kotlin/agents_engine/x402/X402SessionLimitsTest.kt b/src/test/kotlin/agents_engine/x402/X402SessionLimitsTest.kt new file mode 100644 index 0000000..dcc8eac --- /dev/null +++ b/src/test/kotlin/agents_engine/x402/X402SessionLimitsTest.kt @@ -0,0 +1,64 @@ +package agents_engine.x402 + +import java.math.BigInteger +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// #4528 (PRD §12.8) — cross-payment velocity/exposure caps. The per-payment policy can't see the running +// total; these bound the aggregate so a run of individually-permitted payments can't exceed intended exposure. +class X402SessionLimitsTest { + + private val payee = "0xSeller" + private fun storeWith(vararg entries: Pair): InMemorySpendStore = InMemorySpendStore().apply { + entries.forEach { (value, atMillis) -> record(payee, BigInteger.valueOf(value), atMillis) } + } + + @Test + fun `maxPayments caps the session payment count`() { + val limits = X402SessionLimits(maxPayments = 2) + val store = storeWith(100L to 0L, 100L to 0L) // already 2 + assertTrue("payment-count limit" in limits.reject(payee, BigInteger.TEN, store, 100)!!) + } + + @Test + fun `maxTotalValue caps cumulative spend`() { + val limits = X402SessionLimits(maxTotalValue = BigInteger.valueOf(150)) + val store = storeWith(100L to 0L) // total 100 + assertTrue("maxTotalValue" in limits.reject(payee, BigInteger.valueOf(60), store, 100)!!) // 160 > 150 + assertNull(limits.reject(payee, BigInteger.valueOf(50), store, 100)) // 150 == cap, ok + } + + @Test + fun `maxPaymentsPerPayee caps payments to one recipient`() { + val limits = X402SessionLimits(maxPaymentsPerPayee = 1) + val store = storeWith(100L to 0L) + assertTrue("per-payee" in limits.reject(payee, BigInteger.TEN, store, 100)!!) + assertNull(limits.reject("0xDifferent", BigInteger.TEN, store, 100)) // another payee is fine + } + + @Test + fun `cooldown blocks payments inside the window`() { + val limits = X402SessionLimits(cooldownMillis = 1000) + val store = storeWith(100L to 5000L) // last payment at t=5000 + assertTrue("cooldown" in limits.reject(payee, BigInteger.TEN, store, 5500)!!) // 500ms < 1000 + assertNull(limits.reject(payee, BigInteger.TEN, store, 6000)) // 1000ms >= cooldown + } + + @Test + fun `no limits set permits everything`() { + assertNull(X402SessionLimits().reject(payee, BigInteger.valueOf(999_999), InMemorySpendStore(), 0)) + } + + @Test + fun `the in-memory store accumulates count, total and per-payee`() { + val store = InMemorySpendStore() + store.record("0xA", BigInteger.valueOf(100), 1) + store.record("0xA", BigInteger.valueOf(50), 2) + store.record("0xB", BigInteger.valueOf(25), 3) + assertTrue(store.count() == 3) + assertTrue(store.total() == BigInteger.valueOf(175)) + assertTrue(store.countForPayee("0xa") == 2) // case-insensitive + assertTrue(store.lastPaymentMillis() == 3L) + } +}