协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
test-x402-payment-client【开发与工程】Test an x402 payment client end to end against a live endpoint: drive the full 402 -> PAYMENT-REQUIRED -> sign -> PAYMENT-SIGNATURE -> verify/settle loop, confirm settlement on chain, and check any signed offers or receipts for conformance. Use when validating a new x402 client or SDK before shipping, debugging a payment that never completes despite …
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Validate that an x402 payment client completes the full protocol loop against a
live endpoint — not just that the endpoint answers a 402, but that a signed
payment can actually be built, submitted, verified, settled on chain, and (where
offered) checked for offer/receipt conformance. A well-formed challenge is a
claim; a completed settlement is the proof.
The header names, scheme semantics, and network identifiers below are from the
x402 v2 specification and its transport and scheme specs
(specs/x402-specification-v2.md, specs/transports-v2/http.md,
specs/transports-v2/mcp.md, specs/schemes/exact/scheme_exact_evm.md,
specs/schemes/exact/scheme_exact_svm.md, and
specs/extensions/extension-offer-and-receipt.md in
coinbase/x402). Treat those specs as the
source of truth over any single vendor's docs.
402 (the failure is usually in the header the client did not
read, or the scheme it did not recognize)402).eip155:84532); for SVM start on Solana Devnet. Testnet USDC is free
from a faucet.mainnet_optin (boolean, default false) — only when true
does the procedure move real funds. The final mainnet step states its own cost.json, markdown).Send a plain request and read the 402. The machine-readable terms ride the
PAYMENT-REQUIRED response header as base64-encoded JSON; the body is
human-oriented and must not be the client's source of truth.
set -o pipefail # a 402 carrying NO payment-required header makes grep fail while every later
# stage succeeds on empty input; without this the pipeline reports only the
# last stage. That is the silent break named below. The assertion catches it
# too — keep both, since either alone can be edited away.
curl -sD challenge.txt -o /dev/null https://x402.example.testnet/resource
# the status line is the first half of what Expected demands, and nothing else reads it
head -1 challenge.txt | grep -q ' 402' || { echo "not a 402: $(head -1 challenge.txt)"; exit 1; }
# base64-decode the PAYMENT-REQUIRED header value into terms.json
grep -i '^payment-required:' challenge.txt | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r' | base64 -d | jq . > terms.json
# assert the rest of it: a non-empty file is not enough, `null` is a non-empty file, and an
# entry missing one of the six fields is not one the signing step can use
jq -e '.x402Version == 2 and ([.accepts[] | select(.scheme and .network and .asset and .amount
and .payTo and .maxTimeoutSeconds)] | length) > 0' terms.json > /dev/null \
|| { echo 'no usable PAYMENT-REQUIRED terms — stop here'; exit 1; }
The transport spec does not pin the base64 alphabet. The one endpoint measured while writing this
skill uses standard base64 with = padding, which the decode above reads; a server using base64url
would fail that decode rather than mis-parse it — base64 -d rejects - and _ — and the
assertion turns the failure into a stop rather than an empty file.
The decoded PaymentRequired object carries x402Version (must be 2), one
or more accepts entries (each a PaymentRequirements), and any advertised
extensions. Each accepts entry carries scheme, network (CAIP-2, e.g.
eip155:84532), the token asset, the amount in atomic units, payTo, and
maxTimeoutSeconds.
Expected: HTTP 402; a PAYMENT-REQUIRED header that base64-decodes to JSON
in terms.json with x402Version: 2 and at least one accepts entry carrying
scheme, network, asset, amount, payTo, and maxTimeoutSeconds. Every
clause of that sentence is asserted by the block, which is the point: the status
line, the decode, the version, and an entry carrying all six fields. It exits
non-zero on a non-402, on a header that decodes to null, on one carrying no
accepts entries, on one declaring a version other than 2, and on one whose
only entry is missing a field the signing step needs.
On failure: If there is no PAYMENT-REQUIRED header, the endpoint is not
serving v2 terms a client can act on — stop and report that (a 402 body with
no header is the single most common silent break). If the header is present but
does not base64-decode to JSON, record it as malformed and stop.
accepts entry the client actually supportsPick the entry whose scheme and network the client implements. The exact
scheme is the baseline (EIP-3009 TransferWithAuthorization on EVM;
TransferChecked for SPL tokens on SVM). A challenge may also offer other
schemes (for example upto for variable amounts, or a batch-settlement scheme);
a client built only for exact must skip those entries rather than misread them.
Then gate the network: unless mainnet_optin is true, the selected entry must
be on a testnet the spec's network list names (Base Sepolia eip155:84532,
Avalanche Fuji eip155:43113, Solana Devnet
solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1). This is the step that makes
testnet-first a procedure rather than a preference.
# pick the first `exact` entry on a network the client implements ($supported: edit to
# match the client), then gate it: a mainnet network passes only with MAINNET_OPTIN=true
jq -e --arg optin "${MAINNET_OPTIN:-false}" '
["eip155:84532","eip155:43113","solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"] as $testnets
| ["eip155:","solana:"] as $supported
| [.accepts[] | select(.scheme=="exact" and (.network as $n | any($supported[]; . as $p | $n|startswith($p))))] as $ok
| if ($ok|length)==0 then error("unsupported-scheme: " + (if (.accepts|length)==0
then "(challenge offered no accepts entries)"
else ([.accepts[] | .scheme+"@"+.network]|join(",")) end))
else ([$ok[] | select((.network|IN($testnets[])) or $optin=="true")][0]
// error("mainnet-not-opted-in: " + $ok[0].network)) end' terms.json > selected.json
Expected: Exactly one supported accepts entry in selected.json; its
scheme, network, asset, amount, payTo, and maxTimeoutSeconds read
into the signing step. A mainnet network appears there only when
mainnet_optin is true.
On failure: If no offered entry matches a scheme+network the client
supports, that is a real interop result — record unsupported-scheme with the
schemes offered, and stop. Do not coerce an upto or batch entry into an
exact signature. If the only matching entry is on a mainnet network and
mainnet_optin is false, record mainnet-not-opted-in and stop — that is the
gate working, not a defect.
This step has no fence because it is the client under test that signs it. Hand
selected.json to that client along the path it would use in production, and
capture what it produces as payload.json; the skill supplies no reference
signer, because signing with one would test the reference rather than the client.
The Inputs section names the wallet and its signer as things you bring.
Construct the scheme-specific authorization over the selected entry and sign it.
For exact on EVM the recommended mechanism is an EIP-3009
TransferWithAuthorization (EIP-712 typed-data signature) for the exact amount
to payTo, with a fresh random nonce and a validBefore inside the entry's
maxTimeoutSeconds — but it is not the only one. The scheme spec
(specs/schemes/exact/scheme_exact_evm.md) also defines Permit2 as the
universal fallback for tokens without EIP-3009 and ERC-7710 for smart accounts,
selected by assetTransferMethod in the payload (default order: EIP-3009, then
Permit2). Echo every extension the server advertised: the client must include
at least the info it received and may append, but may not delete or overwrite
it. Write the result to payload.json for the next step.
Expected: A PaymentPayload in payload.json with x402Version: 2, the
selected entry under accepted, and a scheme-specific payload carrying the
signature and authorization; advertised extensions echoed intact.
On failure: If signing throws on the address or amount, check the asset
checksum and that the amount is an atomic-unit string, not a decimal. A
decimal-typed amount underpays by a factor of the token's decimals and is a
frequent silent bug.
Base64-encode the PaymentPayload and resend the same request with it in the
PAYMENT-SIGNATURE header. The endpoint (or its facilitator) verifies the
signature and settles.
set -o pipefail
# Step 3 has no fence — it is the client under test that signs — so check its artifact arrived.
# `base64 missing.json | tr -d` exits 0 (the pipeline reports tr), which would otherwise send an
# empty PAYMENT-SIGNATURE and read back as a settlement failure.
jq -e '.x402Version == 2 and .accepted != null and .payload != null' payload.json > /dev/null 2>&1 \
|| { echo 'payload.json is not a PaymentPayload: the client under test produced no signed payment'; exit 1; }
# base64 without -w0: GNU wraps at 76 columns, BSD and busybox reject -w — strip newlines instead
curl -s -H "PAYMENT-SIGNATURE: $(base64 payload.json | tr -d '\n')" \
https://x402.example.testnet/resource -D headers.txt -o body.json
head -1 headers.txt | grep -q ' 200' || { echo "not a 200: $(head -1 headers.txt)"; exit 1; }
# the settlement rides a header too, and needs the same decode as Step 1
grep -i '^payment-response:' headers.txt | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r' | base64 -d | jq . > settlement.json
# assert the settlement before believing it: an absent header, a `null` body and a SettleResponse
# with no transaction all leave a block that merely prints, exiting 0
jq -e '.success == true and ((.transaction // "") | length) > 0' settlement.json > /dev/null \
|| { echo 'settled-unverified: no settlement transaction'; exit 1; }
jq -r '.transaction' settlement.json # the hash Step 5 verifies
Expected: HTTP 200. The response carries a settlement result in the
base64-encoded PAYMENT-RESPONSE header, decoded above into settlement.json
(SettleResponse: success: true, a non-empty transaction hash, and the
network; amount and payer are optional and may be omitted).
On failure: A repeated 402 means verification refused the payment — inspect
the reason. A common cause is the client sending the legacy X-PAYMENT header
name where the endpoint only reads PAYMENT-SIGNATURE (or the reverse); try the
other name once and record which the endpoint accepts. The status check above
separates that case from the next one by message, so read which of the two the
block printed. If the response is 200 but carries no settlement transaction —
no PAYMENT-RESPONSE header at all, a SettleResponse without a transaction,
or one reporting success: false — the assertion above exits non-zero: record
settled-unverified and stop. There is no hash for Step 5 to check, and a
200 without a settlement is not a completed payment.
Do not trust the endpoint's own word that money moved. Take the transaction
hash from the SettleResponse and confirm it on chain: correct payTo, correct
asset, amount within what was authorized, and finality.
# EVM: fetch the receipt from a Base Sepolia RPC and read the Transfer log
curl -s -X POST https://sepolia.base.org -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt","params":["<transaction>"]}' \
| jq '{status: .result.status, block: .result.blockNumber, logs: .result.logs}'
# status 0x1 = success. In the ERC-20 Transfer log: .address must equal the `asset`
# contract, topics[2] is the recipient (must equal payTo, zero-padded) and .data is
# the value in atomic units. Check .address too — a Transfer of the right amount to
# the right address from the wrong token contract is exactly the mismatch below.
# Finality: the receipt carries no head height, so ask for one and subtract.
curl -s -X POST https://sepolia.base.org -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \
| jq -r '"head " + .result'
# confirmations = head - .result.blockNumber, both hex; require what that network needs.
# SVM: the equivalent is getTransaction, reading the token-balance delta rather
# than a log.
curl -s -X POST https://api.devnet.solana.com -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"getTransaction","params":["<signature>",{"encoding":"json","maxSupportedTransactionVersion":0}]}' \
| jq '{err: .result.meta.err, pre: .result.meta.preTokenBalances, post: .result.meta.postTokenBalances}'
# err null = success; for the balance entry whose owner is payTo AND whose mint is
# `asset` — both, in one filter — post minus pre must equal the authorized amount.
# A destination account the transaction creates has no pre entry at all, which
# reads as a pre of 0. scheme_exact_svm.md is what licenses filtering on the owner:
# "Destination MUST equal the Associated Token Account PDA for (owner = payTo,
# mint = asset)", so payTo is the wallet, not the token account. Deriving that PDA
# and matching the entry's account address is the stricter check, if you have a
# derivation to hand.
# err null is inclusion, not finality: poll getSignatureStatuses for the
# commitment level you require.
Expected: The on-chain transfer matches payTo, asset, and the authorized
amount, and has reached finality on the stated network.
On failure: If the hash is absent, unconfirmed, or pays a different address
or amount than authorized, record settlement-mismatch — the endpoint claimed a
settlement the chain does not show. This is the failure the whole procedure
exists to catch.
If the client speaks x402 over MCP rather than HTTP, the same loop applies with
different envelopes (specs/transports-v2/mcp.md): the payment terms arrive in
a tool result with isError: true, in result.structuredContent (primary)
and result.content[0].text (JSON fallback), instead of a PAYMENT-REQUIRED
header; the signed payment is sent in the request's _meta["x402/payment"]
instead of a PAYMENT-SIGNATURE header; and the settlement comes back in the
result's _meta["x402/payment-response"]. The scheme, signing, and on-chain
verification steps are unchanged. Treat this as a transport variant, not the
primary path.
Expected: Terms parsed from result.structuredContent of an isError: true
tool result; payment sent in _meta["x402/payment"]; settlement read from
_meta["x402/payment-response"] and verified on chain as in Step 5.
On failure: If an isError: true result carries no structuredContent or
content[0].text that decodes to PaymentRequired terms, the server is not
advertising x402 over this transport — fall back to the HTTP path or stop.
402 carried a PAYMENT-REQUIRED header that base64-decoded to JSON
with x402Version: 2 and at least one complete accepts entry.exact entry on a network prefix the client does not
implement, a challenge mixing mainnet and testnet entries with
mainnet_optin false (the testnet entry must be the one selected, not a
refusal), and a challenge with an empty accepts array. Each must stop
the run with the documented reason rather than select anything.PAYMENT-SIGNATURE returned 200 with a SettleResponse
carrying a real transaction hash.offer-receipt extension
(specs/extensions/extension-offer-and-receipt.md): offers live in
extensions["offer-receipt"].info.offers[], the receipt in
extensions["offer-receipt"].info.receipt. For an EIP-712 signature,
recover the signer from the signature; for a JWS compact serialization,
resolve the header's kid (a DID URL) to the issuer's key and verify
over the complete payload. In both cases confirm the signer is authorised
for resourceUrl, that the signed network, asset, payTo and
amount match the accepts entry selected in Step 2 — the spec binds an
offer to its entry by those fields, and explicitly not by the unsigned
acceptIndex an offer may also carry — that the required fields are
present (offer: version,
resourceUrl, scheme, network, asset, payTo, amount; receipt:
version, network, resourceUrl, payer, issuedAt), and any
validUntil has not passed.PAYMENT-REQUIRED header; the 402 body is human text. A client that
parses the body will miss or mis-handle the real accepts.PAYMENT-SIGNATURE (v2) versus the legacy X-PAYMENT
name breaks otherwise-correct clients silently — a well-signed payload under
the wrong header just yields another 402. Test both names and record which
the endpoint honors.200 with no settlement transaction, or with a hash
that pays the wrong address, is not a completed payment. Always verify on
chain before treating the resource as paid for.upto or batch-settlement
entry into an exact signature produces an invalid payment. Skip unsupported
schemes; do not reshape them.eip155:84532, Solana Devnet). Step 2's gate refuses a mainnet network
unless mainnet_optin is true; only set it once the full loop passes on
testnet.Testnet (primary). Point the client at the coinbase/x402 reference example
server, or any facilitator's testnet endpoint, on Base Sepolia
(eip155:84532). Fund the test wallet from a testnet USDC faucet and run
Steps 1–5. This exercises the entire loop end to end with no real value at risk
and is the example to wire into CI.
Mainnet settlement (explicit opt-in). Only with mainnet_optin: true: run
the same loop against a live mainnet endpoint to confirm real settlement.
scvd.store is one such endpoint: its lowest-priced resource settles for
$0.001 USDC on Base or Solana and returns a signed receipt
(GET https://scvd.store/api/buy/spot_check, verify at
https://scvd.store/api/verify/{id}), which makes it a known-good mainnet
target when you want to prove the client against real money. The same operator
runs a free conformance checker for offer/receipt artifacts
(POST https://scvd.store/api/conformance/v1) and a challenge-shape preflight
(POST https://scvd.store/api/preflight/v1); both are optional and accept any
issuer's artifact. This step moves real funds; run it deliberately, not in CI.
test-a2a-interop — protocol-conformance testing for A2A agents; same
posture (drive the real loop, validate against the spec) on a different rail.build-custom-mcp-server — relevant when the client under test speaks x402
over the MCP transport variant in Step 6.