diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2ad14b..6670661 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -53,13 +53,35 @@ eip155:84532` (MCP: `network`) runs `X402-06` and `X402-07` there, with the paye
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
+`X402-07` forges only the payer's signature. Both EVM transfer methods are paid:
+EIP-3009, and Permit2 with its approval signed as a gas-sponsored EIP-2612 permit
+(a Permit2 target without sponsoring, where the payer never approved Permit2, is
+`ERROR (setup)`, not a failure). Against Wasit's own Base Sepolia fixtures, both
+methods pass with `X402-06` settled on-chain and the payer holding no ETH; 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.
+**Added — three negative payment checks: `X402-08` Payment Replay Rejected, `X402-09`
+Underpayment Rejected, `X402-10` Expired Authorization Rejected.** Each sends a payment the
+target must refuse: the exact payment `X402-06` was accepted for, sent again; a validly
+signed payment for half the price that still claims the full price; and a payment signed
+with a one-second lifetime, sent once it has expired. They run on Stellar and Base
+Sepolia. A refusal now shows the target's stated reason, when it gives one. Against
+Wasit's own fixtures all three pass on both chains, refused for the reason each is about
+(`invalid_exact_stellar_payload_wrong_amount`, `invalid_exact_evm_payload_authorization_value_mismatch`,
+`invalid_exact_evm_payload_authorization_valid_before`, ...); against `wasit serve
+--mode no-settle` all three fail. `X402-08` is skipped when the challenge advertises the
+`payment-identifier` extension, whose cached replies are legitimate. The catalogue grows
+from 13 to 16 checks. `X402-10` waits about 20 seconds on Stellar for the authorization
+to expire.
+
+**Changed — the negative payment checks need an accepted baseline.** When the target
+refuses `X402-06`'s valid payment, `X402-07` to `X402-10` are skipped rather than passed:
+a target that refuses everything proves nothing by refusing a bad payment. `X402-07` used
+to pass in that case.
+
**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.
@@ -83,7 +105,9 @@ no option on the run's network and nothing was paid.
passes `X402-05` where it failed. A challenge with a broken second option now
fails `X402-04` or `X402-05` where it passed. A challenge without `scheme`,
`asset` or `maxTimeoutSeconds`, or with a wrongly typed field, now fails
-`X402-04`.
+`X402-04`. A target that accepts replayed, underpaid or expired payments now fails
+`X402-08`, `X402-09` or `X402-10`; one that refuses even a valid payment now gets
+`X402-07` skipped instead of passed.
## [0.6.0] — 2026-09-30
diff --git a/README.md b/README.md
index db0f065..49b194e 100644
--- a/README.md
+++ b/README.md
@@ -197,7 +197,7 @@ Wasit exists so these are found by a tool, before they are found by a user.
| Area | Status |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| x402 read-only checks (`X402-01`–`05`) | Done, verified against a real facilitator |
-| x402 payment checks (`X402-06`, `07`) | Done, settlement verified from contract events |
+| x402 payment checks (`X402-06`–`10`) | Done, settlement verified from contract events |
| MPP charge mode (`MPP-01`) | Done, settlement verified from contract events |
| MPP channel mode (`MPP-10`–`14`) | Done, including destructive close |
| CLI | Done — five subcommands plus an interactive dashboard |
@@ -295,7 +295,7 @@ again, in a single command:
```bash
./scripts/run-all.sh # free checks only: X402-01..05, MPP-10..12/14
-./scripts/run-all.sh --full # also X402-06/07 and MPP-01, which move testnet funds
+./scripts/run-all.sh --full # also X402-06..10 and MPP-01, which move testnet funds
./scripts/run-all.sh --npm # run the published CLI instead of this checkout
```
diff --git a/docs/CHECKS.md b/docs/CHECKS.md
index ef06758..226d27b 100644
--- a/docs/CHECKS.md
+++ b/docs/CHECKS.md
@@ -17,8 +17,11 @@ 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 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. |
+| `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. |
+| `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. |
+| `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, 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. **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. The target answers with a non-2xx status. An expired authorization cannot settle, so a target that serves it serves for nothing. |
**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`
@@ -30,6 +33,18 @@ only and cost nothing; `--read-only` (CLI) or `readOnly: true` (MCP) restricts a
run to those. The payment checks are also skipped entirely when no payer key is
present, so the default posture is the cheap one.
+**Note on the negative payment checks (0.7.0).** `X402-07` through `X402-10` each send a
+payment the target must refuse, and pass when it does. A refusal only means something
+from a target that accepts a valid payment: one that refuses everything would pass them
+all. So they run only after `X402-06`'s valid payment was answered with a 2xx, and are
+**skipped** otherwise, with that reason. They run in catalogue order after `X402-06`.
+
+**Note on settlement timing (0.7.0).** There is no check that a target settles before it
+serves, because the protocol does not require it. x402 v2's default `authorization` flow
+is verify, run the resource, settle, then respond (spec §6.1); only a flow declared as
+`upfront` or `escrow` in `extra.paymentFlow` settles first. What the client can observe,
+that the response arrives with a settlement that happened, `X402-06` already requires.
+
**Note on cascading failures (Week 2).** The read-only checks inspect
progressively deeper parts of one challenge: the status, then the header, then
its payload, then the fields inside it. When one fails, the checks after it have
@@ -60,8 +75,8 @@ 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 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
+`stellar:pubnet`, or `eip155:84532` (Base Sepolia, with the EIP-3009 or Permit2
+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 any other network, for pubnet without an RPC endpoint, or paying
@@ -173,7 +188,7 @@ arguments, so an agent never handles them.
**Revision note (Week 2, corrected):** `MPP-11`/`MPP-12` pass criteria were first written as "server rejects", then briefly revised to "zero balance delta / silent no-op" after reading only the `stellar-experimental/one-way-channel` on-chain contract source (which is genuinely a silent no-op for stale `settle`/`close` calls). That revision was corrected after reading the `@stellar/mpp` channel server implementation directly: the HTTP-facing server — the actual artifact Wasit tests — rejects stale/replayed commitments explicitly via `ChannelVerificationError`, before the on-chain contract is ever invoked. The contract's own no-op behavior only applies if the contract is called directly, bypassing the server, which is out of scope for Wasit.
-**Status note (Week 2, corrected).** All thirteen checks in this catalogue are implemented
+**Status note (Week 2, corrected; 0.7.0).** All sixteen checks in this catalogue are implemented
and reachable from both front ends. `X402-02` deliberately accepts either header
name because Stellar's own official documentation is not yet internally
consistent (`PAYMENT-REQUIRED` vs `X-Payment`); that divergence is a
@@ -308,6 +323,26 @@ The target served a payment whose authorization signature was corrupted: it
decoded the payment without verifying it. Pass every payment to the
facilitator's verify step and refuse it when verification fails.
+### The same payment is accepted twice (`X402-08`)
+
+A target that serves a payment it has already been paid with sold the resource twice
+for one payment. Each authorization is single-use; the facilitator's verify refuses one
+whose nonce is spent, so verify every payment before serving and never serve a payload
+twice. A server that offers the `payment-identifier` extension may answer a repeat with
+its cached response, and `X402-08` is skipped for it.
+
+### A payment for less than the price is accepted (`X402-09`)
+
+The signature was valid; only the amount was short. A server that checks only the
+signature, or skips the facilitator's verify, sells for less than its price. Hold the
+signed amount to the advertised one before serving: on Stellar it must be exact.
+
+### An expired payment is accepted (`X402-10`)
+
+An authorization past its window (`validBefore` on EVM, the auth entry's expiration
+ledger on Stellar) can never settle, so a target that serves it serves for nothing.
+Verify with the facilitator before serving; it refuses expired authorizations.
+
### A charge to a muxed recipient is refused (`MPP-01`)
A charge server on `@stellar/mpp` 0.7.1 configured with a muxed (`M...`)
diff --git a/docs/evidence/2026-10-05-base-sepolia-verification-run.md b/docs/evidence/2026-10-05-base-sepolia-verification-run.md
index 3a6f2d7..70b1234 100644
--- a/docs/evidence/2026-10-05-base-sepolia-verification-run.md
+++ b/docs/evidence/2026-10-05-base-sepolia-verification-run.md
@@ -2,8 +2,8 @@
**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.
+`fb21760` for `wasit serve` on Base Sepolia, `7d27551` for the fixture, `384f0b8` for
+the Permit2 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
@@ -91,6 +91,27 @@ So the settlement Wasit verified is the one that happened, and a Base Sepolia pa
needs the token and nothing else. `X402-07`'s forged signature was refused, and nothing
settled for it.
+## The Permit2 method
+
+A second fixture (`x402-evm-permit2-server.ts`, port 3006) advertises
+`assetTransferMethod: "permit2"` and the `eip2612GasSponsoring` extension, which the
+public facilitator supports (its `/supported` list). The same payer, still with no ETH,
+ran all ten x402 checks against it: **10/10**, each refusal for its own reason:
+
+```
+PASS X402-07 ... rejected (HTTP 402: invalid_permit2_signature).
+PASS X402-08 ... refused (HTTP 412: permit2_allowance_required).
+PASS X402-09 ... refused (HTTP 402: permit2_amount_mismatch).
+PASS X402-10 ... refused (HTTP 402: permit2_deadline_expired).
+```
+
+Read back for the second run's settlement,
+[`0x860ca8cc…dcf7`](https://base-sepolia.blockscout.com/tx/0x860ca8cc7563e31fb9d95090369e527461bd3f614b15779f4124f30d71fbdcf7):
+`success` in block 47710896, sent by the facilitator's account to the x402 Permit2
+proxy, logging an `Approval` from the payer to the canonical Permit2 contract for 10000
+(the EIP-2612 permit) and exactly one `Transfer` from the payer to the payee for 10000.
+The payer was still at 0 ETH, and its Permit2 allowance back at 0.
+
## What did not change
The same day, on the same build, Wasit's Stellar fixtures were unchanged: x402 7/7 with
@@ -99,9 +120,9 @@ The same day, on the same build, Wasit's Stellar fixtures were unchanged: x402 7
## 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.
+- One EVM network: Base Sepolia. Permit2 was run only with a gas-sponsored approval.
+ 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
diff --git a/docs/evidence/2026-10-05-x402-negative-checks-run.md b/docs/evidence/2026-10-05-x402-negative-checks-run.md
new file mode 100644
index 0000000..e497fa8
--- /dev/null
+++ b/docs/evidence/2026-10-05-x402-negative-checks-run.md
@@ -0,0 +1,112 @@
+# Replay, Underpayment and Expired Authorization — Three New x402 Checks, on Stellar and Base Sepolia
+
+**Date:** 2026-10-05
+**Wasit:** a build of the local `release/0.7.0` branch with `X402-08`, `X402-09` and
+`X402-10`, not yet published.
+**Targets:** Wasit's own x402 fixtures, on Stellar testnet (port 3001) and Base Sepolia
+(port 3005), both settling through the public `x402.org` facilitator; and `wasit serve
+--mode no-settle` on each chain, a paywall that takes any payment. All on our machine.
+**Environment:** macOS, Node `v26.8.1`.
+
+**What this is for.** `X402-07` already checked that a corrupted signature is refused.
+Three more ways a payment can be wrong while its signature is fine are now checked, each
+grounded in the spec: the same payment sent twice (each authorization is single-use,
+x402 v2 §10.1), a payment for less than the price (Stellar: the amount must equal the
+requirement exactly; EVM: verification step 3), and a payment whose authorization has
+expired (§10.1 time constraints; verification of the validity window). As in 0.6.0, a
+check is only worth having if it fails the servers that get this wrong and passes one
+that gets it right.
+
+**Authorization.** None was needed or sought. Every target was Wasit's own code on our
+machine. No service operated by anyone else was tested.
+
+## An honest server: 10/10 on both chains
+
+Each refusal below is quoted as printed, with the reason the target gave.
+
+**Stellar testnet** (`wasit test --target http://localhost:3001/protected`), 42 seconds,
+most of it `X402-10` waiting out the authorization:
+
+```
+PASS X402-07 Invalid Signature Rejected
+ Payment with a corrupted authorization signature correctly rejected
+ (HTTP 402: invalid_exact_stellar_payload_simulation_failed).
+PASS X402-08 Payment Replay Rejected
+ The same payment, sent again, was refused
+ (HTTP 402: invalid_exact_stellar_payload_simulation_failed).
+PASS X402-09 Underpayment Rejected
+ A validly signed payment for 50000 of the advertised 100000 base units was
+ refused (HTTP 402: invalid_exact_stellar_payload_wrong_amount).
+PASS X402-10 Expired Authorization Rejected
+ A payment whose authorization had expired was refused
+ (HTTP 402: invalid_exact_stellar_payload_simulation_failed).
+```
+
+On Stellar the facilitator re-simulates every payment against the current ledger; a
+spent nonce, an expired auth entry and a bad signature all fail that simulation, which is
+the reason it names for three of the four. The underpayment is caught earlier, by the
+amount rule.
+
+**Base Sepolia** (`--network eip155:84532`, target port 3005), 9.5 seconds:
+
+```
+PASS X402-07 Invalid Signature Rejected
+ Payment with a corrupted authorization signature correctly rejected
+ (HTTP 402: invalid_exact_evm_signature).
+PASS X402-08 Payment Replay Rejected
+ The same payment, sent again, was refused (HTTP 402).
+PASS X402-09 Underpayment Rejected
+ A validly signed payment for 5000 of the advertised 10000 base units was
+ refused (HTTP 402: invalid_exact_evm_payload_authorization_value_mismatch).
+PASS X402-10 Expired Authorization Rejected
+ A payment whose authorization had expired was refused
+ (HTTP 402: invalid_exact_evm_payload_authorization_valid_before).
+```
+
+The Base Sepolia replay was refused without a stated reason. A separate probe paid the
+fixture once and replayed the same header: the replay drew 402 with no
+`PAYMENT-REQUIRED` header and an empty `{}` body, a different response path from the
+verify refusals above. What matters to the check holds either way: the resource was not
+served a second time.
+
+## A server that takes anything
+
+`wasit serve --mode no-settle` answers every payment with 200 and settles nothing.
+
+| | Stellar | Base Sepolia |
+|---|---|---|
+| `X402-06` | FAIL | FAIL |
+| `X402-07` | FAIL | FAIL |
+| `X402-08` | FAIL | FAIL |
+| `X402-09` | FAIL | FAIL |
+| `X402-10` | FAIL | FAIL |
+
+## How each payment was built
+
+- **Replay:** the headers `X402-06`'s accepted payment was sent with, byte for byte.
+ Nothing can settle twice, so this costs nothing.
+- **Underpayment:** signed by the official SDK client for half the advertised amount,
+ then sent with `accepted` set back to the advertised terms, so the target's own
+ matching passes and only the signed amount is wrong.
+- **Expired:** signed with `maxTimeoutSeconds: 1`, from which both SDK clients derive
+ the lifetime (EVM `validBefore = now + 1`; Stellar one ledger), held 5 seconds on Base
+ Sepolia and 20 on Stellar, and sent claiming the advertised terms.
+
+An offline test confirms each payload says what its check claims: the replay equals the
+honest header, the underpayment signs 5000 while `accepted` says 10000, and the expired
+authorization's `validBefore` is already past when it arrives.
+
+## Not a check: settling before serving
+
+The default x402 v2 flow is verify, run the resource, settle, respond (§6.1), so a
+server that runs the resource before settling is conformant. Only `upfront` and `escrow`
+flows settle first, and they must say so in `extra.paymentFlow`. What a client can
+observe, a response that arrives with a real settlement, `X402-06` already requires.
+
+## Limits
+
+- Wasit's own fixtures only, on the official SDK and the public facilitator. No
+ third-party service was tested.
+- `X402-08` cannot tell a server that refuses a replay at verify from one that refuses
+ at settlement; both keep the resource from being served twice.
+- Unreleased: this ran from the branch. Registry parity follows the 0.7.0 release.
diff --git a/docs/evidence/README.md b/docs/evidence/README.md
index 1637862..1ac6b31 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-05 | [x402-negative-checks-run](2026-10-05-x402-negative-checks-run.md) | `X402-08` replay, `X402-09` underpayment and `X402-10` expired authorization (unreleased 0.7.0 branch): Wasit's fixtures pass 10/10 on Stellar and Base Sepolia, each refusal for the reason the check is about; `wasit serve --mode no-settle` fails all of them on both chains | Beyond the SOW |
| 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 |
diff --git a/docs/guides/cli.md b/docs/guides/cli.md
index 9f77416..b2aab46 100644
--- a/docs/guides/cli.md
+++ b/docs/guides/cli.md
@@ -34,7 +34,7 @@ Each action reuses the same environment-variable defaults as the matching
subcommand below.
This is deliberately narrower than the subcommands themselves: no
-`X402-06`/`07` payment checks, no `--allow-destructive`, no header/method/body
+`X402-06`–`10` payment checks, no `--allow-destructive`, no header/method/body
overrides, no `--json`. Anything past "run the safe default checks and read
the result" still goes through the direct subcommand — `wasit test
--payer-key ...`, `wasit mpp-channel --allow-destructive ...`, and so on. MPP
diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md
index e406a5c..76b79b3 100644
--- a/docs/guides/configuration.md
+++ b/docs/guides/configuration.md
@@ -11,8 +11,8 @@ keys in this file.
| Checks | Required |
|---|---|
| `X402-01`–`05` | nothing |
-| `X402-06`, `07` on Stellar | `STELLAR_PRIVATE_KEY` |
-| `X402-06`, `07` on Base Sepolia (`--network eip155:84532`) | `EVM_PRIVATE_KEY` |
+| `X402-06`–`10` on Stellar | `STELLAR_PRIVATE_KEY` |
+| `X402-06`–`10` on Base Sepolia (`--network eip155:84532`) | `EVM_PRIVATE_KEY` |
| `MPP-01` | `MPP_PAYER_SECRET`, `MPP_STELLAR_NETWORK` |
| `MPP-10`–`12`, `14` | `COMMITMENT_SECRET_HEX`, `MPP_STELLAR_NETWORK` |
| `MPP-13` | the above, plus `CHANNEL_CONTRACT_DISPOSABLE` and an explicit opt-in |
@@ -55,9 +55,12 @@ string — it signs channel commitments directly rather than transactions.
For Base Sepolia, `EVM_PRIVATE_KEY` is a raw private key, `0x` followed by 64
hex characters, from any EVM wallet or `viem`'s `generatePrivateKey()`. The
payer needs Base Sepolia USDC, from https://faucet.circle.com, and **no ETH**:
-the facilitator pays the gas (the `exact` scheme's EIP-3009 method). A wrong
-or malformed key is reported at `PREFLIGHT` before anything is sent, without
-echoing it.
+the facilitator pays the gas (the `exact` scheme's EIP-3009 method). A target
+that uses Permit2 instead needs no ETH either when it offers the
+`eip2612GasSponsoring` extension; without it, the payer must approve Permit2
+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.
## The disposable channel
diff --git a/docs/guides/core.md b/docs/guides/core.md
index 41c0f0d..be7079a 100644
--- a/docs/guides/core.md
+++ b/docs/guides/core.md
@@ -56,7 +56,7 @@ config, a setup step that was never accepted) comes back as an `ERROR` or
| Function | Checks | Cost |
| --- | --- | --- |
| `runX402ReadChecks({ target, method?, body?, headers? })` | `X402-01`–`05` | Free |
-| `runX402PaymentChecks({ target, network, payerSecretKey, rpcUrl?, method?, body?, headers? })` | `X402-06`, `07` | Settles a real payment every call |
+| `runX402PaymentChecks({ target, network, payerSecretKey, rpcUrl?, method?, body?, headers? })` | `X402-06`–`10` | Settles a real payment every call |
## MPP modes
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 58360fe..90f4e45 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -16,7 +16,7 @@ import { CHECK_COUNT, WASIT_VERSION } from "@/lib/site-facts";
// The one command on the page with a copy button, so it has to be the
// safe one. --read-only is not optional here: without it the payment
-// checks X402-06/07 run as soon as a STELLAR_PRIVATE_KEY is in scope,
+// checks X402-06..10 run as soon as a STELLAR_PRIVATE_KEY is in scope,
// and the CLI loads .env from whatever directory it was run in — so a
// reader who has followed the configuration docs would settle a real
// testnet payment from what the homepage sells as a first look. Matches
@@ -92,7 +92,7 @@ type McpTool = { name: string; checks: string; cost: string; warn?: boolean };
// The MCP server's tools, as packages/server/README.md lists them. The
// fourth is registered only with an explicit opt-in.
const MCP_TOOLS: McpTool[] = [
- { name: "wasit_x402_test", checks: "X402-01–07", cost: "06/07 settle real testnet payments" },
+ { name: "wasit_x402_test", checks: "X402-01–10", cost: "06 settles a real testnet payment; 07–10 must be refused" },
{ name: "wasit_mpp_charge_test", checks: "MPP-01", cost: "Settles a testnet payment every call" },
{ name: "wasit_mpp_channel_test", checks: "MPP-10–12, 14", cost: "Free" },
{ name: "wasit_mpp_channel_test_with_close", checks: "+ MPP-13", cost: "Opt-in only · closes a channel for good", warn: true },
diff --git a/frontend/components/McpSession.tsx b/frontend/components/McpSession.tsx
index c0cb8ad..ba3ad60 100644
--- a/frontend/components/McpSession.tsx
+++ b/frontend/components/McpSession.tsx
@@ -116,7 +116,7 @@ export function McpSession() {
{at >= RESULT_AT && (
✓ 5 passed. The 402 challenge is well formed and names a valid
- network. X402-06/07 did not run: they settle a real payment, and you asked for none.
+ network. X402-06–10 did not run: they settle a real payment, and you asked for none.
)}
diff --git a/frontend/components/StackStrip.tsx b/frontend/components/StackStrip.tsx
index 86a3b44..40ee1cc 100644
--- a/frontend/components/StackStrip.tsx
+++ b/frontend/components/StackStrip.tsx
@@ -27,11 +27,11 @@ function MppLogo() {
type Cell = { key: string; href: string; name: string; role: "checks" | "trusts"; meta: string; logo: React.ReactNode }
-// Check counts as docs/CHECKS.md lists them: seven for x402, one for MPP
+// Check counts as docs/CHECKS.md lists them: ten for x402, one for MPP
// 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: "7 checks · exact scheme on Stellar", logo: },
+ { key: "x402", href: "https://x402.org", name: "x402", role: "checks", meta: "10 checks · exact scheme on Stellar and Base", 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/frontend/lib/docs-pages.ts b/frontend/lib/docs-pages.ts
index 6f0facc..e02b188 100644
--- a/frontend/lib/docs-pages.ts
+++ b/frontend/lib/docs-pages.ts
@@ -139,7 +139,7 @@ outranks a missing one.
npx @wasit-dev/cli checks
\`\`\`
-Lists all thirteen checks with the subcommand that runs each, and flags
+Lists all sixteen checks with the subcommand that runs each, and flags
the ones that are negative, destructive, or spend funds. It contacts
nothing.
@@ -393,7 +393,7 @@ where it went.
wasit checks
\`\`\`
-Contacts nothing and needs no keys. If this prints thirteen checks, the
+Contacts nothing and needs no keys. If this prints sixteen checks, the
install is sound and the problem in any later failure is configuration or
the target, not Wasit.
diff --git a/package-lock.json b/package-lock.json
index e70d5ba..7abde92 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4168,6 +4168,7 @@
"@types/node": "^26.1.1",
"@x402/core": "^2.28.0",
"@x402/express": "^2.28.0",
+ "@x402/extensions": "^2.28.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"tsx": "^4.23.1"
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 000b7a6..068dcba 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -22,7 +22,7 @@ wasit test --target --read-only
Requires Node.js `>=22`.
Both lines carry `--read-only` deliberately: without it the payment checks
-`X402-06`/`07` run as soon as a `STELLAR_PRIVATE_KEY` is available, and the CLI
+`X402-06`–`10` run as soon as a `STELLAR_PRIVATE_KEY` is available, and the CLI
reads `.env` from the directory you run it in — so on a machine already set up
for a later suite, the first command in this README would settle a real testnet
payment.
@@ -35,7 +35,7 @@ The default posture costs nothing — no keys required:
wasit test --target https://your-service.example.com/paid --read-only
```
-That runs the read-only x402 checks (`X402-01`–`05`): whether the service issues a well-formed 402 challenge. Drop `--read-only` and set `STELLAR_PRIVATE_KEY` to also exercise the payment flow — `X402-06`/`07` settle a real testnet payment.
+That runs the read-only x402 checks (`X402-01`–`05`): whether the service issues a well-formed 402 challenge. Drop `--read-only` and set `STELLAR_PRIVATE_KEY` to also exercise the payment flow — `X402-06` settles a real testnet payment, and `X402-07`–`10` send payments the service must refuse.
## Commands
diff --git a/packages/cli/src/dashboard/RunView.tsx b/packages/cli/src/dashboard/RunView.tsx
index dcd366c..865e83f 100644
--- a/packages/cli/src/dashboard/RunView.tsx
+++ b/packages/cli/src/dashboard/RunView.tsx
@@ -8,6 +8,7 @@ import {
toStructuredRun,
type CheckResult,
type ProtocolId,
+ X402_PAYMENT_CHECK_IDS,
} from "@wasit-dev/core";
import type { DashboardAction } from "./App.js";
import { runAction } from "./runners.js";
@@ -63,10 +64,11 @@ function colorFor(status: EntryStatus): string {
}
}
-// X402-06/07 need a real settled payment and are never touched by the
-// read-only x402 action — listing them as "pending" would leave two rows
-// spinning forever after the run has already finished.
-const READ_ONLY_EXCLUDED_IDS = new Set(["X402-06", "X402-07"]);
+// The payment checks need a payer key and are never touched by the
+// read-only x402 action — listing them as "pending" would leave their rows
+// spinning forever after the run has already finished. Core owns the list,
+// so a new payment check cannot be missed here.
+const READ_ONLY_EXCLUDED_IDS = new Set(X402_PAYMENT_CHECK_IDS);
export function RunView({ protocol, action, target, onBack }: RunViewProps) {
const { exit } = useApp();
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index c84c9ca..70a59b0 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -226,7 +226,7 @@ program
"Extra request header the endpoint needs before it will issue a challenge. Repeatable.",
collectHeader,
)
- .option("--read-only", "Skip payment checks (X402-06/07)", false)
+ .option("--read-only", "Skip the payment checks (X402-06..10)", false)
.option("--json", "Print results as JSON instead of formatted text", false)
.addHelpText(
"after",
@@ -236,10 +236,11 @@ Examples:
$ wasit test --target https://api.example.com/paid-endpoint --payer-key S...
$ wasit test --target https://api.example.com/paid-endpoint --read-only --json
-X402-01..05 (challenge/header checks) always run and cost nothing. X402-06/07
+X402-01..05 (challenge/header checks) always run and cost nothing. X402-06..10
(real payment checks) run only when a payer key is available and --read-only
-is not set: X402-06 settles a payment, X402-07 attempts one with a corrupted
-signature. See docs/CHECKS.md for what each check ID verifies.`,
+is not set: X402-06 settles a payment; X402-07..10 send payments the target
+must refuse (a corrupted signature, a replay, an underpayment, an expired
+authorization). See docs/CHECKS.md for what each check ID verifies.`,
)
.action(async (opts) => {
const jsonMode = opts.json === true;
@@ -265,7 +266,7 @@ signature. See docs/CHECKS.md for what each check ID verifies.`,
note(
jsonMode,
`When the target offers a ${opts.network} option, X402-06 settles a real ` +
- "payment and X402-07 attempts one. Testnet funds will move.\n",
+ "payment and X402-07..10 send payments it must refuse. Testnet funds will move.\n",
);
results.push(
...(await runX402PaymentChecks({
diff --git a/packages/core/README.md b/packages/core/README.md
index 03783bf..464159d 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -51,7 +51,7 @@ Every check function returns `Promise` and never rejects for a ta
| Function | Checks | Cost |
|---|---|---|
| `runX402ReadChecks({ target, method?, body?, headers? })` | `X402-01`–`05` | Free |
-| `runX402PaymentChecks({ target, network, payerSecretKey, rpcUrl?, method?, body?, headers? })` | `X402-06`, `07` | **Settles a real payment every call** |
+| `runX402PaymentChecks({ target, network, payerSecretKey, rpcUrl?, method?, body?, headers? })` | `X402-06`–`10` | **Settles a real payment every call** |
### MPP — charge mode
diff --git a/packages/core/package.json b/packages/core/package.json
index 5ceddc7..3514575 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -52,6 +52,7 @@
"@types/node": "^26.1.1",
"@x402/core": "^2.28.0",
"@x402/express": "^2.28.0",
+ "@x402/extensions": "^2.28.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"tsx": "^4.23.1"
diff --git a/packages/core/src/catalogue.ts b/packages/core/src/catalogue.ts
index 5668d1c..f7af46c 100644
--- a/packages/core/src/catalogue.ts
+++ b/packages/core/src/catalogue.ts
@@ -102,6 +102,34 @@ export const CHECK_CATALOGUE: readonly CheckCatalogueEntry[] = [
costsFunds: true,
fix: "Verify every payment before serving: pass it to the facilitator's verify step and refuse it when verification fails. Never serve on a payload that was only decoded.",
},
+ {
+ id: "X402-08",
+ name: "Payment Replay Rejected",
+ protocol: "x402",
+ specRef: "x402 v2 spec §10.1 (replay protection); exact scheme, one-time use",
+ summary: "A payment that was already accepted must not be accepted again.",
+ negative: true,
+ fix: "Treat every payment as single-use: verify it with the facilitator before serving, which refuses an authorization whose nonce is already spent, and never serve the same payload twice.",
+ },
+ {
+ id: "X402-09",
+ name: "Underpayment Rejected",
+ protocol: "x402",
+ specRef: "exact scheme verification: amount (Stellar: exactly requirements.amount; EVM: step 3)",
+ summary: "A validly signed payment for less than the advertised amount must be rejected.",
+ negative: true,
+ costsFunds: true,
+ fix: "Hold the signed payment to the advertised amount before serving. The facilitator's verify does; a server that skips it, or checks only the signature, sells for less than its price.",
+ },
+ {
+ id: "X402-10",
+ name: "Expired Authorization Rejected",
+ protocol: "x402",
+ specRef: "x402 v2 spec §10.1 (time constraints); exact scheme verification: validity window",
+ summary: "A payment whose authorization has expired must be rejected.",
+ negative: true,
+ fix: "Verify each payment with the facilitator before serving: it refuses an authorization past its window (validBefore on EVM, the auth entry's expiration ledger on Stellar).",
+ },
{
id: "MPP-01",
name: "Charge Settlement On-Chain",
diff --git a/packages/core/src/x402/chains/evm.ts b/packages/core/src/x402/chains/evm.ts
index f36491d..7b92492 100644
--- a/packages/core/src/x402/chains/evm.ts
+++ b/packages/core/src/x402/chains/evm.ts
@@ -2,7 +2,10 @@
* The `exact` scheme on EVM, with the EIP-3009 transfer method (USDC's
* `transferWithAuthorization`): the payer signs an authorization off-chain,
* the facilitator submits it and pays the gas (`scheme_exact_evm.md`). So a
- * payer needs the token and nothing else.
+ * payer needs the token and nothing else. A target that advertises the
+ * Permit2 method instead is paid through Permit2; with the
+ * `eip2612GasSponsoring` extension its one-time approval is a signed permit
+ * the facilitator submits, so that path needs no gas either.
*
* 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
@@ -10,6 +13,7 @@
* the official SDK ships its USDC and the public facilitator settles there.
*/
+import { toClientEvmSigner } from "@x402/evm";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import {
TransactionReceiptNotFoundError,
@@ -295,6 +299,8 @@ export const evmChain: PaymentChain = {
networks: Object.keys(NETWORKS),
payerKeyEnv: "EVM_PRIVATE_KEY",
referenceKind: "an EVM transaction hash",
+ // validBefore is now + 1 second; Base Sepolia blocks come every 2 seconds.
+ expiryWaitMs: 5_000,
resolveRpcUrl(network, override) {
const chain = NETWORKS[network];
if (chain === undefined) {
@@ -307,8 +313,14 @@ export const evmChain: PaymentChain = {
}
return fallback;
},
- registerPayer(client, _network, payerKey) {
- client.register("eip155:*", new ExactEvmScheme(payerAccount(payerKey)));
+ 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(
+ payerAccount(payerKey),
+ createPublicClient({ transport: http(rpcUrl) }),
+ );
+ client.register("eip155:*", new ExactEvmScheme(signer, { rpcUrl }));
},
payerAddress: (payerKey) => payerAccount(payerKey).address,
isSettlementReference: (reference) => TRANSACTION_HASH.test(reference),
diff --git a/packages/core/src/x402/chains/stellar.ts b/packages/core/src/x402/chains/stellar.ts
index 20455c1..b694d6f 100644
--- a/packages/core/src/x402/chains/stellar.ts
+++ b/packages/core/src/x402/chains/stellar.ts
@@ -72,8 +72,11 @@ export const stellarChain: PaymentChain = {
networks: ["stellar:testnet", "stellar:pubnet"],
payerKeyEnv: "STELLAR_PRIVATE_KEY",
referenceKind: "a settled Stellar transaction hash",
+ // A one-second lifetime is one ledger (ceil(1 / 5)); ledgers close about
+ // every 5 to 6 seconds, so 20 seconds is three or more ledgers past it.
+ expiryWaitMs: 20_000,
resolveRpcUrl: (network, override) => resolveRpcUrl(network, override),
- registerPayer(client, network, payerKey) {
+ registerPayer(client, network, payerKey, _rpcUrl) {
const signer = createEd25519Signer(payerKey, network as `${string}:${string}`);
client.register("stellar:*", new ExactStellarClientScheme(signer));
},
diff --git a/packages/core/src/x402/chains/types.ts b/packages/core/src/x402/chains/types.ts
index 747753a..4ab668b 100644
--- a/packages/core/src/x402/chains/types.ts
+++ b/packages/core/src/x402/chains/types.ts
@@ -2,8 +2,8 @@
* What the x402 payment checks need that differs from chain to chain.
*
* X402-01 through X402-05 need none of it: they read the challenge only, and
- * apply to an x402 service on any chain. X402-06 and X402-07 build a real
- * payment, so they need a signer, a way to look the settlement up, and a way
+ * apply to an x402 service on any chain. X402-06 to X402-10 build real
+ * payments, so they need a signer, a way to look the settlement up, and a way
* to forge only the payer's signature. One adapter per chain family supplies
* those, and the checks themselves stay the same everywhere.
*/
@@ -21,13 +21,22 @@ export interface PaymentChain {
readonly payerKeyEnv: string;
/** What a settlement reference is on this chain, for a report ("a ... hash"). */
readonly referenceKind: string;
+ /**
+ * How long X402-10 holds a payment signed with a one-second lifetime before
+ * sending it, so it has certainly expired on this chain.
+ */
+ readonly expiryWaitMs: number;
/**
* The RPC endpoint X402-06 verifies the settlement on. Throws
* `ConfigurationError` when there is none, before any payment is made.
*/
resolveRpcUrl(network: string, override?: string): string;
- /** Registers this chain's `exact` client scheme, signing with `payerKey`. */
- registerPayer(client: x402Client, network: string, payerKey: string): void;
+ /**
+ * Registers this chain's `exact` client scheme, signing with `payerKey`.
+ * `rpcUrl` is for client schemes that read the chain while building a
+ * payment (EVM Permit2 checks the token allowance); others ignore it.
+ */
+ registerPayer(client: x402Client, network: string, payerKey: string, rpcUrl: string): void;
/** The payer's address, which the settlement must come from. */
payerAddress(payerKey: string): string;
/** Whether `reference` has the shape of a settlement on this chain. */
diff --git a/packages/core/src/x402/simulator.ts b/packages/core/src/x402/simulator.ts
index 364e630..98619b6 100644
--- a/packages/core/src/x402/simulator.ts
+++ b/packages/core/src/x402/simulator.ts
@@ -361,6 +361,7 @@ export async function runX402ReadChecks(
}
import { x402Client, x402HTTPClient } from "@x402/fetch";
+import type { PaymentRequirements } from "@x402/core/types";
import { paymentChainFor, paymentNetworks, type PaymentChain } from "./chains/index.js";
import { stellarChain } from "./chains/stellar.js";
@@ -377,6 +378,14 @@ export interface X402PaymentCheckOptions extends RequestShape {
readonly onResult?: (result: CheckResult) => void;
}
+/** One check has nothing to test against this target; it is skipped with the reason. */
+class CheckNotApplicable extends Error {
+ public constructor(reason: string) {
+ super(reason);
+ this.name = "CheckNotApplicable";
+ }
+}
+
/** The payment flow was never started, so neither payment check has a verdict. */
class PaymentNotAttemptedError extends Error {
public constructor(reason: string) {
@@ -402,9 +411,14 @@ function chainFor(network: string): PaymentChain {
return chain;
}
-function buildX402Client(chain: PaymentChain, network: string, payerSecretKey: string) {
+function buildX402Client(
+ chain: PaymentChain,
+ network: string,
+ payerSecretKey: string,
+ rpcUrl: string,
+) {
const client = new x402Client();
- chain.registerPayer(client, network, payerSecretKey);
+ chain.registerPayer(client, network, payerSecretKey, rpcUrl);
client
// A challenge may offer several networks, including both Stellar ones.
// The client's default pick is the first it supports, which would sign
@@ -422,17 +436,34 @@ function describeOffered(accepts: ReadonlyArray<{ readonly network: string }>):
return networks.length === 0 ? "none" : networks.join(", ");
}
+/**
+ * What a negative check signs differently from the honest payment. The
+ * payload still claims, in `accepted`, the terms the target advertised, so the
+ * only thing wrong with it is what was signed.
+ */
+interface AlteredSigning {
+ readonly amount?: string;
+ readonly maxTimeoutSeconds?: number;
+}
+
/**
* Reads the challenge and produces a signed payment payload for it.
*
- * Shared by both payment checks so each one starts from a challenge the
- * target issued just now, rather than reusing a stale one.
+ * Shared by every payment check so each one starts from a challenge the
+ * target issued just now, rather than reusing a stale one. With `altered`,
+ * the payment is signed for those terms instead, and its `accepted` is set
+ * back to what the target advertised.
*/
-async function preparePayment(options: X402PaymentCheckOptions) {
+async function preparePayment(
+ options: X402PaymentCheckOptions,
+ alter?: (selected: PaymentRequirements) => AlteredSigning,
+) {
+ const chain = chainFor(options.network);
const { client, httpClient } = buildX402Client(
- chainFor(options.network),
+ chain,
options.network,
options.payerSecretKey,
+ chain.resolveRpcUrl(options.network, options.rpcUrl),
);
const challenge = await fetchTarget(options.target, buildInit(options));
@@ -444,7 +475,7 @@ async function preparePayment(options: X402PaymentCheckOptions) {
);
} catch (error) {
// The read checks already report what is wrong with the challenge. Failing
- // X402-06/07 here too would count one broken challenge three times, and
+ // the payment checks here too would count one broken challenge many times, and
// would make X402-07 read as a signature that was not rejected when no
// payment was ever sent. docs/CHECKS.md: an unreadable challenge produces
// no verdict.
@@ -481,8 +512,23 @@ async function preparePayment(options: X402PaymentCheckOptions) {
);
}
- const paymentPayload = await client.createPaymentPayload(paymentRequired);
- return { httpClient, paymentPayload };
+ const selected = onNetwork.find((requirement) => requirement.scheme === "exact")!;
+ // The payment-identifier extension lets a server answer a repeated payload
+ // with its cached response (specs/extensions/payment_identifier.md).
+ const idempotent = paymentRequired.extensions?.["payment-identifier"] !== undefined;
+
+ if (alter === undefined) {
+ const paymentPayload = await client.createPaymentPayload(paymentRequired);
+ return { httpClient, paymentPayload, selected, idempotent };
+ }
+
+ const altered = alter(selected);
+ const signed = await client.createPaymentPayload({
+ ...paymentRequired,
+ accepts: [{ ...selected, ...altered }],
+ });
+ const paymentPayload = { ...signed, accepted: selected };
+ return { httpClient, paymentPayload, selected, idempotent };
}
/**
@@ -544,27 +590,54 @@ export function readSettlementReference(
* held to what the target advertised, as MPP-01 does: a target that serves
* the resource without settling, or settles something else, fails here.
*/
-async function checkSignatureAccepted(
- options: X402PaymentCheckOptions,
-): Promise {
+/** What X402-06 hands the negative checks: whether the honest payment was taken, and how to replay it. */
+interface HonestPayment {
+ readonly result: CheckResult;
+ /** True when the target answered the honest payment with a 2xx. */
+ readonly accepted: boolean;
+ /** The exact headers the honest payment was sent with. */
+ readonly headers: Record;
+ /** True when the challenge advertised the payment-identifier extension. */
+ readonly idempotent: boolean;
+}
+
+async function checkSignatureAccepted(options: X402PaymentCheckOptions): Promise {
const id = "X402-06";
const name = "Signature Resubmit Accepted";
const fail = (detail: string, hint: string): CheckResult => ({ id, name, pass: false, detail, hint });
- const { httpClient, paymentPayload } = await preparePayment(options);
+ const { httpClient, paymentPayload, idempotent } = await preparePayment(options);
const paymentHeaders = httpClient.encodePaymentSignatureHeader(paymentPayload);
const paid = await fetchTarget(
options.target,
withPaymentHeaders(options, paymentHeaders),
);
+ const honest = (result: CheckResult, accepted: boolean): HonestPayment => ({
+ result,
+ accepted,
+ headers: paymentHeaders,
+ idempotent,
+ });
if (paid.status < 200 || paid.status >= 300) {
- return fail(
- `Expected 2xx after a valid payment, got ${paid.status}.`,
+ const reason = await refusalReason(paid);
+ // Permit2 needs the payer to have approved the Permit2 contract once. A
+ // target that offers no gas-sponsored approval leaves that to the payer,
+ // so this refusal is about Wasit's payer, not the target: no verdict.
+ if (reason === "permit2_allowance_required") {
+ throw new CheckSetupError(
+ "The target pays through Permit2 without a gas-sponsored approval, and this " +
+ "run's payer has not approved the Permit2 contract for the token. Approve it " +
+ "once (an on-chain transaction that needs gas), then re-run.",
+ );
+ }
+ 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.",
- );
+ ), false);
}
const chain = chainFor(options.network);
@@ -573,7 +646,7 @@ async function checkSignatureAccepted(
() => httpClient.getPaymentSettleResponse((header) => paid.headers.get(header)),
chain,
);
- if ("failure" in read) return fail(read.failure, read.hint);
+ if ("failure" in read) return honest(fail(read.failure, read.hint), true);
const reference = read.reference;
const accepted = paymentPayload.accepted;
@@ -595,19 +668,22 @@ async function checkSignatureAccepted(
payer: chain.payerAddress(options.payerSecretKey),
});
- return verdict.pass
- ? {
- id,
- name,
- pass: true,
- detail: `Valid payment accepted (HTTP ${paid.status}) and ${verdict.detail}.`,
- }
- : fail(
- verdict.detail,
- "PAYMENT-RESPONSE must name the transaction that settled this payment, " +
- "and that transaction must move exactly `amount` of `asset` from the " +
- "payer to `payTo`, in a single transfer.",
- );
+ return honest(
+ verdict.pass
+ ? {
+ id,
+ name,
+ pass: true,
+ detail: `Valid payment accepted (HTTP ${paid.status}) and ${verdict.detail}.`,
+ }
+ : fail(
+ verdict.detail,
+ "PAYMENT-RESPONSE must name the transaction that settled this payment, " +
+ "and that transaction must move exactly `amount` of `asset` from the " +
+ "payer to `payTo`, in a single transfer.",
+ ),
+ true,
+ );
}
/**
@@ -639,14 +715,14 @@ async function checkInvalidSignatureRejected(
withPaymentHeaders(options, paymentHeaders),
);
- return signatureRejectionVerdict(response.status);
+ return signatureRejectionVerdict(response.status, await refusalReason(response));
}
/**
* X402-07's verdict from the status a corrupted payment drew. Split out so
* the verdict can be tested without building a payment.
*/
-export function signatureRejectionVerdict(status: number): CheckResult {
+export function signatureRejectionVerdict(status: number, reason?: string): CheckResult {
const accepted = status >= 200 && status < 300;
return {
id: "X402-07",
@@ -656,7 +732,7 @@ export function signatureRejectionVerdict(status: number): CheckResult {
? `A payment with a corrupted authorization signature was accepted with ` +
`HTTP ${status} — security-relevant failure.`
: `Payment with a corrupted authorization signature correctly rejected ` +
- `(HTTP ${status}).`,
+ `(${statusWithReason(status, reason)}).`,
...(accepted
? {
hint:
@@ -668,10 +744,226 @@ export function signatureRejectionVerdict(status: number): CheckResult {
};
}
+
+/** Any 2xx is the target serving the resource. */
+function served(status: number): boolean {
+ return status >= 200 && status < 300;
+}
+
+/**
+ * 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.
+ */
+async function refusalReason(response: Response): Promise {
+ const header = response.headers.get("PAYMENT-REQUIRED");
+ const fromJson = (text: string): string | undefined => {
+ try {
+ const parsed = JSON.parse(text) as { error?: unknown };
+ return typeof parsed.error === "string" && parsed.error.length > 0 ? parsed.error : undefined;
+ } catch {
+ return undefined;
+ }
+ };
+ if (header !== null) return fromJson(Buffer.from(header, "base64").toString("utf-8"));
+ return fromJson(await readText(response));
+}
+
+function statusWithReason(status: number, reason: string | undefined): string {
+ return reason === undefined ? `HTTP ${status}` : `HTTP ${status}: ${reason}`;
+}
+
+/**
+ * X402-08 (negative): a payment already accepted must not be accepted again.
+ *
+ * The headers X402-06's honest payment was sent with are sent once more,
+ * byte for byte. Each authorization is single-use: an EIP-3009 nonce, or a
+ * Soroban auth entry's nonce, is spent once the payment settles, so the
+ * facilitator's verify refuses it (spec §10.1). A target that serves it again
+ * has sold the resource twice for one payment. Nothing can settle twice.
+ */
+async function checkReplayRejected(
+ options: X402PaymentCheckOptions,
+ honest: HonestPayment,
+): Promise {
+ if (honest.idempotent) {
+ return skipped(
+ "X402-08",
+ "Payment Replay Rejected",
+ "the challenge advertises the payment-identifier extension, under which a " +
+ "server may answer a repeated payment with its cached response.",
+ );
+ }
+ const response = await fetchTarget(options.target, withPaymentHeaders(options, honest.headers));
+ return replayVerdict(response.status, await refusalReason(response));
+}
+
+/** X402-08's verdict from the status the replayed payment drew. */
+export function replayVerdict(status: number, reason?: string): CheckResult {
+ const accepted = served(status);
+ return {
+ id: "X402-08",
+ name: "Payment Replay Rejected",
+ pass: !accepted,
+ detail: accepted
+ ? `The payment X402-06 had already been accepted for was accepted again ` +
+ `(HTTP ${status}): one payment bought the resource twice.`
+ : `The same payment, sent again, was refused (${statusWithReason(status, reason)}).`,
+ ...(accepted
+ ? {
+ hint:
+ "Treat every payment as single-use: verify it with the facilitator " +
+ "before serving, which refuses an authorization whose nonce is already " +
+ "spent, and never serve the same payload twice.",
+ }
+ : {}),
+ };
+}
+
/**
- * Runs X402-06 and X402-07 against a target.
+ * X402-09 (negative): a validly signed payment for less than the price must
+ * be rejected.
*
- * Both settle real payments when the target accepts them; see docs/CHECKS.md.
+ * The payment is signed for half the advertised amount, while its `accepted`
+ * still claims the advertised terms, so the signature is good and only the
+ * amount is wrong. The facilitator must hold the signed amount to the
+ * requirement (Stellar: exactly; EVM: verification step 3). If the target
+ * accepts, the lower amount may settle.
+ */
+async function checkUnderpaymentRejected(options: X402PaymentCheckOptions): Promise {
+ let advertised = 0n;
+ let offered = 0n;
+ let prepared;
+ try {
+ prepared = await preparePayment(options, (selected) => {
+ try {
+ advertised = BigInt(selected.amount);
+ } catch {
+ throw new MalformedResponseError(
+ `The payment requirements carry a non-numeric amount (${JSON.stringify(selected.amount)}).`,
+ );
+ }
+ if (advertised < 2n) {
+ throw new CheckNotApplicable(
+ `the advertised price is ${advertised} base unit${advertised === 1n ? "" : "s"}, so ` +
+ `there is no smaller payment to offer.`,
+ );
+ }
+ offered = advertised / 2n;
+ return { amount: offered.toString() };
+ });
+ } catch (error) {
+ if (error instanceof CheckNotApplicable) {
+ return skipped("X402-09", "Underpayment Rejected", error.message);
+ }
+ throw error;
+ }
+ const { httpClient, paymentPayload } = prepared;
+ const response = await fetchTarget(
+ options.target,
+ withPaymentHeaders(options, httpClient.encodePaymentSignatureHeader(paymentPayload)),
+ );
+ return underpaymentVerdict(response.status, offered, advertised, await refusalReason(response));
+}
+
+/** X402-09's verdict from the status the underpaid payment drew. */
+export function underpaymentVerdict(
+ status: number,
+ offered: bigint,
+ advertised: bigint,
+ reason?: string,
+): CheckResult {
+ const accepted = served(status);
+ return {
+ id: "X402-09",
+ name: "Underpayment Rejected",
+ pass: !accepted,
+ detail: accepted
+ ? `A validly signed payment for ${offered} of the advertised ${advertised} base ` +
+ `units was accepted (HTTP ${status}) — security-relevant failure: the target ` +
+ `served for less than its price.`
+ : `A validly signed payment for ${offered} of the advertised ${advertised} base ` +
+ `units was refused (${statusWithReason(status, reason)}).`,
+ ...(accepted
+ ? {
+ hint:
+ "Hold the signed payment to the advertised amount before serving. The " +
+ "facilitator's verify does; a server that skips it, or checks only the " +
+ "signature, sells for less than its price.",
+ }
+ : {}),
+ };
+}
+
+/**
+ * X402-10 (negative): a payment whose authorization has expired must be
+ * rejected.
+ *
+ * The payment is signed with a one-second lifetime (EVM `validBefore`, or the
+ * Stellar auth entry's expiration ledger, both derived from
+ * `maxTimeoutSeconds`), then held until that has passed, and sent claiming the
+ * advertised terms. An expired authorization cannot settle, so a target that
+ * serves it serves for nothing.
+ */
+async function checkExpiredRejected(options: X402PaymentCheckOptions): Promise {
+ const { httpClient, paymentPayload } = await preparePayment(options, () => ({ maxTimeoutSeconds: 1 }));
+ await new Promise((done) => setTimeout(done, chainFor(options.network).expiryWaitMs));
+ const response = await fetchTarget(
+ options.target,
+ withPaymentHeaders(options, httpClient.encodePaymentSignatureHeader(paymentPayload)),
+ );
+ return expiredVerdict(response.status, await refusalReason(response));
+}
+
+/** X402-10's verdict from the status the expired payment drew. */
+export function expiredVerdict(status: number, reason?: string): CheckResult {
+ const accepted = served(status);
+ return {
+ id: "X402-10",
+ name: "Expired Authorization Rejected",
+ pass: !accepted,
+ detail: accepted
+ ? `A payment whose authorization had already expired was accepted (HTTP ` +
+ `${status}). It cannot settle, so the target served for nothing.`
+ : `A payment whose authorization had expired was refused (${statusWithReason(status, reason)}).`,
+ ...(accepted
+ ? {
+ hint:
+ "Verify each payment with the facilitator before serving: it refuses an " +
+ "authorization past its window (validBefore on EVM, the auth entry's " +
+ "expiration ledger on Stellar).",
+ }
+ : {}),
+ };
+}
+
+/** The negative checks, in catalogue order, each with how it runs. */
+const NEGATIVE_CHECKS: ReadonlyArray<
+ readonly [string, string, (options: X402PaymentCheckOptions, honest: HonestPayment) => Promise]
+> = [
+ ["X402-07", "Invalid Signature Rejected", (options) => checkInvalidSignatureRejected(options)],
+ ["X402-08", "Payment Replay Rejected", checkReplayRejected],
+ ["X402-09", "Underpayment Rejected", (options) => checkUnderpaymentRejected(options)],
+ ["X402-10", "Expired Authorization Rejected", (options) => checkExpiredRejected(options)],
+];
+
+/**
+ * Every check `runX402PaymentChecks` reports, in order. Front ends that list
+ * checks before a run use it, so a read-only run never shows these as pending.
+ */
+export const X402_PAYMENT_CHECK_IDS: readonly string[] = [
+ "X402-06",
+ ...NEGATIVE_CHECKS.map(([id]) => id),
+];
+
+/**
+ * Runs X402-06 through X402-10 against a target.
+ *
+ * X402-06 settles a real payment. The negative checks send payments the
+ * target must refuse; a target that accepts the underpaid one may settle it.
+ * See docs/CHECKS.md.
*/
export async function runX402PaymentChecks(
options: X402PaymentCheckOptions,
@@ -704,46 +996,50 @@ export async function runX402PaymentChecks(
}
const results: CheckResult[] = [];
+ const emit = (result: CheckResult): void => {
+ results.push(result);
+ options.onResult?.(result);
+ };
+ const skipNegatives = (reason: string): CheckResult[] => {
+ for (const [id, name] of NEGATIVE_CHECKS) emit(skipped(id, name, reason));
+ return results;
+ };
- let accepted: CheckResult;
+ let honest: HonestPayment;
try {
- accepted = await checkSignatureAccepted(options);
+ honest = await checkSignatureAccepted(options);
} catch (error) {
if (error instanceof PaymentNotAttemptedError) {
- const notAttempted = [
- skipped("X402-06", "Signature Resubmit Accepted", error.message),
- skipped("X402-07", "Invalid Signature Rejected", error.message),
- ];
- for (const result of notAttempted) options.onResult?.(result);
- return notAttempted;
+ emit(skipped("X402-06", "Signature Resubmit Accepted", error.message));
+ return skipNegatives(error.message);
}
- accepted = errored("X402-06", "Signature Resubmit Accepted", error);
+ const failed = errored("X402-06", "Signature Resubmit Accepted", error);
+ emit(failed);
+ // If the payment flow could not be exercised at all, no altered variant of
+ // it can be either, and none may be reported as a passing rejection.
+ return skipNegatives(
+ `the payment flow could not be exercised (${failed.error?.kind ?? "harness"}), ` +
+ `so no altered payment can be tested against it.`,
+ );
}
- results.push(accepted);
- options.onResult?.(accepted);
-
- // If the payment flow could not be exercised at all, a corrupted variant of
- // it cannot be either — and must not be reported as a passing rejection.
- if (accepted.error !== undefined) {
- const notExercised = skipped(
- "X402-07",
- "Invalid Signature Rejected",
- `the payment flow could not be exercised (${accepted.error.kind}), so ` +
- `a corrupted signature cannot be tested against it.`,
+ emit(honest.result);
+
+ // A negative check passes when the target refuses. A target that refused
+ // the honest payment too proves nothing by refusing a bad one, so without an
+ // accepted baseline none of them has a verdict.
+ if (!honest.accepted) {
+ return skipNegatives(
+ "the target refused X402-06's valid payment, so refusing an altered one " +
+ "says nothing about what it checks.",
);
- results.push(notExercised);
- options.onResult?.(notExercised);
- return results;
}
- let rejected: CheckResult;
- try {
- rejected = await checkInvalidSignatureRejected(options);
- } catch (error) {
- rejected = errored("X402-07", "Invalid Signature Rejected", error);
+ for (const [id, name, run] of NEGATIVE_CHECKS) {
+ try {
+ emit(await run(options, honest));
+ } catch (error) {
+ emit(errored(id, name, error));
+ }
}
- results.push(rejected);
- options.onResult?.(rejected);
-
return results;
}
diff --git a/packages/core/test/fixtures/x402-evm-permit2-server.ts b/packages/core/test/fixtures/x402-evm-permit2-server.ts
new file mode 100644
index 0000000..51fa1ac
--- /dev/null
+++ b/packages/core/test/fixtures/x402-evm-permit2-server.ts
@@ -0,0 +1,46 @@
+import "dotenv/config";
+import express from "express";
+import { paymentMiddlewareFromConfig } from "@x402/express";
+import { HTTPFacilitatorClient } from "@x402/core/server";
+import { ExactEvmScheme } from "@x402/evm/exact/server";
+import { declareEip2612GasSponsoringExtension } from "@x402/extensions";
+
+// x402-evm-server.ts with the Permit2 transfer method instead of EIP-3009.
+// Permit2 needs a one-time approval; the eip2612GasSponsoring extension lets
+// the client sign it as an EIP-2612 permit that the facilitator submits, so
+// the payer still needs no ETH. The public facilitator supports the
+// extension (its /supported list, 2026-10-05).
+const PORT = 3006;
+const ROUTE_PATH = "/protected";
+const PRICE = "$0.01";
+const NETWORK = "eip155:84532";
+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");
+
+const app = express();
+
+app.use(
+ paymentMiddlewareFromConfig(
+ {
+ [`GET ${ROUTE_PATH}`]: {
+ accepts: {
+ scheme: "exact",
+ price: PRICE,
+ network: NETWORK,
+ payTo: PAY_TO,
+ extra: { assetTransferMethod: "permit2" },
+ },
+ extensions: { ...declareEip2612GasSponsoringExtension() },
+ },
+ },
+ new HTTPFacilitatorClient({ url: FACILITATOR_URL }),
+ [{ network: NETWORK, server: new ExactEvmScheme() }],
+ ),
+);
+
+app.get(ROUTE_PATH, (_, res) => res.json({ secret: "wasit test resource" }));
+
+app.listen(PORT, () =>
+ console.log(`Test x402 server (Base Sepolia, Permit2) on http://localhost:${PORT}${ROUTE_PATH}`),
+);
diff --git a/packages/core/test/unit/payment-negatives.test.ts b/packages/core/test/unit/payment-negatives.test.ts
new file mode 100644
index 0000000..5e8b055
--- /dev/null
+++ b/packages/core/test/unit/payment-negatives.test.ts
@@ -0,0 +1,228 @@
+/**
+ * X402-08 Payment Replay Rejected, X402-09 Underpayment Rejected, X402-10
+ * Expired Authorization Rejected, and the rule that every negative check
+ * needs an accepted baseline.
+ *
+ * Offline: the payer is a throwaway EVM key, and EIP-3009 signing needs no
+ * RPC, so a local stand-in on Base Sepolia's network id can take or refuse
+ * every payment while recording exactly what was signed. The X402-10 runs
+ * wait out a one-second lifetime, so each takes about five seconds.
+ */
+
+import assert from "node:assert/strict";
+import http from "node:http";
+import { describe, it } from "node:test";
+
+import { generatePrivateKey } from "viem/accounts";
+
+import type { CheckResult } from "../../src/check.js";
+import {
+ expiredVerdict,
+ replayVerdict,
+ runX402PaymentChecks,
+ underpaymentVerdict,
+} from "../../src/x402/simulator.js";
+
+const NETWORK = "eip155:84532";
+
+interface Received {
+ readonly raw: string;
+ readonly payload: {
+ accepted: { amount: string; maxTimeoutSeconds: number };
+ payload: { authorization: { value: string; validBefore: string } };
+ };
+ readonly atSeconds: number;
+}
+
+/**
+ * Answers unpaid requests with a Base Sepolia challenge; answers payments
+ * with 200 and no settlement (`accept`), or with 402 (`refuse`).
+ */
+async function standIn(options: {
+ mode: "accept" | "refuse" | "permit2-allowance";
+ amount?: string;
+ paymentIdentifier?: boolean;
+}): Promise<{ url: string; received: Received[]; close: () => Promise }> {
+ const received: Received[] = [];
+ const challenge = {
+ x402Version: 2,
+ resource: { url: "http://127.0.0.1/paid" },
+ accepts: [
+ {
+ scheme: "exact",
+ network: NETWORK,
+ amount: options.amount ?? "10000",
+ asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
+ payTo: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
+ maxTimeoutSeconds: 60,
+ extra: { name: "USDC", version: "2" },
+ },
+ ],
+ ...(options.paymentIdentifier
+ ? { extensions: { "payment-identifier": { info: { required: false } } } }
+ : {}),
+ };
+ const header = Buffer.from(JSON.stringify(challenge)).toString("base64");
+ const server = http.createServer((request, response) => {
+ const paid = request.headers["payment-signature"];
+ if (typeof paid !== "string") {
+ response.writeHead(402, { "payment-required": header });
+ response.end();
+ return;
+ }
+ received.push({
+ raw: paid,
+ payload: JSON.parse(Buffer.from(paid, "base64").toString("utf-8")),
+ atSeconds: Math.floor(Date.now() / 1000),
+ });
+ if (options.mode === "permit2-allowance") {
+ const refusal = { ...challenge, error: "permit2_allowance_required" };
+ response.writeHead(412, { "payment-required": Buffer.from(JSON.stringify(refusal)).toString("base64") });
+ response.end();
+ return;
+ }
+ response.writeHead(options.mode === "accept" ? 200 : 402);
+ response.end();
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const { port } = server.address() as { port: number };
+ return {
+ url: `http://127.0.0.1:${port}/paid`,
+ received,
+ close: () => new Promise((resolve) => server.close(() => resolve())),
+ };
+}
+
+function byId(results: CheckResult[]): Record {
+ return Object.fromEntries(results.map((result) => [result.id, result]));
+}
+
+describe("the negative checks against a target that takes anything", () => {
+ it("fails X402-07 to X402-10, having sent exactly what each one claims", async () => {
+ const target = await standIn({ mode: "accept" });
+ try {
+ const results = await runX402PaymentChecks({
+ target: target.url,
+ network: NETWORK,
+ payerSecretKey: generatePrivateKey(),
+ });
+ assert.deepEqual(
+ results.map((result) => result.id),
+ ["X402-06", "X402-07", "X402-08", "X402-09", "X402-10"],
+ );
+ const r = byId(results);
+ assert.match(r["X402-06"]!.detail, /no PAYMENT-RESPONSE header/);
+ for (const id of ["X402-07", "X402-08", "X402-09", "X402-10"]) {
+ assert.equal(r[id]?.pass, false, id);
+ assert.equal(r[id]?.skipped, undefined, id);
+ assert.ok(r[id]?.hint, `${id} carries a fix`);
+ }
+
+ // Order of payments: honest, corrupted, replay, underpaid, expired.
+ const [honest, , replay, underpaid, expired] = target.received;
+ assert.equal(target.received.length, 5);
+ assert.equal(replay?.raw, honest?.raw, "the replay is the honest payment, byte for byte");
+ assert.equal(underpaid?.payload.payload.authorization.value, "5000");
+ assert.equal(underpaid?.payload.accepted.amount, "10000", "it still claims the advertised price");
+ assert.equal(expired?.payload.accepted.maxTimeoutSeconds, 60, "it still claims the advertised window");
+ assert.ok(
+ Number(expired?.payload.payload.authorization.validBefore) < expired!.atSeconds,
+ "the authorization had expired when it arrived",
+ );
+ } finally {
+ await target.close();
+ }
+ });
+});
+
+describe("a target that refuses even the valid payment", () => {
+ it("gets no verdict on any negative check, and is sent only the valid payment", async () => {
+ const target = await standIn({ mode: "refuse" });
+ try {
+ const r = byId(
+ await runX402PaymentChecks({ target: target.url, network: NETWORK, payerSecretKey: generatePrivateKey() }),
+ );
+ assert.equal(r["X402-06"]?.pass, false);
+ for (const id of ["X402-07", "X402-08", "X402-09", "X402-10"]) {
+ assert.equal(r[id]?.skipped, true, id);
+ assert.match(r[id]?.skipReason ?? "", /refused X402-06's valid payment/, id);
+ }
+ assert.equal(target.received.length, 1);
+ } finally {
+ await target.close();
+ }
+ });
+});
+
+describe("a Permit2 target that needs an approval this payer never made", () => {
+ // The refusal is about Wasit's payer, not the target: no verdict, and no
+ // negative check runs without an accepted baseline.
+ it("reports X402-06 as a setup error and skips the negative checks", async () => {
+ const target = await standIn({ mode: "permit2-allowance" });
+ try {
+ const r = byId(
+ await runX402PaymentChecks({ target: target.url, network: NETWORK, payerSecretKey: generatePrivateKey() }),
+ );
+ assert.equal(r["X402-06"]?.error?.kind, "setup");
+ assert.match(r["X402-06"]?.detail ?? "", /approved the Permit2 contract/);
+ for (const id of ["X402-07", "X402-08", "X402-09", "X402-10"]) assert.equal(r[id]?.skipped, true, id);
+ } 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 () => {
+ const target = await standIn({ mode: "refuse" });
+ try {
+ const r = byId(
+ await runX402PaymentChecks({ target: target.url, network: NETWORK, payerSecretKey: generatePrivateKey() }),
+ );
+ assert.match(r["X402-06"]?.detail ?? "", /^Expected 2xx after a valid payment, got 402\.$/);
+ } finally {
+ await target.close();
+ }
+ });
+});
+
+describe("when a negative check has nothing to test", () => {
+ it("skips X402-08 under payment-identifier, and X402-09 at a 1-unit price", async () => {
+ const target = await standIn({ mode: "accept", amount: "1", paymentIdentifier: true });
+ try {
+ const r = byId(
+ await runX402PaymentChecks({ target: target.url, network: NETWORK, payerSecretKey: generatePrivateKey() }),
+ );
+ assert.equal(r["X402-08"]?.skipped, true);
+ assert.match(r["X402-08"]?.skipReason ?? "", /payment-identifier/);
+ assert.equal(r["X402-09"]?.skipped, true);
+ assert.match(r["X402-09"]?.skipReason ?? "", /1 base unit/);
+ assert.equal(r["X402-07"]?.pass, false);
+ assert.equal(r["X402-10"]?.pass, false);
+ // honest, corrupted, expired: no replay and no underpayment were sent.
+ assert.equal(target.received.length, 3);
+ } finally {
+ await target.close();
+ }
+ });
+});
+
+describe("the verdicts", () => {
+ it("pass a refusal and fail a 2xx, with a fix only on the failure", () => {
+ for (const verdict of [replayVerdict, expiredVerdict, (s: number) => underpaymentVerdict(s, 5000n, 10000n)]) {
+ const refused = verdict(402);
+ assert.equal(refused.pass, true);
+ assert.equal(refused.hint, undefined);
+ const served = verdict(200);
+ assert.equal(served.pass, false);
+ assert.ok(served.hint);
+ }
+ });
+
+ it("say what was offered against what was advertised", () => {
+ assert.match(underpaymentVerdict(200, 5000n, 10000n).detail, /5000 of the advertised 10000 base units was accepted/);
+ assert.match(replayVerdict(200).detail, /one payment bought the resource twice/);
+ assert.match(expiredVerdict(200).detail, /cannot settle/);
+ });
+});
diff --git a/packages/core/test/unit/x402-networks.test.ts b/packages/core/test/unit/x402-networks.test.ts
index b423460..e3ed65a 100644
--- a/packages/core/test/unit/x402-networks.test.ts
+++ b/packages/core/test/unit/x402-networks.test.ts
@@ -265,7 +265,7 @@ describe("payment checks on a challenge Wasit cannot pay", () => {
const results = byId(
await runX402PaymentChecks({ target: target.url, network: "stellar:testnet", payerSecretKey }),
);
- for (const id of ["X402-06", "X402-07"]) {
+ for (const id of ["X402-06", "X402-07", "X402-08", "X402-09", "X402-10"]) {
assert.equal(results[id]?.skipped, true, id);
assert.match(results[id]?.skipReason ?? "", reason, id);
}
diff --git a/packages/core/test/unit/x402-v1-challenge.test.ts b/packages/core/test/unit/x402-v1-challenge.test.ts
index ac442fd..b6349ca 100644
--- a/packages/core/test/unit/x402-v1-challenge.test.ts
+++ b/packages/core/test/unit/x402-v1-challenge.test.ts
@@ -130,7 +130,7 @@ describe("payment checks when no payment can be built", () => {
["an x402 v1 challenge", v1Body("stellar-testnet"), /x402 v1 challenge/],
["an unreadable challenge", "payment required", /could not be read/],
] as const) {
- it(`skips X402-06 and X402-07 for ${label}, and sends nothing`, async () => {
+ it(`skips every payment check for ${label}, and sends nothing`, async () => {
const target = await serve(body);
try {
const results = await runX402PaymentChecks({
@@ -143,6 +143,9 @@ describe("payment checks when no payment can be built", () => {
[
["X402-06", true],
["X402-07", true],
+ ["X402-08", true],
+ ["X402-09", true],
+ ["X402-10", true],
],
);
for (const result of results) assert.match(result.detail, reason);
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 03747a0..48ee65d 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -170,12 +170,14 @@ server.registerTool(
{
title: "Test x402 compliance",
description:
- "Runs the x402 conformance checks (X402-01..07) against a running service. " +
+ "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); otherwise they are skipped. When they do run, "
- + "X402-06 settles a real payment and X402-07 attempts one, so each call "
- + "spends testnet funds and repeated calls spend repeatedly. Testnet only.",
+ + "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 "
+ + "repeatedly. X402-10 waits for an authorization to expire (about 20 s on "
+ + "Stellar). Testnet only.",
inputSchema: {
target: z.string().describe("Full URL of the paid resource, including scheme"),
network: z
@@ -189,7 +191,7 @@ server.registerTool(
readOnly: z
.boolean()
.optional()
- .describe("Skip the payment checks (X402-06/07) even if a payer key is set"),
+ .describe("Skip the payment checks (X402-06..10) even if a payer key is set"),
rpcUrl: z
.string()
.optional()
diff --git a/scripts/fixtures.sh b/scripts/fixtures.sh
index e7c7e74..2f2c93b 100755
--- a/scripts/fixtures.sh
+++ b/scripts/fixtures.sh
@@ -27,6 +27,7 @@ FIXTURES=(
"channel:3003:packages/core/test/fixtures/mpp-channel-server.ts"
"refusing:3004:packages/core/test/fixtures/mpp-channel-refusing-server.ts"
"x402-evm:3005:packages/core/test/fixtures/x402-evm-server.ts"
+ "x402-permit2:3006:packages/core/test/fixtures/x402-evm-permit2-server.ts"
)
field() { echo "$1" | cut -d: -f"$2"; }
@@ -82,10 +83,14 @@ cmd_start() {
for fixture in "${FIXTURES[@]}"; do
# The Base Sepolia fixture needs its own payee; without one it is skipped,
# not failed, so a Stellar-only setup starts as before.
- if [ "$(field "$fixture" 1)" = "x402-evm" ] && ! grep -q '^EVM_PAYEE_ADDRESS=.' "$ROOT/.env"; then
- printf " %-9s skipped (EVM_PAYEE_ADDRESS not set in .env)\n" "x402-evm"
- continue
- fi
+ case "$(field "$fixture" 1)" in
+ x402-evm|x402-permit2)
+ if ! grep -q '^EVM_PAYEE_ADDRESS=.' "$ROOT/.env"; then
+ printf " %-9s skipped (EVM_PAYEE_ADDRESS not set in .env)\n" "$(field "$fixture" 1)"
+ continue
+ fi
+ ;;
+ esac
start_one "$fixture"
done
echo
@@ -95,6 +100,7 @@ cmd_start() {
echo " channel http://localhost:3003/data"
echo " refusing http://localhost:3004/data (always refuses, by design)"
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)"
}
cmd_stop() {
diff --git a/scripts/run-all.sh b/scripts/run-all.sh
index 2dc4163..8d601d4 100755
--- a/scripts/run-all.sh
+++ b/scripts/run-all.sh
@@ -106,13 +106,16 @@ 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.
-if lsof -nP -iTCP:3005 -sTCP:LISTEN -t >/dev/null 2>&1; then
- if [ "$FULL" = 1 ]; then
- SUITES+=("x402-evm|test --target http://localhost:3005/protected --network eip155:84532")
- else
- SUITES+=("x402-evm|test --target http://localhost:3005/protected --network eip155:84532 --read-only")
+for evm in "x402-evm:3005" "x402-permit2:3006"; do
+ name="${evm%%:*}"; port="${evm#*:}"
+ 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")
+ else
+ SUITES+=("$name|test --target http://localhost:$port/protected --network eip155:84532 --read-only")
+ fi
fi
-fi
+done
SUITES+=("mpp-channel|mpp-channel --target http://localhost:3003/data")
# The CLI reads .env from its working directory.
diff --git a/skills/wasit/SKILL.md b/skills/wasit/SKILL.md
index 3c9e749..8e2e6b2 100644
--- a/skills/wasit/SKILL.md
+++ b/skills/wasit/SKILL.md
@@ -53,8 +53,9 @@ 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`, or `EVM_PRIVATE_KEY` with
-`--network eip155:84532` for Base Sepolia) to also run `X402-06`/`07`, which
-settle a real testnet payment; `X402-06` then verifies that settlement on-chain. `wasit mpp-charge` and `wasit mpp-channel` cover MPP;
+`--network eip155:84532` for Base Sepolia) to also run `X402-06`–`10`: `X402-06`
+settles a real testnet payment and verifies it on-chain, and `X402-07`–`10` send
+payments the service must refuse. `wasit mpp-charge` and `wasit mpp-channel` cover MPP;
channel mode is free except for the destructive close check, which is opt-in
only (`--allow-destructive`) and never runs by accident.