判定端点按次计费,传输层是 x402 v2,也就是说整个支付握手发生在 HTTP 头里:没有 API key、没有账号、
没有账单。这篇记录 pay.ashesborn.cloud 实际使用的线上格式,重点写客户端最容易做错的地方。
第 2、3、6、7 节都是在 2026-10-28 对着线上服务跑出来的。第 5 节的签名没有跑:那需要 Base 主网上一个 有余额的钱包,所以那一节是根据规范与本站挑战字段写的,文中会标出来。
1 付费的是什么,单位是什么
GET https://pay.ashesborn.cloud/api/v1/datasets 是唯一权威。三条记录,其中两条是判定:
dataset_id | 工具 | 价格 | amount(原子单位) |
|---|---|---|---|
retraction-propagation | retraction_propagation | $0.02 | 20000 |
retraction-evidence-audit | integrity_evidence_audit | $0.10 | 100000 |
web3-security | query_graph | 局部 0.10 | 20000 / 100000 |
Base 上的 USDC 是 6 位小数,所以 20000 就是 0.02 USDC。协议里金额一律用原子单位;客户端如果在
签名前把它格式化成浮点数,授权里的 value 就会对不上,服务端只会返回 value_mismatch。
免费的论文状态路由(GET /api/v1/judge/paper?doi=)不属于这个流程,它根本没有支付要求。
2 第一次请求,以及它返回的挑战
不带支付头的请求返回 402,响应体为空,挑战在响应头里:
bashcurl -sS -D - -o /dev/null \ -X POST https://pay.ashesborn.cloud/api/v1/judge \ -H 'Content-Type: application/json' \ -d '{"dataset_id":"retraction-propagation","doi":"10.1016/S0140-6736(97)11096-0"}'
textHTTP/2 402 payment-required: eyJ4NDAyVmVyc2lvbiI6MiwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9wYXku… access-control-expose-headers: PAYMENT-REQUIRED, PAYMENT-RESPONSE
这个头是 base64url 编码的 JSON。解码后,本站的挑战是:
json{ "x402Version": 2, "resource": { "url": "https://pay.ashesborn.cloud/api/v1/judge", "description": "Deterministic analysis of retraction propagation through citations.", "mimeType": "application/json", "serviceName": "graphrag-ts", "tags": ["integrity", "retractions", "academic"] }, "accepts": [ { "scheme": "exact", "network": "eip155:8453", "amount": "20000", "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "payTo": "0x5d44…8e29", "maxTimeoutSeconds": 60, "extra": { "name": "USDC", "version": "2" } } ], "extensions": { "bazaar": { "…": "本端点的入参/出参示例与 JSON Schema" } } }
四个字段决定客户端签什么:asset 是 EIP-712 的 verifyingContract,extra.name 与
extra.version 是域里的 name 与 version,network 是 CAIP-2 形式,eip155:8453 对应
chainId 8453(Base 主网),maxTimeoutSeconds 限定授权有效期。这四个值都不要写死,从
accepts[0] 里读。
bazaar 扩展里带着本端点的入参示例和 JSON Schema,客户端不用看人类文档也能知道 body 长什么样。
3 请求体的契约
正常路径需要 dataset_id 加上恰好一个标识符:
json{ "dataset_id": "retraction-propagation", "doi": "10.1016/S0140-6736(97)11096-0" } { "dataset_id": "retraction-propagation", "paper_id": "W2117847125" }
端点会在要钱之前先校验 body,并且把期望字段写进错误里,而不是让客户端猜:
json{ "error": "invalid_request_body", "expected": { "dataset_id": "string, see GET /api/v1/datasets", "target": "exactly one of doi or paper_id" }, "docs": "/api/v1/openapi.json" }
GET /api/v1/openapi.json 无需认证即可取到完整 schema。
4 支付载荷
PAYMENT-SIGNATURE 里放 base64url 编码的 PaymentPayload。在 EVM 的 exact 方案下,形状是:
json{ "x402Version": 2, "resource": { "url": "…", "description": "…", "mimeType": "application/json" }, "accepted": { "scheme": "exact", "network": "eip155:8453", "amount": "20000", "asset": "0x8335…2913", "payTo": "0x5d44…8e29", "maxTimeoutSeconds": 60, "extra": { "name": "USDC", "version": "2" } }, "payload": { "signature": "0x…65 字节…", "authorization": { "from": "0x…", "to": "0x5d44…8e29", "value": "20000", "validAfter": "1793172000", "validBefore": "1793172060", "nonce": "0x…32 字节…" } } }
accepted 是从挑战里原样回传的。自己重建这个对象、导致 amount/asset/payTo 对不上,验证时就只会
拿到一个收款方或金额不匹配,而不会得到有用的错误信息,所以复制挑战里的那份 requirement 对象。
授权是 EIP-3009 的 TransferWithAuthorization:签的是转账条款,不是交易。在服务端结算之前,链上什么
都不会发生,付款方的私钥也一直留在客户端。
5 怎么签名
tsimport { toHex } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); const challenge = JSON.parse(Buffer.from(paymentRequiredHeader, 'base64url').toString()); const req = challenge.accepts[0]; const now = Math.floor(Date.now() / 1000); const authorization = { from: account.address, to: req.payTo, value: req.amount, validAfter: String(now - 60), validBefore: String(now + req.maxTimeoutSeconds), nonce: toHex(crypto.getRandomValues(new Uint8Array(32))), }; const signature = await account.signTypedData({ domain: { name: req.extra.name, // "USDC" version: req.extra.version, // "2" chainId: 8453, // 来自 network eip155:8453 verifyingContract: req.asset as `0x${string}`, }, types: { TransferWithAuthorization: [ { name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'value', type: 'uint256' }, { name: 'validAfter', type: 'uint256' }, { name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'bytes32' }, ], }, primaryType: 'TransferWithAuthorization', message: { ...authorization, value: BigInt(req.amount), validAfter: BigInt(authorization.validAfter), validBefore: BigInt(authorization.validBefore), }, }); const payment = { x402Version: 2, resource: challenge.resource, accepted: req, payload: { signature, authorization }, }; const header = Buffer.from(JSON.stringify(payment)).toString('base64url');
实践中三个容易踩的点:
value在载荷里是字符串,在 typed data 里必须是BigInt。混着用会改变被签的哈希。nonce是 32 字节随机数,按bytes32传。复用 nonce 就是重放,ERC-20 会在结算时拒绝,而那时资源 已经执行完了。validAfter往前放一分钟没有问题;validBefore超过maxTimeoutSeconds不行,服务端会按挑战里 声明的时间窗校验。
不想手写的话,x402-fetch 与 x402-axios(npm 上都是 1.2.x)把同一套流程包成了客户端,把账号作为
付款方传进去即可。
6 返回什么,以及错误路径
成功时资源先执行、再结算,回执放在第二个头里:
textHTTP/2 200 payment-response: eyJzdWNjZXNzIjp0cnVlLCJ0cmFuc2FjdGlvbiI6IjB4…
解码是 { "success": true, "transaction": "0x…", "network": "eip155:8453", "payer": "0x…" }。
对着线上端点实测的错误路径:
| 情况 | 状态 | 响应体 |
|---|---|---|
| 没有支付头 | 402 | {}(挑战在 PAYMENT-REQUIRED 里) |
body 缺 dataset_id | 400 | {"error":"invalid_request_body","expected":{…}} |
dataset_id 不在目录里 | 404 | {"error":"Unknown judgment dataset."} |
PAYMENT-SIGNATURE 不是 base64 | 400 | {"error":"Payment header is not valid base64."} |
| 头能解码但载荷过不了 schema | 400 | {"error":"Payment payload is invalid."} |
| 载荷合法、签名伪造 | 402 | {"error":"invalid_exact_evm_payload_signature"},并带一个新的挑战 |
两个 400 与那个 402 是三种不同的问题,对应三种不同的修法。400 意味着客户端构造的东西服务端根本
解析不了:这时候还没谈钱,重发同样的字节仍然得到 400。带原因的 402 是服务端读懂之后交给 facilitator、
被拒并给出了理由。invalid_exact_evm_payload_signature 就是上面那个伪造签名,授权不是由 from
对应的私钥签的。规范里的其余验证结论走同一个字段,例如 insufficient_funds 与
invalid_exact_evm_payload_authorization_valid_before,客户端可以按字符串分支而不用靠探。有一个
命名细节会白白耗掉一小时:原因放在 error 里,而 x402 客户端库读的 invalidReason 在这个响应里
没有,所以只认 invalidReason 的封装会把"时钟不对"报成网络错误。
幂等的顺序问题:Idempotency-Key 是被接受的(它在 CORS 允许头列表里),所以同一次重试复用同一个 key
时,即使第一次响应丢了也不会重复扣费。
7 MCP 上的同一套流程
mcp.ashesborn.cloud/mcp 上有三个诚信工具。它对 initialize 与 tools/list 匿名开放,客户端可以先
连上、看清单,再决定是否付钱:
| 工具 | 匿名行为 |
|---|---|
integrity_paper_status | 200,返回状态载荷 |
retraction_propagation | 工具结果,isError: true |
integrity_evidence_audit | 工具结果,isError: true |
付费拒绝是一条正常的 JSON-RPC 结果,不是传输层错误。把工具错误当成"模型做错了什么"的客户端会把它藏 起来。内容是可机读的:
json{ "error": "payment_required", "product": "retraction-propagation", "price": { "usd": "$0.02", "atomic": "20000" }, "scheme": "x402", "network": "base", "pay_endpoint": "https://pay.ashesborn.cloud/api/v1/judge", "docs": "https://blog.ashesborn.cloud/datasets" }
MCP 传输层不承载 HTTP 的支付握手。工具错误里给出了产品、价格与 pay_endpoint,支付本身走第 2 到第 5
节的 HTTP 流程,换回来的凭证(目前以 key 的形式发放)由已认证会话带去调用付费工具。这个交接目前没有
公开文档,这是把 Agent 接到这个表面上时实际会卡住的地方:不付费的客户端仍然能拿到免费的状态工具,也
就足以回答"这篇论文撤了吗",不需要钱包。
8 这篇没覆盖什么
- 结算没有在这里执行。 我解码了挑战、构造了载荷、跑了每一条错误路径,但没有广播转账:那需要 Base 上一个有余额的钱包。第 5 节的签名示例是按照规范的字段集与本站挑战值写的,没有端到端跑通。
- 只有一个网络。 目前只提供
eip155:8453上的 USDC。客户端应当遍历accepts、跳过付不了的那 条,而不是假定只有一条。 - 没有订阅。 每次调用都是一次独立授权。预算属于客户端,服务端只约束单次金额与时间窗。