diff --git a/.env.example b/.env.example index 54a0b2c..1addc69 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,15 @@ EVM_PRIVATE_KEY= # Only needed to run the bundled fixture. EVM_PAYEE_ADDRESS= +# --- x402 payment checks on Ethereum Sepolia (eip155:11155111) -------------- +# The payer is EVM_PRIVATE_KEY above; it needs Circle's Sepolia USDC and no ETH. +# No public facilitator settles Ethereum Sepolia, so the bundled fixture runs +# its own, which pays the gas from this key (0x + 64 hex, holding Sepolia ETH). +# Only needed to run the bundled fixture. +EVM_FACILITATOR_PRIVATE_KEY= +# Optional RPC endpoint for that fixture (default: viem's public Sepolia RPC). +SEPOLIA_RPC_URL= + # --- x402 payment checks on Solana devnet ----------------------------------- # Payer keypair for --network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1, base58 # of its 64 bytes as wallets export it. It needs devnet USDC and no SOL: the diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ea20e..a3b37d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,18 @@ Sepolia, the lying modes fail both checks the default; MPP is Stellar only. Under the hood, the payment checks now go through a per-chain adapter, and the x402 SDK moves from 2.19 to 2.28. +**Added — the x402 payment checks pay on Ethereum Sepolia.** `--network +eip155:11155111` runs `X402-06` to `X402-10` there with the same `EVM_PRIVATE_KEY`, +paying in Circle's Sepolia USDC through EIP-3009, so the payer needs no ETH. The SDK +ships no default asset for this network, and its client's spend controls refuse other +assets: Wasit allows Circle's Sepolia USDC there, with the SDK's own $1 cap. The wait +for a settled transaction is now per network: 10 blocks on Sepolia, whose blocks are 12 +seconds apart. No public facilitator settles Ethereum Sepolia, so Wasit's own fixture +runs the SDK's facilitator itself. Against it, all ten pass with the settlement read back +independently; against `wasit serve` there, the lying modes fail +([evidence](docs/evidence/2026-10-06-ethereum-sepolia-verification-run.md)). BNB Smart +Chain testnet stays read-only. + **Added — the x402 payment checks pay on Solana devnet.** `--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (MCP: `network`) runs `X402-06` to `X402-10` there, with the payer key in `SVM_PRIVATE_KEY` (base58 of the 64-byte keypair, as @@ -99,6 +111,20 @@ refuses `X402-06`'s valid payment, `X402-07` to `X402-10` are skipped rather tha a target that refuses everything proves nothing by refusing a bad payment. `X402-07` used to pass in that case. +**Changed — a price the official client will not pay gets no verdict.** The official x402 +client, which Wasit pays through, pays only the SDK's default assets and at most $1 a +payment. A target above that used to get `X402-06` ERROR (harness) with the SDK's own +configuration advice; it now gets `ERROR (setup)` naming the price or the asset, and +nothing is sent. Wasit keeps the cap: it pays automatically, and on `stellar:pubnet` +with real money. + +**Fixed — a failed settlement names its reason.** A valid payment that verified but did +not settle read `got 402.`. The official SDK server reports why in a `PAYMENT-RESPONSE` +with `success: false`, which Wasit now reads: `got 402 (settlement failed: +invalid_exact_evm_transaction_failed)`. The same reason now shows on refused negative +payments, which is how the public facilitator was seen refusing a replay at settlement +rather than at verify. + **Changed — a payer that cannot cover the price gets no verdict.** `X402-06` reads the payer's balance of the advertised asset before it signs; below the price, it reports `ERROR (setup)` naming the key to fund, sends nothing, and skips `X402-07`–`10`. An diff --git a/docs/CHECKS.md b/docs/CHECKS.md index ad6c642..91a8f36 100644 --- a/docs/CHECKS.md +++ b/docs/CHECKS.md @@ -17,9 +17,9 @@ in every test report. | `X402-03` | Header Payload Decodable | x402 spec §payment-required-object | The header value must be valid base64 that decodes to JSON | `atob()` + `JSON.parse()` succeed without error | | `X402-04` | Required Fields Present | x402 spec §payment-required-object | The payload must include the core payment terms, under the field names its own advertised version requires | Checked in **every** payment option in `accepts`, each reported by its index when the challenge offers more than one; a challenge with no options fails. In each option, every field the advertised `x402Version` requires is present: for v2 `scheme`, `network`, `amount`, `asset`, `payTo` and `maxTimeoutSeconds` (spec 5.1.2); for v1 `scheme`, `network`, `maxAmountRequired`, `asset`, `payTo`, `resource`, `description` and `maxTimeoutSeconds` (v1 spec 5.1). Each is a non-empty string, except `maxTimeoutSeconds`, a positive number of seconds; a field present with the wrong type is reported as such rather than as missing. The price field follows the version: `maxAmountRequired` for v1, `amount` for v2 (renamed in v2, which also moves the resource out of the option). The version is read from the challenge rather than accepting whichever name happens to appear, because a service advertising `x402Version: 2` while emitting the v1 field name is not conformant to the version it claims — and reporting that as a merely absent price would hide the actual defect. An unrecognised version fails: the field names cannot be checked against a version whose schema is unknown. | | `X402-05` | Network Identifier Valid | x402 v2 spec §11.1; CAIP-2 | Every advertised network id is CAIP-2 | Every option's `network` is a CAIP-2 identifier, `namespace:reference` with a 3 to 8 character lowercase namespace and a reference of at most 32 characters, as x402 v2 requires. Where the namespace's own CAIP-2 definition fixes the reference, that is checked too: `stellar` is `testnet` or `pubnet`; `eip155` is the chain id in base 10 (`eip155:84532`, not `eip155:0x14a34`); `solana` is the first 32 characters of the base58 genesis hash. A well-formed id in another namespace passes, and the result says only the format was checked there: x402 v2 asks for CAIP-2 and nothing more, so failing it would report Wasit's own lack of rules as the target's defect. An option without a `network` is left to `X402-04`. | -| `X402-06` | Signature Resubmit Accepted | x402 spec §payment-flow | A resubmitted request carrying a valid signature must be accepted | Response is no longer 402; a 2xx returns the original resource. The challenge is re-read immediately before signing, so the payment answers a challenge the target issued just now rather than a stale one. Before anything is signed, the payer's balance of `asset` is read from the chain: when it is below `amount`, nothing is sent and the check gives no verdict (`ERROR (setup)`), since a payment the payer cannot fund is refused for the payer's sake; when the balance cannot be read, the payment goes ahead. **Settles a real payment** — see the cost note below. A 2xx alone does not pass. The settlement the response reports in its `PAYMENT-RESPONSE` header (x402 v2 HTTP transport) must name a Stellar transaction hash, and that transaction is then looked up on Stellar RPC and held to the advertised terms exactly as `MPP-01` does: it must have succeeded and emitted exactly one `transfer` event, from this run's payer, to the advertised `payTo`, for the advertised `amount` of the advertised `asset`. A missing header, a reported failure, or a hash that does not match fails. A `settlement_pending` response, which the spec defines as broadcast but unconfirmed, is reconciled on chain rather than failed. Uses the same RPC wait as `MPP-01`. On Base Sepolia (`eip155:84532`) the payment uses the transfer method the target advertises: EIP-3009 by default, or Permit2, whose one-time approval the client signs as an EIP-2612 permit when the target offers the `eip2612GasSponsoring` extension. A Permit2 target without it, where this run's payer has not approved Permit2, gives no verdict (`ERROR (setup)`): the missing approval is the payer's, not the target's. The reference is an EVM transaction hash, and the receipt's ERC-20 `Transfer` log is held to the same terms (a Permit2 settlement also logs the permit's `Approval`, which is not a transfer): the transaction succeeded and logged exactly one token transfer, from this run's payer, to `payTo`, for `amount` of `asset` (an ERC-721 transfer, which shares the event signature, is not counted). The receipt is awaited for 30 blocks before the transaction counts as missing; an RPC that stops advancing gives no verdict. On Solana devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) the client signs a transaction with one `TransferChecked` to the payee's associated token account, and the sponsor named in `extra.feePayer` signs as fee payer and submits it, so the payer needs no SOL. The reference is a transaction signature, and the confirmed transaction's token balances are held to the same terms: it succeeded, and exactly one transfer moved (one account debited and one credited, by the same amount of the same mint), for `amount` of `asset`, to an account `payTo` owns, from this run's payer. The transaction is awaited for 150 slots before it counts as missing; an RPC that stops advancing gives no verdict. | +| `X402-06` | Signature Resubmit Accepted | x402 spec §payment-flow | A resubmitted request carrying a valid signature must be accepted | Response is no longer 402; a 2xx returns the original resource. The challenge is re-read immediately before signing, so the payment answers a challenge the target issued just now rather than a stale one. Before anything is signed, the payer's balance of `asset` is read from the chain: when it is below `amount`, nothing is sent and the check gives no verdict (`ERROR (setup)`), since a payment the payer cannot fund is refused for the payer's sake; when the balance cannot be read, the payment goes ahead. **Settles a real payment** — see the cost note below. A 2xx alone does not pass. The settlement the response reports in its `PAYMENT-RESPONSE` header (x402 v2 HTTP transport) must name a Stellar transaction hash, and that transaction is then looked up on Stellar RPC and held to the advertised terms exactly as `MPP-01` does: it must have succeeded and emitted exactly one `transfer` event, from this run's payer, to the advertised `payTo`, for the advertised `amount` of the advertised `asset`. A missing header, a reported failure, or a hash that does not match fails. A `settlement_pending` response, which the spec defines as broadcast but unconfirmed, is reconciled on chain rather than failed. Uses the same RPC wait as `MPP-01`. On Base Sepolia (`eip155:84532`) the payment uses the transfer method the target advertises: EIP-3009 by default, or Permit2, whose one-time approval the client signs as an EIP-2612 permit when the target offers the `eip2612GasSponsoring` extension. A Permit2 target without it, where this run's payer has not approved Permit2, gives no verdict (`ERROR (setup)`): the missing approval is the payer's, not the target's. The reference is an EVM transaction hash, and the receipt's ERC-20 `Transfer` log is held to the same terms (a Permit2 settlement also logs the permit's `Approval`, which is not a transfer): the transaction succeeded and logged exactly one token transfer, from this run's payer, to `payTo`, for `amount` of `asset` (an ERC-721 transfer, which shares the event signature, is not counted). The receipt is awaited for 30 blocks before the transaction counts as missing; an RPC that stops advancing gives no verdict. On Ethereum Sepolia (`eip155:11155111`) the payment is made the same way, in Circle's USDC, and the receipt is awaited for 10 blocks, Sepolia's blocks being 12 seconds apart. The official x402 client pays only the SDK's default assets (plus Circle's Sepolia USDC) and at most $1 a payment, which Wasit keeps: a target above that gets no verdict (`ERROR (setup)`) and nothing is sent. A valid payment the target refuses fails with its stated reason; one that verified but did not settle reads `settlement failed: `, from the `PAYMENT-RESPONSE` the official SDK server sends with `success: false`. On Solana devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) the client signs a transaction with one `TransferChecked` to the payee's associated token account, and the sponsor named in `extra.feePayer` signs as fee payer and submits it, so the payer needs no SOL. The reference is a transaction signature, and the confirmed transaction's token balances are held to the same terms: it succeeded, and exactly one transfer moved (one account debited and one credited, by the same amount of the same mint), for `amount` of `asset`, to an account `payTo` owns, from this run's payer. The transaction is awaited for 150 slots before it counts as missing; an RPC that stops advancing gives no verdict. | | `X402-07` | Invalid Signature Rejected *(negative)* | x402 spec §payment-flow; `exact` scheme on Stellar | A payment whose authorization signature is wrong must be REJECTED | The target answers with a non-2xx status. The payment is built exactly as for `X402-06`, then only the client's authorization signature is corrupted: in the `exact` scheme on Stellar the client signs a Soroban authorization entry rather than the envelope, and one byte of that entry's `signature` is flipped. The transaction still decodes and carries the same amount, payer and recipient, so a target can refuse it only by verifying the signature. Measured against the `x402.org` facilitator on 2026-09-30, the rejection is `invalid_exact_stellar_payload_simulation_failed`, reached at the Soroban simulation that checks authorization; the pre-0.6.0 corruption, which overwrote the base64 tail and broke XDR decoding, drew `invalid_exact_stellar_payload_malformed` instead, and a target that decoded the envelope without verifying the signature passed it. Rejection is established only by an answer: a target that cannot be reached, or whose challenge cannot be read, produces no verdict and is reported as ERROR or SKIP, and a payload with no authorization signature to corrupt reports `ERROR (setup)`. On Base Sepolia the client signs an EIP-3009 `transferWithAuthorization`, or a Permit2 transfer, off-chain; the first byte of that signature is flipped and the authorization left intact, so the signer it recovers to is no longer the payer and only signature verification can refuse it. Measured against the `x402.org` facilitator on 2026-10-05: refused with 402. On Solana devnet the transaction carries one signature before the sponsor signs, the payer's; its first byte is flipped, and the message, transfer and blockhash are left intact. Measured against the `x402.org` facilitator on 2026-10-05: `invalid_exact_svm_payload_signature_invalid`. | -| `X402-08` | Payment Replay Rejected *(negative)* | x402 v2 spec §10.1; `exact` scheme, one-time use | A payment that was already accepted must not be accepted again | The headers `X402-06`'s accepted payment was sent with are sent again, byte for byte, and the target answers with a non-2xx status. Each authorization is single-use: once the payment settles, its EIP-3009 nonce, or its Soroban auth entry's nonce, is spent, or on Solana the transaction has already executed, and the facilitator's verify refuses it. A 2xx means one payment bought the resource twice. Nothing can settle twice, so this costs nothing. **Skipped** when the challenge advertises the `payment-identifier` extension, under which a server may legitimately answer a repeated payment with its cached response. | +| `X402-08` | Payment Replay Rejected *(negative)* | x402 v2 spec §10.1; `exact` scheme, one-time use | A payment that was already accepted must not be accepted again | The headers `X402-06`'s accepted payment was sent with are sent again, byte for byte, and the target answers with a non-2xx status. Each authorization is single-use: once the payment settles, its EIP-3009 nonce, or its Soroban auth entry's nonce, is spent, or on Solana the transaction has already executed, and the facilitator refuses it, at verify or at settlement: the public facilitator on Base Sepolia lets a replayed EIP-3009 authorization through verify and refuses it at settlement (`settlement failed: invalid_exact_evm_nonce_already_used`, measured 2026-10-06). A 2xx means one payment bought the resource twice. Nothing can settle twice, so this costs nothing. **Skipped** when the challenge advertises the `payment-identifier` extension, under which a server may legitimately answer a repeated payment with its cached response. | | `X402-09` | Underpayment Rejected *(negative)* | `exact` scheme verification: amount | A validly signed payment for less than the advertised amount must be rejected | The payment is signed for half the advertised amount, while its `accepted` still claims the advertised terms, so the signature is valid and only the amount is wrong; the target answers with a non-2xx status. On Stellar the facilitator must hold the transfer to `requirements.amount` exactly; on EVM, verification step 3 holds the authorization's value to it; on Solana the scheme forbids a transfer below it (§1.4), and the reference facilitator's static path requires the exact amount (§3.1). **Skipped** when the price is 1 base unit, since nothing smaller can be offered. A target that accepts may settle the lower amount. | | `X402-10` | Expired Authorization Rejected *(negative)* | x402 v2 spec §10.1 (time constraints); `exact` scheme verification: validity window | A payment whose authorization has expired must be rejected | The payment is signed with a one-second lifetime, which both SDK clients derive from `maxTimeoutSeconds` (EVM `validBefore`, the Stellar auth entry's expiration ledger), held until that has passed (5 seconds on Base Sepolia, 20 on Stellar, three or more ledgers), and sent claiming the advertised terms. On Solana a payment's lifetime is its recent blockhash, which the SDK client does not take from `maxTimeoutSeconds`: the payment is built, through the client's `extra.recentBlockhash` hint, on a blockhash 300 slots old that the RPC confirms is no longer valid, and sent at once. The target answers with a non-2xx status. An expired authorization cannot settle, so a target that serves it serves for nothing. | @@ -76,7 +76,8 @@ in that case, contradicting the `X402-07` row above, and a v1 challenge left apply to an x402 service on any chain. The payment checks pay through the `exact` scheme on the network the run names: `stellar:testnet` (the default), `stellar:pubnet`, `eip155:84532` (Base Sepolia, with the EIP-3009 or Permit2 -method, where the facilitator pays the gas), or +method, where the facilitator pays the gas), `eip155:11155111` (Ethereum Sepolia, with +EIP-3009), or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (Solana devnet, where the facilitator signs as fee payer). When a challenge offers several options, the payment is built for the option on that network; a challenge with no such option, or only one in another scheme, gets @@ -300,7 +301,11 @@ ids are its EIP-155 chain ids. The usual slips: Wasit built the payment with the official client, for exactly the advertised terms, and the target refused it. The target's own log of the facilitator's -verify or settle response says why. +verify or settle response says why. When the detail reads `settlement failed: +`, the payment verified and the facilitator could not settle it, which the +official SDK server reports in `PAYMENT-RESPONSE`: on Base Sepolia, the public +facilitator's `invalid_exact_evm_transaction_failed` occurred intermittently on +2026-10-06, and each re-run passed. ### The paid response has no `PAYMENT-RESPONSE` (`X402-06`) @@ -379,6 +384,13 @@ the advertised amount it stops with `ERROR (setup)`: "This run's payer holds les than the advertised … base units", naming the key to fund. Nothing is sent, and `X402-07`–`10` are skipped ([evidence](evidence/2026-10-05-payer-balance-check-run.md)). +The official x402 client, which Wasit pays through, refuses a price above $1 and an asset +outside the SDK's defaults (Circle's USDC on Ethereum Sepolia is allowed too). Such a +target gets `ERROR (setup)` saying which, and nothing is sent: the cap keeps a paywall +from charging a payer more than it meant to, and Wasit pays automatically, on +`stellar:pubnet` with real money +([evidence](evidence/2026-10-06-ethereum-sepolia-verification-run.md#the-clients-spend-controls)). + On Stellar, the payment checks need a testnet payer holding testnet USDC. `wasit wallet create --role x402 --fund` generates the key and funds it with testnet XLM, and `wasit wallet fund --role x402 --asset usdc` adds the USDC @@ -391,6 +403,10 @@ raw `0x` private key. It needs Base Sepolia USDC from the same faucet and no ETH at all: the facilitator pays the gas. A Stellar secret there, or any malformed key, is reported at `PREFLIGHT` before anything is sent. +On Ethereum Sepolia (`--network eip155:11155111`), the payer is the same +`EVM_PRIVATE_KEY`, holding Circle's Sepolia USDC from the same faucet (network Ethereum +Sepolia) and no ETH. + On Solana devnet (`--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`), the payer is `SVM_PRIVATE_KEY`, the base58 encoding of the 64-byte keypair, as wallets export it. It needs devnet USDC of the SDK's default mint (`4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`), diff --git a/docs/evidence/2026-10-05-payer-balance-check-run.md b/docs/evidence/2026-10-05-payer-balance-check-run.md index 09d8fdc..eb9d31c 100644 --- a/docs/evidence/2026-10-05-payer-balance-check-run.md +++ b/docs/evidence/2026-10-05-payer-balance-check-run.md @@ -73,8 +73,19 @@ One Permit2 run (port 3006), started a few seconds after an EIP-3009 run from th payer, drew `FAIL X402-06 Expected 2xx after a valid payment, got 402.`, with no reason given. The payer's USDC transfers on Base Sepolia show no settlement at that time, so the payment was refused, not taken. Five Permit2 runs followed, three of them right after an -EIP-3009 run, as the failing one was: all passed 10/10. The cause was not found; it was -the target's facilitator refusing once, which Wasit reported as it happened. +EIP-3009 run, as the failing one was: all passed 10/10. The cause was not found that day. + +*Added 2026-10-06:* found. With the Permit2 fixture logging its facilitator's answers, +the same failure recurred on both Base Sepolia fixtures (five of sixteen runs on +2026-10-06), and each time its cause could be seen (the Permit2 fixture's log twice, +Wasit's own report once) it was the public facilitator's settlement failing as it +broadcast its own transaction: `settle failed: invalid_exact_evm_transaction_failed: Missing or invalid +parameters ... "method":"eth_sendRawTransaction"`. The payment verified; the +facilitator's submission failed; the payer was not charged. The official SDK server +says so in a `PAYMENT-RESPONSE` with `success: false`, which Wasit did not read; since +`178b8a6` it does, and the failure reads `got 402 (settlement failed: +invalid_exact_evm_transaction_failed)` +([Ethereum Sepolia run](2026-10-06-ethereum-sepolia-verification-run.md#settlement-failures-are-named-now)). ## Limits diff --git a/docs/evidence/2026-10-05-wallet-network-and-serve-modes-run.md b/docs/evidence/2026-10-05-wallet-network-and-serve-modes-run.md index 8eae755..ecd02e0 100644 --- a/docs/evidence/2026-10-05-wallet-network-and-serve-modes-run.md +++ b/docs/evidence/2026-10-05-wallet-network-and-serve-modes-run.md @@ -2,7 +2,7 @@ **Date:** 2026-10-05 **Wasit:** builds of the local `release/0.7.0` branch (`4c3ca55` for `wallet --network`, -and the commit after it for the two modes), not yet published. +and `d82555a` for the two modes), not yet published. **Targets:** none operated by anyone else. `wasit wallet` read balances from each chain's public RPC; `wasit serve` ran on our machine, and the official x402 SDK client was pointed at it as the agent. diff --git a/docs/evidence/2026-10-06-ethereum-sepolia-verification-run.md b/docs/evidence/2026-10-06-ethereum-sepolia-verification-run.md new file mode 100644 index 0000000..97f25d2 --- /dev/null +++ b/docs/evidence/2026-10-06-ethereum-sepolia-verification-run.md @@ -0,0 +1,130 @@ +# x402 Payment Checks on Ethereum Sepolia — A/B Against Lying Servers, and a Real Settlement + +**Date:** 2026-10-05 and 2026-10-06 +**Wasit:** builds of the local `release/0.7.0` branch (`35b12a6` and `11672d4` for +Ethereum Sepolia, `93e04c0` for the spend-control result, `178b8a6` for the settlement +failure reason), not yet published. +**Targets:** `wasit serve --network eip155:11155111`, the paywall that misbehaves on +purpose; and Wasit's own Ethereum Sepolia x402 fixture +(`packages/core/test/fixtures/x402-evm-sepolia-server.ts`). No public facilitator settles +Ethereum Sepolia, so that fixture runs the official SDK's facilitator (`x402Facilitator` +with the EVM `exact` scheme, `@x402/evm` 2.28.0) in its own process, paying the gas from +a key of ours. All on our machine; the chain is Ethereum Sepolia. +**Environment:** macOS, Node `v26.8.1`. + +**What this is for.** `X402-06` to `X402-10` now also pay on Ethereum Sepolia, through +the same EVM adapter as Base Sepolia. Circle's USDC there +(`0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238`) has EIP-3009: read from the contract, it +answers `authorizationState` and `nonces`, with EIP-712 name "USDC", version "2", 6 +decimals. The same two things have to hold as on the other chains: the checks fail +servers that lie, and pass an honest one. + +**Authorization.** None was needed or sought. Every target was Wasit's own code on our +machine. No service operated by anyone else was tested. + +## Servers that lie + +Payer: Najmi's Base Sepolia payer, which also held 20 Sepolia USDC and no ETH. Nothing +moved: `wasit serve` never settles. + +| `wasit serve` mode | `X402-06` | `X402-07`–`10` | Run time | +|---|---|---|---| +| `no-settle` | **FAIL**, no `PAYMENT-RESPONSE` | **FAIL** | 6 s | +| `wrong-settlement` | **FAIL**, "the chain produced 10 more blocks without it" | **FAIL** | 131 s | +| `wrong-network` (asks `eip155:1`) | SKIP, nothing sent | SKIP | 1 s | + +Ethereum Sepolia makes a block about every 12 seconds, so the wait for a cited +transaction is 10 blocks there (30 on Base Sepolia): 131 seconds here. + +## An honest server + +`wasit test --target http://localhost:3008/protected --network eip155:11155111`: +**10/10**, in 20 seconds. + +``` +PASS X402-06 Signature Resubmit Accepted + Valid payment accepted (HTTP 200) and settled on-chain for exactly the + advertised 10000 base units of 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 + to 0xb57bFdA962aAa607D5260747A175cda8f4bDDad7, verified from the Transfer + log (tx 0x0c48fa9d…7849). +PASS X402-07 ... rejected (HTTP 402: invalid_exact_evm_signature). +PASS X402-08 ... refused (HTTP 402: invalid_exact_evm_nonce_already_used). +PASS X402-09 ... 5000 of the advertised 10000 base units was refused + (HTTP 402: invalid_exact_evm_payload_authorization_value_mismatch). +PASS X402-10 ... refused (HTTP 402: invalid_exact_evm_payload_authorization_valid_before). +``` + +[`0x0c48fa9d…7849`](https://sepolia.etherscan.io/tx/0x0c48fa9def71f95411862beceef7110c50ac72c323c4603feacb40bc314d7849) + +Read back independently afterwards, outside Wasit, with `viem` against the public +Sepolia RPC: + +| | | +|---|---| +| Receipt | `success`, block 11849904, 85728 gas | +| Sent by | `0x5D58…C579`, the fixture's facilitator, calling the USDC contract | +| Logs | `AuthorizationUsed` for the payer `0xC171…0428`, and exactly one `Transfer` from the payer to the payee `0xb57b…Dad7` for 10000 | +| Payer after | 19.99 USDC, **still 0 ETH** | + +A second run, after the spend-control change (`93e04c0`), passed 10/10 again +([`0x5b351060…67bb`](https://sepolia.etherscan.io/tx/0x5b351060f5597a8240b3f9f4a193a3124cb7c8e0eec238a0af616664341f67bb)). + +## The client's spend controls + +The official x402 client pays only the SDK's default assets, at most $1 a payment, +unless told otherwise. Circle's Sepolia USDC is not a default asset, so the first +attempt stopped before signing: `ERROR (harness) All payment requirements were rejected +by spendControls: only default assets or entries in spendControls.allowedAssets are +allowed`. The EVM adapter now allows that token on Ethereum Sepolia only, with the same +$1 cap the SDK puts on its defaults (1000000 base units); an offline test pays 10000 and +refuses 1000001. + +A target above those limits is not at fault and gets no verdict. Against `wasit serve` +on Base Sepolia priced at 2000000 units (2 USDC), with a payer holding 19.8: + +``` +ERROR X402-06 Signature Resubmit Accepted + Check could not run (setup): The target asks for 2000000 base units of + 0x036CbD53842c5426634e7929541eC2318f3dCF7e, more than the $1 a payment the + official x402 client pays by default, so nothing was sent. ... +``` + +and the paywall's log shows no payment received. + +## Settlement failures are named now + +A payment that verifies but does not settle used to read `got 402.` with no reason. The +official SDK server answers it with a `PAYMENT-RESPONSE` whose `success` is false and +whose `errorReason` names the facilitator's failure, and Wasit now reads it. On +2026-10-06, on the Base Sepolia fixtures, which settle through the public facilitator: + +``` +FAIL X402-06 Signature Resubmit Accepted + Expected 2xx after a valid payment, got 402 + (settlement failed: invalid_exact_evm_transaction_failed). +PASS X402-08 Payment Replay Rejected + The same payment, sent again, was refused + (HTTP 402: settlement failed: invalid_exact_evm_nonce_already_used). +``` + +The first is the public facilitator failing to broadcast its own transaction (the +fixture's log: `eth_sendRawTransaction` answered "Missing or invalid parameters"); five +of sixteen Base Sepolia runs that day failed `X402-06` this way, each passing on re-run, +and the three whose cause could be seen all showed it. The second +shows where the public facilitator stops a replay: the replayed EIP-3009 authorization +passed its verify and was refused at settlement, its nonce spent. The resource was not +served either way. The same day the Stellar fixture passed 10/10, in 198 seconds against +41 the day before; one earlier run had stopped with `ERROR (harness)` when Stellar +testnet RPC was slow enough for the payment's own authorization to expire before the +SDK client simulated it. + +## Limits + +- One more EVM network: Ethereum Sepolia, with EIP-3009. Permit2 there was not run. + BNB Smart Chain testnet stays read-only. +- The honest target is Wasit's own fixture with the SDK's facilitator run locally. No + third-party Ethereum Sepolia service was tested. +- A facilitator's transient failure is reported as `X402-06` FAIL, with its reason: the + target did refuse a valid payment. Wasit cannot tell a transient failure from a lasting + one in a single run. +- Unreleased: this ran from the branch, not from npm. diff --git a/docs/evidence/README.md b/docs/evidence/README.md index 7d454af..1db7200 100644 --- a/docs/evidence/README.md +++ b/docs/evidence/README.md @@ -92,6 +92,7 @@ and what it does and does not establish. Newest first. | Date | File | What it shows | Deliverable | |---|---|---|---| +| 2026-10-06 | [ethereum-sepolia-verification-run](2026-10-06-ethereum-sepolia-verification-run.md) | The x402 payment checks on Ethereum Sepolia (unreleased 0.7.0 branch): `wasit serve`'s lying modes fail and a mainnet request gets nothing sent; Wasit's own fixture, running the SDK's facilitator, passes 10/10 with the settlement read back independently and the payer holding no ETH; a price above the client's $1 cap gets no verdict; a failed settlement now names its reason | Beyond the SOW | | 2026-10-05 | [wallet-network-and-serve-modes-run](2026-10-05-wallet-network-and-serve-modes-run.md) | `wasit wallet --network` makes and reads the Base Sepolia and Solana devnet payers; `wasit serve`'s `v1-challenge` and `malformed-header` modes, with the official SDK client as the agent: it pays the first as v1 and refuses the second (unreleased 0.7.0 branch) | Beyond the SOW | | 2026-10-05 | [payer-balance-check-run](2026-10-05-payer-balance-check-run.md) | `X402-06` reads the payer's balance before signing (unreleased 0.7.0 branch): fresh, unfunded payers get `ERROR (setup)` with nothing sent on Stellar, Base Sepolia and Solana devnet, where they used to read as a target's failure or an unexplained error; funded payers pass 10/10 on every fixture | Beyond the SOW | | 2026-10-05 | [solana-devnet-verification-run](2026-10-05-solana-devnet-verification-run.md) | The x402 payment checks on Solana devnet (unreleased 0.7.0 branch): `wasit serve`'s lying modes fail `X402-06`–`10` and a mainnet request gets nothing sent; Wasit's own fixture passes 10/10 with `X402-06` settled on-chain and read back independently, the payer holding no SOL; the packed core installs and runs alongside TypeScript 5.8, 6 and 7 | Beyond the SOW | diff --git a/docs/guides/cli.md b/docs/guides/cli.md index 54f9da0..cba391f 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -76,8 +76,8 @@ wasit wallet fund --role x402|mpp-charge [--network ] [--asset xlm|usdc Testnet-only convenience commands for the payer keys the other subcommands read from `.env`. `--network` takes testnets only, `stellar:testnet` (the -default), `eip155:84532` (Base Sepolia) or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` -(Solana devnet), since Friendbot, the printed USDC issuer, and the whole idea +default), `eip155:84532` (Base Sepolia), `eip155:11155111` (Ethereum Sepolia) or +`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (Solana devnet), since Friendbot, the printed USDC issuer, and the whole idea of a disposable generated key only make sense on testnet. `status` shows each configured role's on-chain balances (or "not yet created" @@ -109,7 +109,7 @@ public key `wasit wallet fund` prints), or set `WASIT_USDC_DISTRIBUTOR_SECRET` in `.env` to an account you already funded that way — every run after that sends automatically from it. -On Base Sepolia and Solana devnet only `--role x402` applies: MPP runs on +On the other testnets only `--role x402` applies: MPP runs on Stellar. `create --role x402 --network eip155:84532` prints an `EVM_PRIVATE_KEY=0x...` line, and `--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` an `SVM_PRIVATE_KEY=...` line (base58 of the 64-byte keypair), each with the @@ -169,9 +169,9 @@ wasit test --target [options] | Option | Default | Notes | |---|---|---| | `--target ` | required | Must include the scheme | -| `--network ` | `stellar:testnet` | Network the payment checks pay on: `stellar:testnet`, `stellar:pubnet`, `eip155:84532` (Base Sepolia) or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (Solana devnet). `X402-01`–`05` apply to a challenge on any chain | -| `--payer-key ` | `STELLAR_PRIVATE_KEY`, `EVM_PRIVATE_KEY` on Base Sepolia, or `SVM_PRIVATE_KEY` on Solana devnet | A Stellar secret (`S...`), an EVM private key (`0x` + 64 hex), or a Solana keypair (base58 of its 64 bytes) | -| `--rpc-url ` | the network's default | RPC used to verify `X402-06`'s settlement: Soroban RPC on Stellar, required for pubnet; JSON-RPC on Base Sepolia (default `https://sepolia.base.org`) and on Solana devnet (default `https://api.devnet.solana.com`) | +| `--network ` | `stellar:testnet` | Network the payment checks pay on: `stellar:testnet`, `stellar:pubnet`, `eip155:84532` (Base Sepolia), `eip155:11155111` (Ethereum Sepolia) or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (Solana devnet). `X402-01`–`05` apply to a challenge on any chain | +| `--payer-key ` | `STELLAR_PRIVATE_KEY`, `EVM_PRIVATE_KEY` on Base or Ethereum Sepolia, or `SVM_PRIVATE_KEY` on Solana devnet | A Stellar secret (`S...`), an EVM private key (`0x` + 64 hex), or a Solana keypair (base58 of its 64 bytes) | +| `--rpc-url ` | the network's default | RPC used to verify `X402-06`'s settlement: Soroban RPC on Stellar, required for pubnet; JSON-RPC on Base Sepolia (default `https://sepolia.base.org`), Ethereum Sepolia (default `https://11155111.rpc.thirdweb.com`, viem's) and Solana devnet (default `https://api.devnet.solana.com`) | | `--method ` | `GET` | HTTP method the paid endpoint uses. Endpoints that compute something usually take `POST` | | `--body ` | — | Request body, sent verbatim; implies `Content-Type: application/json` | | `--header ` | — | Extra request header the endpoint needs before it will issue a challenge. Repeatable | @@ -262,8 +262,8 @@ wasit serve --mode no-settle | `--mode ` | required | One of the six above | | `--port ` | `4020` | The server answers on every path | | `--host ` | `127.0.0.1` | Local only unless you bind another interface | -| `--network ` | `stellar:testnet` | `stellar:testnet`, `eip155:84532` (Base Sepolia) or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (Solana devnet). On Base Sepolia and Solana devnet the paywall uses the SDK's USDC, `wrong-network` asks for that chain's mainnet (`eip155:8453`, `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`), and `overprice` one million USDC in its 6 decimals. On Solana the challenge names the payee as `extra.feePayer`, which the spec allows; nothing is ever submitted | -| `--pay-to
` | `STELLAR_PAYEE_ADDRESS`, `EVM_PAYEE_ADDRESS` on Base Sepolia, or `SVM_PAYEE_ADDRESS` on Solana devnet | A testnet account (`G...`) with a trustline for the asset, an EVM address (`0x...`), or a Solana address (base58) | +| `--network ` | `stellar:testnet` | `stellar:testnet`, `eip155:84532` (Base Sepolia), `eip155:11155111` (Ethereum Sepolia) or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (Solana devnet). Elsewhere than Stellar the paywall uses that network's USDC (the SDK's, or Circle's on Ethereum Sepolia), `wrong-network` asks for that chain's mainnet (`eip155:8453`, `eip155:1`, `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`), and `overprice` one million USDC in its 6 decimals. On Solana the challenge names the payee as `extra.feePayer`, which the spec allows; nothing is ever submitted | +| `--pay-to
` | `STELLAR_PAYEE_ADDRESS`, `EVM_PAYEE_ADDRESS` on Base or Ethereum Sepolia, or `SVM_PAYEE_ADDRESS` on Solana devnet | A testnet account (`G...`) with a trustline for the asset, an EVM address (`0x...`), or a Solana address (base58) | | `--amount ` | `10000` | Price in base units, for every mode except `overprice` | | `--asset ` | testnet USDC | `CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA` | | `--settlement-tx ` | a random hash | `wrong-settlement` only. The default cites a transaction that exists nowhere; pass a real, unrelated transaction to test an agent that finds it on-chain but must notice it is not this payment | diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 9f4070b..f17fdef 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -12,7 +12,7 @@ keys in this file. |---|---| | `X402-01`–`05` | nothing | | `X402-06`–`10` on Stellar | `STELLAR_PRIVATE_KEY` | -| `X402-06`–`10` on Base Sepolia (`--network eip155:84532`) | `EVM_PRIVATE_KEY` | +| `X402-06`–`10` on Base Sepolia (`--network eip155:84532`) or Ethereum Sepolia (`--network eip155:11155111`) | `EVM_PRIVATE_KEY` | | `X402-06`–`10` on Solana devnet (`--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) | `SVM_PRIVATE_KEY` | | `MPP-01` | `MPP_PAYER_SECRET`, `MPP_STELLAR_NETWORK` | | `MPP-10`–`12`, `14` | `COMMITMENT_SECRET_HEX`, `MPP_STELLAR_NETWORK` | @@ -34,7 +34,10 @@ third-party node. The x402 payment checks also pay on Base Sepolia (`eip155:84532`), through `--network` (MCP: `network`), with the payer key in `EVM_PRIVATE_KEY`. Its default RPC endpoint is Base's public `https://sepolia.base.org`, overridable -with `--rpc-url`. They pay on Solana devnet too +with `--rpc-url`. They pay on Ethereum Sepolia (`eip155:11155111`) with the same +`EVM_PRIVATE_KEY`, through viem's default Sepolia endpoint +`https://11155111.rpc.thirdweb.com` unless `--rpc-url` names another. The public +x402.org facilitator does not settle Ethereum Sepolia. They pay on Solana devnet too (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`), with the payer key in `SVM_PRIVATE_KEY` and the public `https://api.devnet.solana.com` as the default RPC endpoint. Any other network gets the read-only checks only. @@ -67,6 +70,12 @@ once on-chain, which needs gas, and a run without that approval reports `ERROR (setup)`. A wrong or malformed key is reported at `PREFLIGHT` before anything is sent, without echoing it. +On Ethereum Sepolia the same key pays in Circle's USDC +(`0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238`), from https://faucet.circle.com (network +Ethereum Sepolia), and again needs no ETH. Wasit's own Ethereum Sepolia fixture runs the +SDK's facilitator itself, paying the gas from `EVM_FACILITATOR_PRIVATE_KEY`, which needs +Sepolia ETH; that key is for the fixture only. + For Solana devnet, `SVM_PRIVATE_KEY` is the base58 encoding of the keypair's 64 bytes (seed, then public key), the form wallets export and `wasit wallet create --role x402 --network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` prints. The payer needs devnet USDC diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 2baf764..67391be 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -172,7 +172,8 @@ Any MCP client that runs stdio servers takes the same three things: `command` set to `npx`, `args` set to `["-y", "@wasit-dev/server"]`, and the environment variables `MPP_STELLAR_NETWORK`, `STELLAR_PRIVATE_KEY`, `MPP_PAYER_SECRET` and `COMMITMENT_SECRET_HEX`, plus `EVM_PRIVATE_KEY` for x402 payment checks on Base -Sepolia (`network: "eip155:84532"`) and `SVM_PRIVATE_KEY` for Solana devnet +Sepolia (`network: "eip155:84532"`) or Ethereum Sepolia (`"eip155:11155111"`), and +`SVM_PRIVATE_KEY` for Solana devnet (`network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"`). Use absolute paths for `node packages/server/dist/index.js` if launching from a local checkout instead of npx, since a client launches the server from a working directory you don't control. diff --git a/frontend/components/StackStrip.tsx b/frontend/components/StackStrip.tsx index 3b5f000..2254328 100644 --- a/frontend/components/StackStrip.tsx +++ b/frontend/components/StackStrip.tsx @@ -31,7 +31,7 @@ type Cell = { key: string; href: string; name: string; role: "checks" | "trusts" // charge mode and five for channel mode. Links are the homepages the repo // already cites. const CELLS: Cell[] = [ - { key: "x402", href: "https://x402.org", name: "x402", role: "checks", meta: "10 checks · exact scheme on Stellar, Base and Solana", logo: }, + { key: "x402", href: "https://x402.org", name: "x402", role: "checks", meta: "10 checks · exact scheme on Stellar, EVM and Solana", logo: }, { key: "mpp", href: "https://paymentauth.org", name: "Machine Payments Protocol", role: "checks", meta: "6 checks · charge and channel", logo: }, { key: "stellar", diff --git a/packages/cli/README.md b/packages/cli/README.md index 32a3454..aeec585 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -48,8 +48,8 @@ wasit test --target [options] | Option | Default | Notes | |---|---|---| | `--target ` | required | Must include the scheme | -| `--network ` | `stellar:testnet` | Network the payment checks pay on (`stellar:testnet`, `stellar:pubnet`, `eip155:84532` for Base Sepolia, or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` for Solana devnet); `X402-01`–`05` apply on any chain | -| `--payer-key ` | `STELLAR_PRIVATE_KEY`, `EVM_PRIVATE_KEY` on Base Sepolia, or `SVM_PRIVATE_KEY` on Solana devnet | A Stellar secret (`S...`), an EVM private key (`0x` + 64 hex), or a Solana keypair (base58 of its 64 bytes) | +| `--network ` | `stellar:testnet` | Network the payment checks pay on (`stellar:testnet`, `stellar:pubnet`, `eip155:84532` for Base Sepolia, `eip155:11155111` for Ethereum Sepolia, or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` for Solana devnet); `X402-01`–`05` apply on any chain | +| `--payer-key ` | `STELLAR_PRIVATE_KEY`, `EVM_PRIVATE_KEY` on Base or Ethereum Sepolia, or `SVM_PRIVATE_KEY` on Solana devnet | A Stellar secret (`S...`), an EVM private key (`0x` + 64 hex), or a Solana keypair (base58 of its 64 bytes) | | `--rpc-url ` | testnet default | Soroban RPC used to verify `X402-06`'s settlement | | `--method ` | `GET` | HTTP method the paid endpoint uses | | `--body ` | — | Request body; implies `Content-Type: application/json` | @@ -113,7 +113,7 @@ wasit wallet create --role x402|mpp-charge|mpp-channel [--network ] [-- wasit wallet fund --role x402|mpp-charge [--network ] [--asset xlm|usdc] [--amount ] ``` -Testnet-only helpers for the keys the subcommands above read from `.env`. `--network` takes testnets only: `stellar:testnet` (default), `eip155:84532` or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`, where `create --role x402` prints an `EVM_PRIVATE_KEY` or `SVM_PRIVATE_KEY` line and the address to fund at [faucet.circle.com](https://faucet.circle.com); that payer needs USDC and no ETH or SOL. `create` prints the exact `.env` lines to paste and never writes the file itself; `fund --asset xlm` uses Stellar's public Friendbot and is fully automatic. +Testnet-only helpers for the keys the subcommands above read from `.env`. `--network` takes testnets only: `stellar:testnet` (default), `eip155:84532`, `eip155:11155111` or `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`, where `create --role x402` prints an `EVM_PRIVATE_KEY` or `SVM_PRIVATE_KEY` line and the address to fund at [faucet.circle.com](https://faucet.circle.com); that payer needs USDC and no ETH or SOL. `create` prints the exact `.env` lines to paste and never writes the file itself; `fund --asset xlm` uses Stellar's public Friendbot and is fully automatic. `fund --asset usdc` opens the Circle testnet USDC trustline automatically, but **receiving a balance always needs one human step**: there is no scriptable USDC faucet for Stellar. Visit [faucet.circle.com](https://faucet.circle.com) once, or set `WASIT_USDC_DISTRIBUTOR_SECRET` to an account you funded that way and every later run sends from it automatically. @@ -171,7 +171,7 @@ A run with both a failure and an error exits `1` — a real finding outranks a m | Env var | Used by | |---|---| | `STELLAR_PRIVATE_KEY` | `test` (x402 payment checks on Stellar) | -| `EVM_PRIVATE_KEY` | `test --network eip155:84532` (x402 payment checks on Base Sepolia) | +| `EVM_PRIVATE_KEY` | `test --network eip155:84532` or `eip155:11155111` (x402 payment checks on Base or Ethereum Sepolia) | | `SVM_PRIVATE_KEY` | `test --network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (x402 payment checks on Solana devnet) | | `MPP_PAYER_SECRET` | `mpp-charge` | | `MPP_STELLAR_NETWORK` | `mpp-charge`, `mpp-channel` | diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 623cab9..e2a228d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -204,8 +204,9 @@ program .option( "--network ", "Network the payment checks pay on: stellar:testnet, stellar:pubnet, " + - "eip155:84532 (Base Sepolia) or solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 " + - "(Solana devnet). X402-01..05 apply to a challenge on any chain.", + "eip155:84532 (Base Sepolia), eip155:11155111 (Ethereum Sepolia) or " + + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 (Solana devnet). X402-01..05 " + + "apply to a challenge on any chain.", "stellar:testnet", ) .option( @@ -298,8 +299,8 @@ program .option( "--pay-to
", "Payee: a Stellar account (G...) with a USDC trustline, an EVM address (0x...) " + - "on Base Sepolia, or a Solana address on devnet (overrides STELLAR_PAYEE_ADDRESS, " + - "EVM_PAYEE_ADDRESS or SVM_PAYEE_ADDRESS)", + "on Base or Ethereum Sepolia, or a Solana address on devnet (overrides " + + "STELLAR_PAYEE_ADDRESS, EVM_PAYEE_ADDRESS or SVM_PAYEE_ADDRESS)", ) .option("--amount ", "Price in base units for every mode except overprice", "10000") .option("--asset ", "Token contract (default: the network's testnet USDC)") diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index 2998174..8c7e92a 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -33,6 +33,13 @@ export const TESTNET_USDC = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMX /** Base Sepolia USDC, the official SDK's default asset there. */ export const BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +/** + * Ethereum Sepolia USDC, Circle's, with EIP-3009 and EIP-712 domain "USDC" + * version "2" (read from the contract, 2026-10-05). The SDK ships no default + * asset for this network. + */ +export const ETHEREUM_SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"; + /** Solana devnet USDC, the official SDK's default asset there. */ export const SOLANA_DEVNET_USDC = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; @@ -121,6 +128,23 @@ export const SERVE_NETWORKS: Readonly> = { randomTx: () => `0x${randomBytes(32).toString("hex")}`, payeeEnv: "EVM_PAYEE_ADDRESS", }, + "eip155:11155111": { + name: "Ethereum Sepolia", + faucetNetwork: "Ethereum Sepolia", + nativeToken: "ETH", + v1Network: "sepolia", + mainnet: "eip155:1", + mainnetName: "Ethereum mainnet", + asset: ETHEREUM_SEPOLIA_USDC, + decimals: 6, + extra: () => ({ name: "USDC", version: "2" }), + payTo: /^0x[0-9a-fA-F]{40}$/, + payToHint: "an EVM address (0x followed by 40 hex characters)", + txHash: /^0x[0-9a-fA-F]{64}$/, + txHashHint: "an EVM transaction hash, 0x followed by 64 hex characters", + randomTx: () => `0x${randomBytes(32).toString("hex")}`, + payeeEnv: "EVM_PAYEE_ADDRESS", + }, "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { name: "Solana devnet", faucetNetwork: "Solana Devnet", @@ -164,7 +188,7 @@ export const MODE_DESCRIPTIONS: Readonly { } }); }); + +describe("on Ethereum Sepolia", () => { + const SEPOLIA = { network: "eip155:11155111", payTo: EVM_PAY_TO }; + const accept = (mode: ServeMode) => + (challengeFor({ mode, ...SEPOLIA }, "http://x/paid")["accepts"] as Array>)[0]!; + + it("poses with Circle's Sepolia USDC and its EIP-712 domain, and asks for Ethereum mainnet", () => { + assert.equal(accept("no-settle")["asset"], ETHEREUM_SEPOLIA_USDC); + assert.deepEqual(accept("no-settle")["extra"], { name: "USDC", version: "2" }); + assert.equal(accept("wrong-network")["network"], "eip155:1"); + assert.equal(accept("overprice")["amount"], "1000000000000"); + const v1 = (v1ChallengeFor({ mode: "v1-challenge", ...SEPOLIA }, "http://x/paid")["accepts"] as Array< + Record + >)[0]!; + assert.equal(v1["network"], "sepolia"); + }); + + for (const mode of WELL_FORMED_MODES) { + it(`${mode}: Wasit's read checks pass the Ethereum Sepolia challenge`, async () => { + const { url, server } = await start(mode, SEPOLIA); + try { + for (const result of await runX402ReadChecks({ target: url })) { + assert.equal(result.pass, true, `${mode} ${result.id}: ${result.detail}`); + } + } finally { + await stop(server); + } + }); + } +}); diff --git a/packages/core/src/x402/chains/evm.ts b/packages/core/src/x402/chains/evm.ts index e19d65e..94fa8a2 100644 --- a/packages/core/src/x402/chains/evm.ts +++ b/packages/core/src/x402/chains/evm.ts @@ -9,8 +9,10 @@ * * Settlement is read from the transaction receipt: the token contract's own * ERC-20 `Transfer` log is held to the advertised terms, as the Stellar - * adapter holds the `transfer` event. Only Base Sepolia for now: - * the official SDK ships its USDC and the public facilitator settles there. + * adapter holds the `transfer` event. Base Sepolia, where the official SDK + * ships its USDC and the public facilitator settles, and Ethereum + * Sepolia, which Circle's USDC with EIP-3009 also serves but no public + * facilitator settles. */ import { toClientEvmSigner } from "@x402/evm"; @@ -27,16 +29,50 @@ import { type Hex, } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; -import { baseSepolia } from "viem/chains"; +import { baseSepolia, sepolia } from "viem/chains"; import { CheckSetupError, ConfigurationError } from "../../errors.js"; import type { ExpectedSettlement, SettlementVerdict } from "../../settlement.js"; import type { PaymentChain } from "./types.js"; import { waitForReceipt } from "./wait.js"; -/** The networks this adapter pays on, with viem's chain definition for each. */ -const NETWORKS: Readonly> = { - "eip155:84532": baseSepolia, +/** What differs between the EVM networks this adapter pays on. */ +interface EvmNetwork { + /** viem's definition, whose first default RPC is the default endpoint. */ + readonly chain: Chain; + /** + * How long to look for the settled transaction, in blocks, as the Stellar + * adapter counts ledgers: about a minute or two of the chain's own blocks, + * long enough for any facilitator that reports a hash before its receipt, + * short enough that a fabricated hash fails soon. + */ + readonly blocksBeforeMissing: bigint; + /** The wall-clock limit on that wait, past which a still RPC gives no verdict. */ + readonly maxWaitMs: number; + /** + * Tokens to pay in besides the SDK's default assets. The SDK client's spend + * controls refuse any other asset; each entry keeps the cap the SDK puts on + * its defaults, $1 a payment, as base units. + */ + readonly allowedAssets?: readonly { readonly asset: string; readonly maxAmountPerPayment: string }[]; +} + +/** + * Circle's USDC on Ethereum Sepolia: EIP-3009, EIP-712 domain "USDC" version + * "2", 6 decimals (read from the contract, 2026-10-05). Not an SDK default. + */ +export const ETHEREUM_SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"; + +const NETWORKS: Readonly> = { + // A block every 2 seconds: 30 blocks is about a minute. + "eip155:84532": { chain: baseSepolia, blocksBeforeMissing: 30n, maxWaitMs: 120_000 }, + // A block every 12 seconds: 10 blocks is about two minutes. + "eip155:11155111": { + chain: sepolia, + blocksBeforeMissing: 10n, + maxWaitMs: 240_000, + allowedAssets: [{ asset: ETHEREUM_SEPOLIA_USDC, maxAmountPerPayment: "1000000" }], + }, }; /** An EVM transaction hash: 0x and 32 bytes of hex. */ @@ -48,14 +84,6 @@ const PRIVATE_KEY = /^0x[0-9a-fA-F]{64}$/; /** `Transfer(address indexed from, address indexed to, uint256 value)`. */ const TRANSFER_TOPIC = toEventSelector("Transfer(address,address,uint256)"); -/** - * How long to look for the settled transaction, in blocks, as the Stellar - * adapter counts ledgers. Base Sepolia makes a block every 2 seconds, so 30 - * blocks is about a minute: long enough for any facilitator that reports a - * hash before its receipt, short enough that a fabricated hash fails soon. - */ -const BLOCKS_BEFORE_MISSING = 30n; - function payerAccount(payerKey: string) { // The message never echoes the key: it is a secret, and a malformed one is // often a real key pasted with a character missing. @@ -185,10 +213,15 @@ export function verifyEvmReceipt( } async function verifyEvmSettlement( + network: string, rpcUrl: string, reference: string, expected: ExpectedSettlement, ): Promise { + const profile = NETWORKS[network]; + if (profile === undefined) { + throw new ConfigurationError(`No EVM network "${network}" in Wasit's payment checks.`); + } const client = createPublicClient({ transport: http(rpcUrl) }); const lookup = await waitForReceipt( async () => { @@ -200,7 +233,7 @@ async function verifyEvmSettlement( } }, () => client.getBlockNumber(), - { blocks: BLOCKS_BEFORE_MISSING }, + { blocks: profile.blocksBeforeMissing, maxWaitMs: profile.maxWaitMs }, ); if (lookup.kind === "stalled") { @@ -252,18 +285,18 @@ export const evmChain: PaymentChain = { payerKeyEnv: "EVM_PRIVATE_KEY", referenceKind: "an EVM transaction hash", resolveRpcUrl(network, override) { - const chain = NETWORKS[network]; - if (chain === undefined) { + const profile = NETWORKS[network]; + if (profile === undefined) { throw new ConfigurationError(`No EVM network "${network}" in Wasit's payment checks.`); } if (override) return override; - const fallback = chain.rpcUrls.default.http[0]; + const fallback = profile.chain.rpcUrls.default.http[0]; if (fallback === undefined) { throw new ConfigurationError(`No default RPC endpoint for ${network}. Pass an explicit rpcUrl.`); } return fallback; }, - registerPayer(client, _network, payerKey, rpcUrl) { + registerPayer(client, network, payerKey, rpcUrl) { // The signer can read the chain: the SDK needs that only on the Permit2 // path (allowance, permit nonce). EIP-3009 signing stays offline. const signer = toClientEvmSigner( @@ -271,8 +304,16 @@ export const evmChain: PaymentChain = { createPublicClient({ transport: http(rpcUrl) }), ); client.register("eip155:*", new ExactEvmScheme(signer, { rpcUrl })); + const allowed = NETWORKS[network]?.allowedAssets; + if (allowed !== undefined) { + client.setSpendControls({ + allowedAssets: allowed.map((entry) => ({ network: network as `${string}:${string}`, ...entry })), + }); + } }, - // validBefore is now + 1 second; Base Sepolia blocks come every 2 seconds. + // validBefore is now + 1 second. Held 5 seconds, it has passed by the + // clock, and any block that could include it is later still, whatever + // the chain's block time. expiredSigning: async () => ({ terms: { maxTimeoutSeconds: 1 }, holdMs: 5_000 }), payerAddress: (payerKey) => payerAccount(payerKey).address, generatePayerKey: () => generatePrivateKey(), diff --git a/packages/core/src/x402/chains/stellar.ts b/packages/core/src/x402/chains/stellar.ts index d7fd1c8..4a48461 100644 --- a/packages/core/src/x402/chains/stellar.ts +++ b/packages/core/src/x402/chains/stellar.ts @@ -182,7 +182,7 @@ export const stellarChain: PaymentChain = { payerBalance: stellarTokenBalance, generatePayerKey: () => Keypair.random().secret(), isSettlementReference: (reference) => TRANSACTION_HASH.test(reference), - verifySettlement, + verifySettlement: (_network, rpcUrl, reference, expected) => verifySettlement(rpcUrl, reference, expected), corruptPayload: (payload) => ({ ...payload, transaction: corruptAuthSignature(payload["transaction"] as string), diff --git a/packages/core/src/x402/chains/svm.ts b/packages/core/src/x402/chains/svm.ts index f96df86..ca260d6 100644 --- a/packages/core/src/x402/chains/svm.ts +++ b/packages/core/src/x402/chains/svm.ts @@ -425,6 +425,6 @@ export const svmChain: PaymentChain = { return false; } }, - verifySettlement: verifySvmSettlement, + verifySettlement: (_network, rpcUrl, reference, expected) => verifySvmSettlement(rpcUrl, reference, expected), corruptPayload: corruptSvmSignature, }; diff --git a/packages/core/src/x402/chains/types.ts b/packages/core/src/x402/chains/types.ts index 101bca2..1e13077 100644 --- a/packages/core/src/x402/chains/types.ts +++ b/packages/core/src/x402/chains/types.ts @@ -80,11 +80,12 @@ export interface PaymentChain { /** Whether `reference` has the shape of a settlement on this chain. */ isSettlementReference(reference: string): boolean; /** - * Looks the settlement up and holds it to the advertised terms. Returns a - * verdict about the target; throws when the chain's RPC gives no answer, - * which says nothing about the target. + * Looks the settlement up on `network` and holds it to the advertised + * terms. Returns a verdict about the target; throws when the chain's RPC + * gives no answer, which says nothing about the target. */ verifySettlement( + network: string, rpcUrl: string, reference: string, expected: ExpectedSettlement, diff --git a/packages/core/src/x402/simulator.ts b/packages/core/src/x402/simulator.ts index a84e2e6..5007d56 100644 --- a/packages/core/src/x402/simulator.ts +++ b/packages/core/src/x402/simulator.ts @@ -437,6 +437,48 @@ function describeOffered(accepts: ReadonlyArray<{ readonly network: string }>): return networks.length === 0 ? "none" : networks.join(", "); } +/** What the SDK client's spend controls say when they refuse a payment. */ +const SPEND_CONTROLS_REFUSAL = "All payment requirements were rejected by spendControls"; + +/** + * Builds a payment, turning a refusal by the SDK client's spend controls into + * no verdict that says why. + * + * The official client pays only its default assets (plus the ones an adapter + * allows, such as Circle's USDC on Ethereum Sepolia) and at most $1 a + * payment, so that a paywall cannot charge a payer more than it meant to. + * Wasit keeps those limits: it pays automatically, and on `stellar:pubnet` + * with real money. A target above them is not at fault, and nothing was + * sent, so this is `ERROR (setup)`, not a harness error. Matched on the SDK's + * message (`@x402/core` 2.28 `client/index.mjs`); a message it does not + * recognise passes through as before. + */ +async function withinSpendControls( + network: string, + selected: PaymentRequirements, + build: () => Promise, +): Promise { + try { + return await build(); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (!message.startsWith(SPEND_CONTROLS_REFUSAL)) throw error; + if (message.includes("only default assets")) { + throw new CheckSetupError( + `The target asks to be paid in ${selected.asset} on ${network}, which is not ` + + `an asset the official x402 client pays in by default, nor one Wasit allows, ` + + `so nothing was sent.`, + ); + } + throw new CheckSetupError( + `The target asks for ${selected.amount} base units of ${selected.asset}, more ` + + `than the $1 a payment the official x402 client pays by default, so nothing ` + + `was sent. The cap keeps a paywall from charging a payer more than it meant ` + + `to; a target priced at $1 or less is checked as usual.`, + ); + } +} + /** * Reads the challenge and produces a signed payment payload for it. * @@ -514,15 +556,19 @@ async function preparePayment( await beforeSigning?.(selected); if (alter === undefined) { - const paymentPayload = await client.createPaymentPayload(paymentRequired); + const paymentPayload = await withinSpendControls(options.network, selected, () => + client.createPaymentPayload(paymentRequired), + ); return { httpClient, paymentPayload, selected, idempotent }; } const altered = await alter(selected); - const signed = await client.createPaymentPayload({ - ...paymentRequired, - accepts: [{ ...selected, ...altered }], - }); + const signed = await withinSpendControls(options.network, selected, () => + client.createPaymentPayload({ + ...paymentRequired, + accepts: [{ ...selected, ...altered }], + }), + ); const paymentPayload = { ...signed, accepted: selected }; return { httpClient, paymentPayload, selected, idempotent }; } @@ -677,9 +723,13 @@ async function checkSignatureAccepted(options: X402PaymentCheckOptions): Promise return honest(fail( `Expected 2xx after a valid payment, got ${paid.status}` + `${reason === undefined ? "" : ` (${reason})`}.`, - "A valid payment was refused. Your server's log of the facilitator's " + - "verify or settle response says why; serve the resource with a 2xx once " + - "the payment settles.", + reason?.startsWith("settlement failed:") + ? "The payment verified, but the facilitator did not settle it, and said why. " + + "That is the facilitator's failure, or its RPC's, yet a valid payment was " + + "refused; check the facilitator's account and endpoint, then re-run." + : "A valid payment was refused. Your server's log of the facilitator's " + + "verify or settle response says why; serve the resource with a 2xx once " + + "the payment settles.", ), false); } @@ -704,7 +754,7 @@ async function checkSignatureAccepted(options: X402PaymentCheckOptions): Promise } const rpcUrl = chain.resolveRpcUrl(options.network, options.rpcUrl); - const verdict = await chain.verifySettlement(rpcUrl, reference, { + const verdict = await chain.verifySettlement(options.network, rpcUrl, reference, { amount, token: accepted.asset, recipient: accepted.payTo, @@ -794,24 +844,44 @@ function served(status: number): boolean { } /** - * Why the target refused a payment, when it says: x402 servers answer a - * refused payment with a fresh challenge whose `error` names the reason - * (often the facilitator's, e.g. `invalid_exact_evm_payload_signature`). - * Shown with a PASS so a reader can tell the refusal came from the rule the - * check is about, not from something else that also answers 402. + * Why the target refused a payment, when it says. A payment refused at + * verify comes back with a fresh challenge whose `error` names the reason + * (often the facilitator's, e.g. `invalid_exact_evm_payload_signature`). One + * that verified but did not settle comes back with a `PAYMENT-RESPONSE` whose + * `success` is false and whose `errorReason` says why, as the official SDK + * server sends it; that is read first, and named as a settlement failure, so + * an operator can tell their facilitator's failure from a refusal. Shown with + * a PASS so a reader can tell the refusal came from the rule the check is + * about, not from something else that also answers 402. */ async function refusalReason(response: Response): Promise { - const header = response.headers.get("PAYMENT-REQUIRED"); - const fromJson = (text: string): string | undefined => { + const decode = (header: string): Record | undefined => { try { - const parsed = JSON.parse(text) as { error?: unknown }; - return typeof parsed.error === "string" && parsed.error.length > 0 ? parsed.error : undefined; + return asRecord(JSON.parse(Buffer.from(header, "base64").toString("utf-8"))); } catch { return undefined; } }; - if (header !== null) return fromJson(Buffer.from(header, "base64").toString("utf-8")); - return fromJson(await readText(response)); + const settlement = response.headers.get("PAYMENT-RESPONSE"); + if (settlement !== null) { + const parsed = decode(settlement); + const reason = parsed?.["errorReason"]; + if (parsed?.["success"] === false && typeof reason === "string" && reason.length > 0) { + // The reason is a code; some facilitators append a long RPC message. + return `settlement failed: ${/^[\w.-]+/.exec(reason)?.[0] ?? reason}`; + } + } + const errorOf = (record: Record | undefined): string | undefined => { + const error = record?.["error"]; + return typeof error === "string" && error.length > 0 ? error : undefined; + }; + const header = response.headers.get("PAYMENT-REQUIRED"); + if (header !== null) return errorOf(decode(header)); + try { + return errorOf(asRecord(JSON.parse(await readText(response)))); + } catch { + return undefined; + } } function statusWithReason(status: number, reason: string | undefined): string { diff --git a/packages/core/test/fixtures/x402-evm-permit2-server.ts b/packages/core/test/fixtures/x402-evm-permit2-server.ts index 51fa1ac..8d6800a 100644 --- a/packages/core/test/fixtures/x402-evm-permit2-server.ts +++ b/packages/core/test/fixtures/x402-evm-permit2-server.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import express from "express"; import { paymentMiddlewareFromConfig } from "@x402/express"; -import { HTTPFacilitatorClient } from "@x402/core/server"; +import { HTTPFacilitatorClient, type FacilitatorClient } from "@x402/core/server"; import { ExactEvmScheme } from "@x402/evm/exact/server"; import { declareEip2612GasSponsoringExtension } from "@x402/extensions"; @@ -18,6 +18,29 @@ const FACILITATOR_URL = "https://www.x402.org/facilitator"; const PAY_TO = process.env.EVM_PAYEE_ADDRESS; if (!PAY_TO) throw new Error("EVM_PAYEE_ADDRESS is not set in .env"); +// The facilitator's refusals, logged: a refused settlement otherwise reaches +// the client as a bare 402 with no reason, and the fixture's own log is the +// only place to find out why (one Permit2 run did that on 2026-10-05). +const remote = new HTTPFacilitatorClient({ url: FACILITATOR_URL }); +const facilitator: FacilitatorClient = { + async verify(payload, requirements) { + const result = await remote.verify(payload, requirements); + if (!result.isValid) console.log(`verify refused: ${result.invalidReason} ${result.invalidMessage ?? ""}`); + return result; + }, + async settle(payload, requirements) { + try { + const result = await remote.settle(payload, requirements); + if (!result.success) console.log(`settle failed: ${result.errorReason} ${result.errorMessage ?? ""}`); + return result; + } catch (error) { + console.log(`settle threw: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + }, + getSupported: () => remote.getSupported(), +}; + const app = express(); app.use( @@ -34,7 +57,7 @@ app.use( extensions: { ...declareEip2612GasSponsoringExtension() }, }, }, - new HTTPFacilitatorClient({ url: FACILITATOR_URL }), + facilitator, [{ network: NETWORK, server: new ExactEvmScheme() }], ), ); diff --git a/packages/core/test/fixtures/x402-evm-sepolia-server.ts b/packages/core/test/fixtures/x402-evm-sepolia-server.ts new file mode 100644 index 0000000..a4d5be7 --- /dev/null +++ b/packages/core/test/fixtures/x402-evm-sepolia-server.ts @@ -0,0 +1,80 @@ +import "dotenv/config"; +import express from "express"; +import { paymentMiddlewareFromConfig } from "@x402/express"; +import { x402Facilitator } from "@x402/core/facilitator"; +import type { FacilitatorClient } from "@x402/core/server"; +import type { SupportedResponse } from "@x402/core/types"; +import { toFacilitatorEvmSigner } from "@x402/evm"; +import { registerExactEvmScheme } from "@x402/evm/exact/facilitator"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { createWalletClient, http, publicActions, type Hex } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { sepolia } from "viem/chains"; + +// The Ethereum Sepolia twin of x402-evm-server.ts. No public facilitator +// settles Ethereum Sepolia, so this one runs the official SDK's own +// facilitator in process (x402Facilitator with the EVM exact scheme), +// submitting each settlement from EVM_FACILITATOR_PRIVATE_KEY, which pays the +// gas and so needs Sepolia ETH. The payer still needs only the token. The SDK +// ships no default asset here, so the price names Circle's Sepolia USDC and +// its EIP-712 domain: 10000 units, $0.01. +const PORT = 3008; +const ROUTE_PATH = "/protected"; +const NETWORK = "eip155:11155111"; +const USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"; +const RPC_URL = process.env.SEPOLIA_RPC_URL ?? sepolia.rpcUrls.default.http[0]; +const PAY_TO = process.env.EVM_PAYEE_ADDRESS; +const FACILITATOR_KEY = process.env.EVM_FACILITATOR_PRIVATE_KEY; +if (!PAY_TO) throw new Error("EVM_PAYEE_ADDRESS is not set in .env"); +if (!FACILITATOR_KEY) throw new Error("EVM_FACILITATOR_PRIVATE_KEY is not set in .env"); + +const account = privateKeyToAccount(FACILITATOR_KEY as Hex); +const wallet = createWalletClient({ account, chain: sepolia, transport: http(RPC_URL) }).extend(publicActions); +const facilitator = new x402Facilitator(); +// The signer the SDK's facilitator needs, method by method, from one viem +// wallet client extended with the public actions. +registerExactEvmScheme(facilitator, { + signer: toFacilitatorEvmSigner({ + address: account.address, + readContract: (args) => wallet.readContract(args as never), + verifyTypedData: (args) => wallet.verifyTypedData(args as never), + writeContract: (args) => wallet.writeContract(args as never), + sendTransaction: (args) => wallet.sendTransaction(args as never), + waitForTransactionReceipt: (args) => wallet.waitForTransactionReceipt(args), + getCode: (args) => wallet.getCode(args), + }), + networks: NETWORK, +}); + +// The resource server talks to the facilitator through the same interface it +// would use over HTTP. +const local: FacilitatorClient = { + verify: (payload, requirements) => facilitator.verify(payload, requirements), + settle: (payload, requirements) => facilitator.settle(payload, requirements), + getSupported: async () => facilitator.getSupported() as SupportedResponse, +}; + +const app = express(); + +app.use( + paymentMiddlewareFromConfig( + { + [`GET ${ROUTE_PATH}`]: { + accepts: { + scheme: "exact", + price: { amount: "10000", asset: USDC, extra: { name: "USDC", version: "2" } }, + network: NETWORK, + payTo: PAY_TO, + }, + }, + }, + local, + [{ network: NETWORK, server: new ExactEvmScheme() }], + ), +); + +app.get(ROUTE_PATH, (_, res) => res.json({ secret: "wasit test resource" })); + +app.listen(PORT, () => + console.log(`Test x402 server (Ethereum Sepolia, local facilitator ${account.address}) on http://localhost:${PORT}${ROUTE_PATH}`), +); diff --git a/packages/core/test/unit/evm-chain.test.ts b/packages/core/test/unit/evm-chain.test.ts index f28bd15..a6614e4 100644 --- a/packages/core/test/unit/evm-chain.test.ts +++ b/packages/core/test/unit/evm-chain.test.ts @@ -169,15 +169,19 @@ describe("corruptEvmSignature", () => { }); describe("the EVM adapter", () => { - it("pays on Base Sepolia, and Stellar still pays on Stellar", () => { + it("pays on Base Sepolia and Ethereum Sepolia, and Stellar still pays on Stellar", () => { assert.equal(paymentChainFor("eip155:84532")?.name, "EVM"); + assert.equal(paymentChainFor("eip155:11155111")?.name, "EVM"); assert.equal(paymentChainFor("stellar:testnet")?.name, "Stellar"); - assert.equal(paymentChainFor("eip155:11155111"), undefined); + // BNB Smart Chain testnet: read-only only. + assert.equal(paymentChainFor("eip155:97"), undefined); }); it("defaults to the chain's public RPC and honours an override", () => { assert.equal(evmChain.resolveRpcUrl("eip155:84532"), "https://sepolia.base.org"); assert.equal(evmChain.resolveRpcUrl("eip155:84532", "https://rpc.internal"), "https://rpc.internal"); + // viem's default for Ethereum Sepolia; the docs name it. + assert.equal(evmChain.resolveRpcUrl("eip155:11155111"), "https://11155111.rpc.thirdweb.com"); }); it("derives the payer address, and rejects a malformed key without echoing it", () => { diff --git a/packages/core/test/unit/payment-negatives.test.ts b/packages/core/test/unit/payment-negatives.test.ts index 967d453..1df4c5d 100644 --- a/packages/core/test/unit/payment-negatives.test.ts +++ b/packages/core/test/unit/payment-negatives.test.ts @@ -41,9 +41,13 @@ interface Received { * with 200 and no settlement (`accept`), or with 402 (`refuse`). */ async function standIn(options: { - mode: "accept" | "refuse" | "permit2-allowance"; + mode: "accept" | "refuse" | "permit2-allowance" | "settle-fail"; amount?: string; paymentIdentifier?: boolean; + network?: string; + asset?: string; + /** settle-fail only: what its PAYMENT-RESPONSE claims. */ + settled?: boolean; }): Promise<{ url: string; received: Received[]; close: () => Promise }> { const received: Received[] = []; const challenge = { @@ -52,9 +56,9 @@ async function standIn(options: { accepts: [ { scheme: "exact", - network: NETWORK, + network: options.network ?? NETWORK, amount: options.amount ?? "10000", - asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + asset: options.asset ?? "0x036CbD53842c5426634e7929541eC2318f3dCF7e", payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", maxTimeoutSeconds: 60, extra: { name: "USDC", version: "2" }, @@ -77,6 +81,19 @@ async function standIn(options: { payload: JSON.parse(Buffer.from(paid, "base64").toString("utf-8")), atSeconds: Math.floor(Date.now() / 1000), }); + if (options.mode === "settle-fail") { + // As the official SDK server answers a payment that verified but did + // not settle (measured with the public facilitator, 2026-10-06). + const failure = { + success: options.settled ?? false, + errorReason: "invalid_exact_evm_transaction_failed: Missing or invalid parameters.", + transaction: "", + network: options.network ?? NETWORK, + }; + response.writeHead(402, { "payment-response": Buffer.from(JSON.stringify(failure)).toString("base64") }); + response.end("{}"); + return; + } if (options.mode === "permit2-allowance") { const refusal = { ...challenge, error: "permit2_allowance_required" }; response.writeHead(412, { "payment-required": Buffer.from(JSON.stringify(refusal)).toString("base64") }); @@ -232,6 +249,47 @@ describe("a Permit2 target that needs an approval this payer never made", () => }); }); +describe("a valid payment whose settlement failed", () => { + it("names the facilitator's reason from PAYMENT-RESPONSE, and says where to look", async () => { + const target = await standIn({ mode: "settle-fail" }); + try { + const r = byId( + await runX402PaymentChecks({ + target: target.url, + network: NETWORK, + payerSecretKey: generatePrivateKey(), + rpcUrl: funded.url, + }), + ); + assert.equal(r["X402-06"]?.pass, false); + assert.match( + r["X402-06"]?.detail ?? "", + /^Expected 2xx after a valid payment, got 402 \(settlement failed: invalid_exact_evm_transaction_failed\)\.$/, + ); + assert.match(r["X402-06"]?.hint ?? "", /the facilitator did not settle it/); + } finally { + await target.close(); + } + }); + + it("does not call a refusal a failed settlement when PAYMENT-RESPONSE claims success", async () => { + const target = await standIn({ mode: "settle-fail", settled: true }); + try { + const r = byId( + await runX402PaymentChecks({ + target: target.url, + network: NETWORK, + payerSecretKey: generatePrivateKey(), + rpcUrl: funded.url, + }), + ); + assert.match(r["X402-06"]?.detail ?? "", /^Expected 2xx after a valid payment, got 402\.$/); + } finally { + await target.close(); + } + }); +}); + describe("a refused valid payment", () => { // Earlier evidence quotes this wording; a reason is added only when given. it("keeps X402-06's wording when the target gives no reason", async () => { @@ -252,6 +310,70 @@ describe("a refused valid payment", () => { }); }); +describe("on Ethereum Sepolia", () => { + // Circle's Sepolia USDC is not an SDK default asset, and the SDK client's + // spend controls refuse non-default assets unless they are allowed. + it("pays in Circle's Sepolia USDC, up to the SDK's own $1 cap", async () => { + for (const [amount, paid] of [ + ["10000", true], + ["1000001", false], + ] as const) { + const target = await standIn({ + mode: "refuse", + amount, + network: "eip155:11155111", + asset: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + }); + try { + const r = byId( + await runX402PaymentChecks({ + target: target.url, + network: "eip155:11155111", + payerSecretKey: generatePrivateKey(), + rpcUrl: funded.url, + }), + ); + assert.equal(target.received.length, paid ? 1 : 0, amount); + if (paid) assert.match(r["X402-06"]?.detail ?? "", /got 402/); + else { + assert.equal(r["X402-06"]?.error?.kind, "setup"); + assert.match(r["X402-06"]?.detail ?? "", /asks for 1000001 base units .* more than the \$1 a payment/); + } + } finally { + await target.close(); + } + } + }); +}); + +describe("a target the SDK client's spend controls refuse", () => { + // Not the target's fault, and nothing is sent: no verdict, saying why. + it("is a setup error on a price above $1, or on an asset the client does not pay in", async () => { + for (const [label, options, reason] of [ + ["above $1", { amount: "1000001" }, /more than the \$1 a payment the official x402 client pays by default/], + ["other asset", { asset: "0x0000000000000000000000000000000000000Bad" }, /not an asset the official x402 client pays in by default/], + ] as const) { + const target = await standIn({ mode: "accept", ...options }); + try { + const r = byId( + await runX402PaymentChecks({ + target: target.url, + network: NETWORK, + payerSecretKey: generatePrivateKey(), + rpcUrl: funded.url, + }), + ); + assert.equal(r["X402-06"]?.error?.kind, "setup", label); + assert.match(r["X402-06"]?.detail ?? "", reason, label); + for (const id of ["X402-07", "X402-08", "X402-09", "X402-10"]) assert.equal(r[id]?.skipped, true, `${label} ${id}`); + assert.equal(target.received.length, 0, label); + } finally { + await target.close(); + } + } + }); +}); + describe("a payer that cannot cover the price", () => { // Measured on Solana devnet: an unfunded payer's payment is refused for a // reason a real defect gives too. Read first, it is the payer's, not the diff --git a/packages/core/test/unit/x402-networks.test.ts b/packages/core/test/unit/x402-networks.test.ts index e3ed65a..bbbe2b7 100644 --- a/packages/core/test/unit/x402-networks.test.ts +++ b/packages/core/test/unit/x402-networks.test.ts @@ -277,12 +277,12 @@ describe("payment checks on a challenge Wasit cannot pay", () => { } it("stops before paying when asked to pay on a network it cannot pay on", async () => { - // Ethereum Sepolia: read-only only. - const target = await serveV2(v2(option("eip155:11155111"))); + // BNB Smart Chain testnet: read-only only. + const target = await serveV2(v2(option("eip155:97"))); try { const results = await runX402PaymentChecks({ target: target.url, - network: "eip155:11155111", + network: "eip155:97", payerSecretKey, }); assert.equal(results.length, 1); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 08f0224..2000571 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -173,7 +173,7 @@ server.registerTool( "Runs the x402 conformance checks (X402-01..10) against a running service. " + "Payment checks are included only when the network's payer key is set in " + "this server's environment (STELLAR_PRIVATE_KEY on Stellar, EVM_PRIVATE_KEY " + - "on Base Sepolia, SVM_PRIVATE_KEY on Solana devnet); otherwise they are " + + "on Base and Ethereum Sepolia, SVM_PRIVATE_KEY on Solana devnet); otherwise they are " + "skipped. When they do run, " + "X402-06 settles a real payment and X402-07..10 send payments the target " + "must refuse, so each call spends testnet funds and repeated calls spend " @@ -186,7 +186,8 @@ server.registerTool( .optional() .describe( 'Network the payment checks pay on: "stellar:testnet" (default), ' + - '"stellar:pubnet", "eip155:84532" (Base Sepolia) or ' + + '"stellar:pubnet", "eip155:84532" (Base Sepolia), "eip155:11155111" ' + + '(Ethereum Sepolia) or ' + '"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" (Solana devnet). The ' + "read-only checks apply to a challenge on any chain.", ), diff --git a/scripts/fixtures.sh b/scripts/fixtures.sh index 775ccdc..f447881 100755 --- a/scripts/fixtures.sh +++ b/scripts/fixtures.sh @@ -29,6 +29,7 @@ FIXTURES=( "x402-evm:3005:packages/core/test/fixtures/x402-evm-server.ts" "x402-permit2:3006:packages/core/test/fixtures/x402-evm-permit2-server.ts" "x402-svm:3007:packages/core/test/fixtures/x402-svm-server.ts" + "x402-sepolia:3008:packages/core/test/fixtures/x402-evm-sepolia-server.ts" ) field() { echo "$1" | cut -d: -f"$2"; } @@ -91,6 +92,13 @@ cmd_start() { continue fi ;; + x402-sepolia) + # It runs its own facilitator, which pays the gas from this key. + if ! grep -q '^EVM_PAYEE_ADDRESS=.' "$ROOT/.env" || ! grep -q '^EVM_FACILITATOR_PRIVATE_KEY=.' "$ROOT/.env"; then + printf " %-9s skipped (EVM_PAYEE_ADDRESS or EVM_FACILITATOR_PRIVATE_KEY not set in .env)\n" "$(field "$fixture" 1)" + continue + fi + ;; x402-svm) if ! grep -q '^SVM_PAYEE_ADDRESS=.' "$ROOT/.env"; then printf " %-9s skipped (SVM_PAYEE_ADDRESS not set in .env)\n" "$(field "$fixture" 1)" @@ -109,6 +117,7 @@ cmd_start() { echo " x402-evm http://localhost:3005/protected (Base Sepolia, needs EVM_PAYEE_ADDRESS)" echo " x402-permit2 http://localhost:3006/protected (Base Sepolia, Permit2 with EIP-2612 sponsoring)" echo " x402-svm http://localhost:3007/protected (Solana devnet, needs SVM_PAYEE_ADDRESS)" + echo " x402-sepolia http://localhost:3008/protected (Ethereum Sepolia, own facilitator, needs EVM_FACILITATOR_PRIVATE_KEY)" } cmd_stop() { diff --git a/scripts/run-all.sh b/scripts/run-all.sh index 9710d20..ba41589 100755 --- a/scripts/run-all.sh +++ b/scripts/run-all.sh @@ -106,13 +106,14 @@ else fi # Base Sepolia, only when its fixture is up: fixtures.sh starts it only when # .env has EVM_PAYEE_ADDRESS. The payment checks read EVM_PRIVATE_KEY. -for evm in "x402-evm:3005" "x402-permit2:3006"; do - name="${evm%%:*}"; port="${evm#*:}" +# Ethereum Sepolia likewise (port 3008), when its fixture has a facilitator key. +for evm in "x402-evm:3005:eip155:84532" "x402-permit2:3006:eip155:84532" "x402-sepolia:3008:eip155:11155111"; do + name="${evm%%:*}"; rest="${evm#*:}"; port="${rest%%:*}"; network="${rest#*:}" if lsof -nP -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1; then if [ "$FULL" = 1 ]; then - SUITES+=("$name|test --target http://localhost:$port/protected --network eip155:84532") + SUITES+=("$name|test --target http://localhost:$port/protected --network $network") else - SUITES+=("$name|test --target http://localhost:$port/protected --network eip155:84532 --read-only") + SUITES+=("$name|test --target http://localhost:$port/protected --network $network --read-only") fi fi done diff --git a/skills/wasit/SKILL.md b/skills/wasit/SKILL.md index aef4fd9..d17dda2 100644 --- a/skills/wasit/SKILL.md +++ b/skills/wasit/SKILL.md @@ -53,7 +53,8 @@ npx @wasit-dev/cli test --target https://your-service.example/paid --read-only That runs `X402-01` through `X402-05`, on a challenge from any chain. Add a payer key (`--payer-key`/`STELLAR_PRIVATE_KEY`, `EVM_PRIVATE_KEY` with -`--network eip155:84532` for Base Sepolia, or `SVM_PRIVATE_KEY` with +`--network eip155:84532` for Base Sepolia or `eip155:11155111` for Ethereum Sepolia, or +`SVM_PRIVATE_KEY` with `--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` for Solana devnet) to also run `X402-06`–`10`: `X402-06` settles a real testnet payment and verifies it on-chain, and `X402-07`–`10` send