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
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ 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 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
unfunded payer used to fail `X402-06` on Solana devnet, for a reason real defects give
too, and to stop on Stellar with only the token's error code. A balance that cannot be
read pays as before. Measured on all three chains with fresh payers
([evidence](docs/evidence/2026-10-05-payer-balance-check-run.md)).

**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.
Expand All @@ -113,7 +121,20 @@ 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. `--network eip155:84532` poses the same
modes on Base Sepolia, and `--network solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` on Solana
devnet.
devnet. Two more modes are about the challenge: `v1-challenge` sends only an x402 v1
challenge (Base Sepolia and Solana devnet; v1 names no Stellar network), and
`malformed-header` a `PAYMENT-REQUIRED` header that does not decode; both answer a
payment with 402 and log which header it came in. The official SDK client pays the
first as v1 and refuses the second without paying
([evidence](docs/evidence/2026-10-05-wallet-network-and-serve-modes-run.md)).

**Added — `wasit wallet --network` for the Base Sepolia and Solana devnet payers.**
`wasit wallet create --role x402 --network eip155:84532` (or Solana devnet) generates the
payer key in the form the payment checks read and prints its `.env` line and the
address to fund at faucet.circle.com; `status --network` shows that payer's USDC
balance, and `fund --network` prints the faucet step, since the payer needs nothing
else. Testnets only; the MPP roles stay on Stellar. The dashboard's environment panel
lists `EVM_PRIVATE_KEY` and `SVM_PRIVATE_KEY`.

**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
12 changes: 9 additions & 3 deletions docs/CHECKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ 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`. 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 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-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. |
Expand Down Expand Up @@ -374,6 +374,11 @@ paying through.

### The payment checks cannot pay

`X402-06` reads the payer's balance before it signs. When the chain reports less than
the advertised amount it stops with `ERROR (setup)`: "This run's payer holds less of …
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)).

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
Expand All @@ -388,7 +393,8 @@ malformed key, is reported at `PREFLIGHT` before anything is sent.

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`)
and no SOL: the facilitator signs as fee payer. The payee's USDC token account must
needs devnet USDC of the SDK's default mint (`4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`),
from the same faucet (network Solana Devnet), and no SOL: the facilitator signs as fee
payer. The payee's USDC token account must
already exist, since the payment's transaction does not create it. A keypair whose public
half does not belong to its seed, or any malformed key, is reported at `PREFLIGHT`.
86 changes: 86 additions & 0 deletions docs/evidence/2026-10-05-payer-balance-check-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# A Payer That Cannot Pay Is the Payer's Problem — X402-06 Reads the Balance First

**Date:** 2026-10-05
**Wasit:** a build of the local `release/0.7.0` branch (`21f7bdc`), not yet published.
**Targets:** Wasit's own x402 fixtures on Stellar testnet (port 3001), Base Sepolia
(3005, EIP-3009; 3006, Permit2) and Solana devnet (3007), each settling through the
public `x402.org` facilitator. All on our machine.
**Environment:** macOS, Node `v26.8.1`.

**What this is for.** A conformance tester must not blame a target for its own setup. A payer holding none of the asset cannot fund a payment, and before this
change that showed up as a verdict about the target, or as an unreadable error:

- On Solana devnet, measured earlier the same day with a throwaway payer:
`FAIL X402-06 Expected 2xx after a valid payment, got 402
(invalid_exact_svm_transaction_simulation_failed)`. The facilitator gives that reason
for real defects too, so it cannot be told apart afterwards.
- On Stellar testnet, the SDK client simulates the transfer while building the payment,
and stopped there: `ERROR X402-06 Check could not run (harness): Stellar simulation
failed with error message: HostError: Error(Contract, #13)`. No verdict, but nothing
that says what to do.
- On Base Sepolia this was not measured before the change.

X402-06 now reads the payer's balance of the advertised asset before anything is
signed. When the chain reports less than the price, the result is `ERROR (setup)` and
nothing is sent. When the balance cannot be read, the run pays as before, so a failed
read never becomes a verdict.

**Authorization.** None was needed or sought. Every target was Wasit's own code on our
machine. No service operated by anyone else was tested.

## Fresh, unfunded payers

Each payer was a keypair generated for the run, holding nothing, never printed.

```
ERROR X402-06 Signature Resubmit Accepted
Check could not run (setup): This run's payer holds less of
CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA than the advertised
100000 base units, so its payment cannot settle, and a refusal would say
nothing about the target. Fund the payer (STELLAR_PRIVATE_KEY), then re-run.
```

| Chain | `X402-06` | `X402-07`–`10` |
|---|---|---|
| Stellar testnet | `ERROR (setup)`, names `STELLAR_PRIVATE_KEY` | SKIP |
| Base Sepolia | `ERROR (setup)`, names `EVM_PRIVATE_KEY` | SKIP |
| Solana devnet | `ERROR (setup)`, names `SVM_PRIVATE_KEY` | SKIP |

How each balance is read:

- **Base Sepolia:** the token's ERC-20 `balanceOf`.
- **Solana devnet:** every token account the payer holds for the mint, summed. The client
pays from the associated one only, so the sum is never less than what it can spend.
- **Stellar:** the token's `balance`, simulated. A Stellar Asset Contract answers an
account without a trustline with an error rather than zero, which is the usual state of
an unfunded payer. So when the token is that asset's SAC, recognised because the asset
named by its `name` (`USDC:GBBD47IF…FLA5`) derives this very contract id, the trustline
is looked up, and none means the payer holds nothing.

The same reads, made directly against each chain's public RPC: the funded payers read
196000000 (Stellar), 19910000 (Base Sepolia) and 19970000 (Solana) base units; fresh
addresses read 0 on all three.

## Funded payers

With the funded payers, on the same build, every x402 fixture passed 10/10: Stellar
(port 3001), Base Sepolia EIP-3009 (3005) and Permit2 (3006), Solana devnet (3007), with
`X402-06` settled on-chain each time.

## An observation on the way

One Permit2 run (port 3006), started a few seconds after an EIP-3009 run from the same
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.

## Limits

- Wasit's own fixtures only. No third-party service was tested.
- A balance that cannot be read gives no protection: the run pays, as before 0.7.0.
- On Stellar, only a Stellar Asset Contract's missing trustline is read as zero; for
other SEP-41 tokens a failed `balance` read pays as before. The asset's issuer, which
needs no trustline, is never read as zero.
- Unreleased: this ran from the branch, not from npm.
Loading
Loading