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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ STELLAR_PRIVATE_KEY=
# Only needed to run the bundled fixture, not to test a third-party service.
STELLAR_PAYEE_ADDRESS=

# --- x402 payment checks on Base Sepolia (eip155:84532) ---------------------
# Payer private key (0x + 64 hex) for --network eip155:84532. It needs Base
# Sepolia USDC and no ETH: the facilitator pays the gas (EIP-3009).
EVM_PRIVATE_KEY=

# Recipient address (0x...) the Base Sepolia fixture server charges to.
# Only needed to run the bundled fixture.
EVM_PAYEE_ADDRESS=

# --- MPP charge mode (MPP-01) ------------------------------------------------
# Payer secret key (S...) for the charge check. Settles a real payment on every
# run; repeated runs spend repeatedly.
Expand Down
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ type, such as a numeric `amount` or a `maxTimeoutSeconds` that is not a positive
number, is reported as such instead of as missing. Challenges built with the
official x402 server SDK carry every field, so they are unaffected.

**Added — the x402 payment checks pay on Base Sepolia.** `--network
eip155:84532` (MCP: `network`) runs `X402-06` and `X402-07` there, with the payer key
in `EVM_PRIVATE_KEY`. Payment uses the `exact` scheme's EIP-3009 method, so the
facilitator pays the gas and a payer needs Base Sepolia USDC and no ETH. `X402-06`
holds the settlement to the receipt's ERC-20 `Transfer` log by the Stellar rules;
`X402-07` forges only the EIP-3009 signature. Against Wasit's own Base Sepolia
fixture, 7/7 with `X402-06` settled on-chain; against `wasit serve` posing on Base
Sepolia, the lying modes fail both checks
([evidence](docs/evidence/2026-10-05-base-sepolia-verification-run.md)). Stellar stays
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.

**Changed — a payer key for the wrong chain, or a malformed one, stops the run at
preflight.** It used to surface mid-payment as a harness error. The message never
echoes the key.

**Added — `wasit serve`, a paywall that misbehaves on purpose.** The checks
test a service that sells; this tests an agent that pays. It runs a local x402
paywall in one of four modes: `no-settle` serves without settling,
Expand All @@ -56,7 +72,8 @@ asks for mainnet, `overprice` asks for one million USDC. The server reports
what the agent did. It never settles or forwards anything, so no funds move.
Every mode's challenge is well-formed (`wasit test --read-only` passes it), and
the two settlement modes reproduce the servers built for the 0.6.0 A/B:
`X402-06` and `X402-07` fail against them.
`X402-06` and `X402-07` fail against them. `--network eip155:84532` poses the same
modes on Base Sepolia.

**Changed — the CLI's payment warning says when it applies.** It said funds
would move before every payment run, including runs where the target offered
Expand Down
29 changes: 17 additions & 12 deletions docs/CHECKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ 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. **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`. |
| `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)`. |
| `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. **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 reference is an EVM transaction hash, and the receipt's ERC-20 `Transfer` log is held to the same terms: 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. |
| `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` 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. |

**Note on the x402 payment checks' cost (Week 2).** `X402-06` and `X402-07`
are not free. `X402-06` settles a real payment against the target, and `X402-07`
Expand Down Expand Up @@ -59,14 +59,14 @@ in that case, contradicting the `X402-07` row above, and a v1 challenge left

**Note on networks (0.7.0).** `X402-01`–`05` read the challenge only, so they
apply to an x402 service on any chain. The payment checks pay through the
`exact` scheme on Stellar, on the network the run names (`stellar:testnet` by
default). When a challenge offers several options, the payment is built for
`exact` scheme on the network the run names: `stellar:testnet` (the default),
`stellar:pubnet`, or `eip155:84532` (Base Sepolia, with the EIP-3009 method,
where the facilitator pays the gas). 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 `X402-06` and `X402-07` **skipped** with the networks it
does offer, since nothing was paid and nothing refused. Asking for a network
other than `stellar:testnet` or `stellar:pubnet`, or for pubnet without an RPC
endpoint, stops the run before any payment, as no settlement could be
verified.
does offer, since nothing was paid and nothing refused. Asking for any other network, for pubnet without an RPC endpoint, or paying
with a key for the wrong chain, stops the run before any payment, as no
settlement could be verified.

## MPP — Charge Mode

Expand Down Expand Up @@ -338,9 +338,14 @@ paying through.

### The payment checks cannot pay

The payment checks need a testnet payer holding testnet USDC.
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
trustline; the
balance itself needs one visit to https://faucet.circle.com, since there is no
scriptable USDC faucet for Stellar (see the CLI guide's wallet setup).
trustline; the balance itself needs one visit to https://faucet.circle.com,
since there is no scriptable USDC faucet for Stellar (see the CLI guide's
wallet setup).

On Base Sepolia (`--network eip155:84532`), the payer is `EVM_PRIVATE_KEY`, a
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.
108 changes: 108 additions & 0 deletions docs/evidence/2026-10-05-base-sepolia-verification-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# x402 Payment Checks on Base Sepolia — A/B Against Lying Servers, and a Real Settlement

**Date:** 2026-10-05
**Wasit:** builds of the local `release/0.7.0` branch (`282004c` for the EVM adapter,
`fb21760` for `wasit serve` on Base Sepolia, `7d27551` for the fixture), not yet
published.
**Targets:** `wasit serve --network eip155:84532`, the paywall that misbehaves on
purpose, in three modes; and Wasit's own Base Sepolia x402 fixture
(`packages/core/test/fixtures/x402-evm-server.ts`), settling through the public
`x402.org` facilitator. Both ran on our machine; the chain is Base Sepolia.
**Environment:** macOS, Node `v26.8.1`.

**What this is for.** Until 0.7.0, `X402-06` and `X402-07` paid on Stellar only. They
now also pay on Base Sepolia (`eip155:84532`), with the `exact` scheme's EIP-3009
method: the payer signs a `transferWithAuthorization` off-chain and the facilitator
submits it and pays the gas. The same two things have to hold as when the checks were
made stricter in 0.6.0: they fail servers that lie, and they 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: a throwaway key generated for the run, holding nothing. That is enough here:
EIP-3009 signing needs no balance, and `wasit serve` never settles, so nothing moved.

| `wasit serve` mode | What it does | `X402-06` | `X402-07` |
|---|---|---|---|
| `no-settle` | Serves any payment with 200, without settling and without `PAYMENT-RESPONSE` | **FAIL** | **FAIL** |
| `wrong-settlement` | Serves with a `PAYMENT-RESPONSE` naming a transaction that does not exist | **FAIL** | **FAIL** |
| `wrong-network` | Asks to be paid on Base mainnet (`eip155:8453`) | SKIP, nothing sent | SKIP |

Output, quoted as printed:

```
FAIL X402-06 Signature Resubmit Accepted
Valid payment accepted (HTTP 200), but the response carried no
PAYMENT-RESPONSE header, which the x402 v2 HTTP transport uses to report
settlement. Whether the payment settled cannot be verified.
FAIL X402-07 Invalid Signature Rejected
A payment with a corrupted authorization signature was accepted with
HTTP 200 — security-relevant failure.
```

```
FAIL X402-06 Signature Resubmit Accepted
Target reported settlement as tx 0x18bb3379…2393, but the chain produced
32 more blocks without it. Either it was never broadcast, or the target
referenced a transaction that does not exist.
```

The second was decided against Base Sepolia itself, through its public RPC
(`https://sepolia.base.org`): the run took 65 seconds, nearly all of it waiting until
the chain had produced 32 blocks without the cited transaction.

```
SKIP X402-06 Signature Resubmit Accepted
Skipped: the target offers no payment option on eip155:84532 (it offers
eip155:8453), so no payment was attempted (see X402-05).
```

## An honest server

Payer: a Base Sepolia account holding 20 USDC, from Circle's faucet, and **no ETH**.

`wasit test --target http://localhost:3005/protected --network eip155:84532`: **7/7.**

```
PASS X402-06 Signature Resubmit Accepted
Valid payment accepted (HTTP 200) and settled on-chain for exactly the
advertised 10000 base units of 0x036CbD53842c5426634e7929541eC2318f3dCF7e
to 0xb57bFdA962aAa607D5260747A175cda8f4bDDad7, verified from the Transfer
log (tx 0xb182df33…7e91).
PASS X402-07 Invalid Signature Rejected
Payment with a corrupted authorization signature correctly rejected (HTTP 402).
```

[`0xb182df33…7e91`](https://base-sepolia.blockscout.com/tx/0xb182df33ee676ac15ec9137717027a61432fc24af4761d9fa89fddb23cbf7e91)

Read back independently afterwards, outside Wasit, with `viem` against the same RPC:

| | |
|---|---|
| Receipt | `success`, block 47709831 |
| Sent by (gas paid by) | `0xd407…f1bf`, the facilitator's account, calling the USDC contract |
| Transfers logged | exactly one: payer `0xC171…0428` to payee `0xb57b…Dad7`, 10000 units |
| Payer after | 19.99 USDC, **still 0 ETH** |
| Payee after | 20.01 USDC |

So the settlement Wasit verified is the one that happened, and a Base Sepolia payer
needs the token and nothing else. `X402-07`'s forged signature was refused, and nothing
settled for it.

## What did not change

The same day, on the same build, Wasit's Stellar fixtures were unchanged: x402 7/7 with
`X402-06` settled on-chain, `MPP-01` PASS, channel 3 passed and 2 skipped. 199 core and
54 CLI offline tests pass.

## Limits

- One EVM network: Base Sepolia. Ethereum Sepolia and BNB Smart Chain testnet get the
read-only checks only; no public facilitator settles them and the official SDK ships
no default token for either.
- The honest target is Wasit's own fixture on the official SDK. No third-party Base
Sepolia service was tested.
- Unreleased: this ran from the branch, not from npm. Registry parity follows the 0.7.0
release.
1 change: 1 addition & 0 deletions docs/evidence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ and what it does and does not establish. Newest first.

| Date | File | What it shows | Deliverable |
|---|---|---|---|
| 2026-10-05 | [base-sepolia-verification-run](2026-10-05-base-sepolia-verification-run.md) | The x402 payment checks on Base Sepolia (unreleased 0.7.0 branch): servers that skip settlement or cite a missing transaction fail `X402-06` and `X402-07`; a mainnet request gets nothing sent; Wasit's own fixture passes 7/7 with `X402-06` settled on-chain and read back independently, the payer holding no ETH | Beyond the SOW |
| 2026-09-30 | [x402-0.6.0-verification-run](2026-09-30-x402-0.6.0-verification-run.md) | 0.6.0's stricter `X402-06` and `X402-07`, A/B against 0.5.0: servers built to skip settlement, misreport it or skip signature checks pass 0.5.0 and fail 0.6.0. `stellar/x402-stellar`'s reference still passes 7/7, with `X402-06` verified on-chain. Registry parity for the published 0.6.0 | D1 |
| 2026-09-28 | [authorized-third-party-run](2026-09-28-authorized-third-party-run.md) | Published 0.5.0 against defi-copilot, a third-party service, with its operator's written authorization, as a local instance: 3 pass, 2 diverge from the Stellar spec (x402 v1, non-CAIP-2 network), 2 no verdict, nothing paid. Also 0.5.0 registry parity against Wasit's own fixtures | D2 |
| 2026-09-24 | [official-sdk-reference-run](2026-09-24-official-sdk-reference-run.md) | Published 0.4.0 against `stellar/stellar-mpp-sdk`'s own example servers. Charge settles on-chain; an A/B across the SDK's `mppx` 0.10.1 bump shows rejected channel vouchers moving from 402 to 500 ([#82](https://github.com/stellar/stellar-mpp-sdk/issues/82)). Closes 0.4.0 registry parity | D2 |
Expand Down
Loading
Loading