Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<chainId>` 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)
Expand Down
63 changes: 51 additions & 12 deletions docs/x402.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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:<chainId>` (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

Expand Down
29 changes: 29 additions & 0 deletions src/main/kotlin/agents_engine/x402/InMemorySpendStore.kt
Original file line number Diff line number Diff line change
@@ -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<Entry>()
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 } }
}
24 changes: 24 additions & 0 deletions src/main/kotlin/agents_engine/x402/LocalKeySigner.kt
Original file line number Diff line number Diff line change
@@ -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()
}
59 changes: 41 additions & 18 deletions src/main/kotlin/agents_engine/x402/X402Account.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,35 @@ 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
* [extraChainIds] overrides. An offer missing either, or in an unsupported scheme, is one the account declines
* 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<String, Long>,
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
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}
Expand All @@ -98,14 +104,20 @@ 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:<chainId>` 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 =
PaymentPlan(network = r.network, payTo = r.payTo, value = value, asset = r.asset, resource = r.resource)

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<String, Long> = mapOf(
Expand All @@ -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<String, Long> = 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<String, Long> = 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)
}
}
}
Loading
Loading