fix(skills): claim x402 payments in onAfterVerify, fail closed

Claim from the verified payload and matched requirements in the
resource server's onAfterVerify hook, so a forged header can no longer
reserve a legitimate payment's key, and an invalid payment takes no
claim. Fail closed on a store error or timeout by returning an abort
(a throwing hook is skipped and would run the handler unguarded);
document fail-open as an explicit, idempotent-only choice. Cover
Permit2 (BigInt nonce) and keep claims until validBefore/deadline.

The test now drives the real @x402/express middleware with payments
signed by the real @x402/evm client and a stand-in facilitator that
checks EIP-3009 signatures with viem, so verification, the hook, the
handler and settlement run in their real order. Adds exact-pinned,
test-only devDependencies and refreshes the registry digest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christopher FerjoandClaude Opus 5.5 committed 2026-10-25 17:25:46 -07:00
1 parent 8f7672406c
commit 46baaeddf0
8 files changed
+2678 -326

No files matched your search

+90 -77
View File
@@ -307,19 +307,22 @@ curl -s https://api.mainnet-beta.solana.com -X POST -H 'Content-Type: applicatio
**セラー:1 つの支払い承認につきルートは 1 回だけ実行する。** ミドルウェアは支払いを検証し、ハンドラーを実行し、その後で決済する。検証は承認を消費しない(消費するのは決済)ため、同じ署名済み支払いを同時に複数回送ると、毎回検証を通り、毎回ハンドラーが実行される。ファシリテーターが決済するのは 1 つのコピーだけで、残りはレスポンスなしの 402 を受け取るので、誰も無料で出力を得ることはない。しかしハンドラー内の副作用(LLM 呼び出し、ジョブのキュー投入、オンチェーントランザクション)はコピーごとに発生する。本番のセラー(Python `x402` 2.9.0、FastAPI)では、1 つの承認を同時に 3 回送ると、ハンドラーが 3 回実行され、決済は 1 回だった。ハンドラー内の有料呼び出しカウンターも同じように売上を過大に数えるので、決済後に数えること。検証から決済までのより広い競合については [x402#1169](https://github.com/x402-foundation/x402/issues/1169) で議論されている。
各承認を、支払いミドルウェアが実行される前に、アトミックな挿入で 1 回だけ取得する:
各承認を、ファシリテーターが検証した後、ハンドラーが実行される前に、1 回だけ取得する。リソースサーバーの `onAfterVerify` フックはちょうどその時点で実行され、そこから返した `abort` はハンドラーを実行せずに 402 になる:
```typescript
import { createHash } from "node:crypto";
import express from "express";
import { paymentMiddleware, x402ResourceServer } from "@x402/express";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { getBase64Encoder, getTransactionDecoder } from "@solana/kit";
import { createClient } from "redis";
// The guard must never be what takes the seller down. With the offline queue
// disabled, a command sent while disconnected fails at once instead of waiting;
// the queue cap does the same when the store is connected but not replying, so
// abandoned claims cannot pile up; the error listener stops a client error from
// crashing the process; and the connection is made in the background, so route
// registration never waits on it.
// The claim store must never be what takes the seller down. With the offline
// queue disabled, a command sent while disconnected fails at once; the queue
// cap does the same when the store is connected but not replying; the error
// listener stops a client error from crashing the process; and the connection
// is made in the background, so startup never waits on it.
const redis = createClient({
url: process.env.REDIS_URL,
disableOfflineQueue: true,
@@ -330,51 +333,46 @@ redis.on("error", (err) => console.warn(`replay store: ${err.message}`));
redis.connect().catch(() => {});
const CLAIM_MIN_S = 900; // longer than maxTimeoutSeconds plus your slowest handler run
const CLAIM_MAX_S = 366 * 86_400; // ceiling for a signer-chosen validBefore
const CLAIM_TIMEOUT_MS = 250; // a slow store must not stall paid requests (the command itself is not cancelled)
const MAX_HEADER_BYTES = 16_384; // claims are stored before anything is verified
const CLAIM_MAX_S = 366 * 86_400; // ceiling for a signer-chosen expiry
const CLAIM_TIMEOUT_MS = 250; // a slow store must not stall paid requests
// When the store is unavailable: refuse before the handler runs (true), or run
// it without duplicate protection (false). Fail open only if the handler is
// idempotent or has its own side-effect boundary.
const FAIL_CLOSED = true;
// One key per authorization, not per header: re-encoding the same signed
// payment must not make it look new.
function claimKey(header: string): string {
try {
const p = JSON.parse(Buffer.from(header, "base64").toString("utf8"));
const network = p.accepted?.network ?? "";
const auth = p.payload?.authorization;
if (auth?.from && auth?.nonce) {
// EVM exact (EIP-3009): each token contract accepts a (from, nonce) once,
// so the asset is part of the authorization's identity.
const asset = String(p.accepted?.asset ?? "").toLowerCase();
return `x402:${network}:${asset}:${String(auth.from).toLowerCase()}:${String(auth.nonce).toLowerCase()}`;
}
if (typeof p.payload?.transaction === "string") {
// SVM exact: hash the message the payer signed, not the whole transaction.
// The fee payer's signature slot sits outside the payer's signature, so a
// copy with different bytes there is still the same payment. The message
// already names the mint.
const tx = getTransactionDecoder().decode(getBase64Encoder().encode(p.payload.transaction));
return `x402:${network}:${createHash("sha256").update(new Uint8Array(tx.messageBytes)).digest("hex")}`;
}
} catch {
// Unparseable: fall through to the raw header, which still catches exact copies.
}
return `x402:raw:${createHash("sha256").update(header).digest("hex")}`;
}
// Keep a claim until the authorization itself expires. The signer picks the
// EIP-3009 validBefore and verification does not cap it at maxTimeoutSeconds,
// so a claim dropped earlier would let a copy of a still-valid authorization
// whose settlement failed run the handler again.
function claimUntil(header: string): number {
// Identifies a verified authorization, however its JSON is encoded, and keeps
// the claim until the authorization can no longer settle: the signer picks
// that expiry, and verification does not cap it at maxTimeoutSeconds.
function claimOf(paymentPayload, requirements): { key: string; until: number } | undefined {
const now = Math.floor(Date.now() / 1000);
let validBefore = 0;
try {
const p = JSON.parse(Buffer.from(header, "base64").toString("utf8"));
validBefore = Number(p.payload?.authorization?.validBefore) || 0;
} catch {
// No EVM expiry to honour: the floor applies.
const keep = (expiry: number) => Math.min(Math.max(now + CLAIM_MIN_S, expiry + 60), now + CLAIM_MAX_S);
const p = paymentPayload.payload;
if (p.authorization?.nonce) {
// EVM exact, EIP-3009: each token contract accepts a (from, nonce) once.
const { from, nonce, validBefore } = p.authorization;
const asset = requirements.asset.toLowerCase();
return {
key: `x402:${requirements.network}:${asset}:${from.toLowerCase()}:${nonce.toLowerCase()}`,
until: keep(Number(validBefore) || 0),
};
}
return Math.min(Math.max(now + CLAIM_MIN_S, validBefore + 60), now + CLAIM_MAX_S);
if (p.permit2Authorization?.nonce !== undefined) {
// EVM exact, Permit2: uint256 nonces per owner, across tokens. The signed
// value is BigInt(nonce), so "1", "01" and "0x1" are one authorization.
const { from, nonce, deadline } = p.permit2Authorization;
return {
key: `x402:${requirements.network}:permit2:${from.toLowerCase()}:${BigInt(nonce)}`,
until: keep(Number(deadline) || 0),
};
}
if (typeof p.transaction === "string") {
// SVM exact: hash the message the payer signed, not the whole transaction.
// The fee payer's signature slot is outside the payer's signature.
const tx = getTransactionDecoder().decode(getBase64Encoder().encode(p.transaction));
const digest = createHash("sha256").update(new Uint8Array(tx.messageBytes)).digest("hex");
return { key: `x402:${requirements.network}:${digest}`, until: now + CLAIM_MIN_S };
}
return undefined;
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
@@ -384,36 +382,51 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
]);
}
// Register on paid routes, before the x402 payment middleware.
async function replayGuard(req, res, next) {
const header = req.get("payment-signature") ?? req.get("x-payment");
if (!header) return next();
if (header.length > MAX_HEADER_BYTES) {
return res.status(431).json({ error: "payment_header_too_large" });
}
let first = true;
try {
// NX is atomic: of two simultaneous copies exactly one wins.
const claimed = redis.set(claimKey(header), "1", {
condition: "NX",
expiration: { type: "EXAT", value: claimUntil(header) },
});
first = (await withTimeout(claimed, CLAIM_TIMEOUT_MS)) === "OK";
} catch {
// Fail open: the facilitator still refuses a second settlement, so a store
// outage degrades to the unguarded behaviour instead of blocking buyers.
}
if (!first) {
return res.status(402).json({
error: "payment_replayed",
detail: "This payment authorization was already presented. Sign a fresh one for each request.",
});
}
next();
}
const resourceServer = new x402ResourceServer(
new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL }),
)
.register("eip155:8453", new ExactEvmScheme())
.onAfterVerify(async ({ paymentPayload, requirements, result }) => {
if (!result.isValid) return; // an invalid payment never takes a claim
const claim = claimOf(paymentPayload, requirements);
if (!claim) return;
try {
// NX is atomic: of two simultaneous copies exactly one wins.
const set = redis.set(claim.key, "1", {
condition: "NX",
expiration: { type: "EXAT", value: claim.until },
});
if ((await withTimeout(set, CLAIM_TIMEOUT_MS)) === "OK") return;
return {
abort: true,
reason: "payment_replayed",
message: "This payment authorization was already presented.",
};
} catch {
// Return, don't throw: the resource server logs and skips a hook that
// throws, which would run the handler unguarded.
if (FAIL_CLOSED) {
return { abort: true, reason: "claim_unavailable", message: "Retry shortly." };
}
}
});
const app = express();
app.use(express.json());
app.use(
paymentMiddleware(
{
"POST /report": {
accepts: [{ scheme: "exact", price: "$0.01", network: "eip155:8453", payTo: process.env.PAY_TO }],
},
},
resourceServer,
),
);
app.post("/report", async (req, res) => res.json(await buildReport(req.body)));
```
各クレームはファシリテーターが支払いを検証する前に書き込まれるので、この経路は認証されていない書き込みと同様に扱うこと。ヘッダーの上限で 1 リクエストが保存できる量を制限し、ルートは通常のレート制限の内側に置く。条件付き挿入ができるストアなら何でもよい:DynamoDB の `attribute_not_exists` 付き `PutItem`、または Postgres のユニークインデックス。クライアント側のコストは、同じ署名で再試行したリクエスト(たとえば 5xx の後、決済がスキップされた場合)が拒否されるようになることだ。アップストリームのクライアントは 402 ごとに新しい承認に署名する。これが防ぐのは 1 つの承認の重複コピーだけである。検証と決済の間に支払いを無効にする支払者(たとえば資金を移動する)は、依然としてハンドラーを 1 回実行させられる。高コストなルートでは、作業の前に資金を確保すること。
アトミックな条件付き挿入ができるストアなら何でもよい:DynamoDB の `attribute_not_exists` 付き `PutItem`、または Postgres のユニークインデックス。ガードが代わりに決めてくれないことが 2 つある。ストアが停止している場合、`FAIL_CLOSED` は可用性と引き換えに承認ごとに 1 回という保証を守る。フェイルオープンにするとルートは動き続けるが、ストアが回復するまで各コピーがハンドラーを実行するので、冪等なハンドラーに限って使うこと。また `payment_replayed` は、その承認が以前に提示されたことを意味し、最初の試行が失敗したことを意味しない。クライアントが最初のレスポンスを失った場合、処理が実行され支払いが決済されている可能性があるので、新しい承認に署名する前に元のリクエストの結果を照合すること。これが防ぐのは 1 つの承認の重複コピーだけである。検証と決済の間に支払いを無効にする支払者(たとえば資金を移動する)は、依然としてハンドラーを 1 回実行させられる。高コストなルートでは、作業の前に資金を確保すること。
**発見。** x402 バザール拡張を実装するファシリテーターは `/discovery/resources` エンドポイントを公開する — CDP カタログは `https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources`、PayAI カタログは `https://facilitator.payai.network/discovery/resources` で照会できる。Solana で支払い可能なサービスには、Solana Foundation のキュレーションカタログである [pay.sh](https://pay.sh) もある。
+90 -77
View File
@@ -307,19 +307,22 @@ curl -s https://api.mainnet-beta.solana.com -X POST -H 'Content-Type: applicatio
**卖方:每个支付授权只运行一次路由。** 中间件先验证支付,再运行处理程序,然后结算。验证不会消耗授权(结算才会),因此同一笔已签名的支付同时发送多次,每次都能通过验证,每次都会运行处理程序。结算器只结算其中一份副本,其余副本收到不带响应的 402,所以没有人能免费获得输出。但处理程序中的每个副作用(LLM 调用、排队任务、链上交易)都会按副本数重复发生。在一个生产卖方(Python `x402` 2.9.0、FastAPI)上,同一授权并发发送三次,处理程序运行了三次,结算一次。放在处理程序内的付费调用计数器也会以同样方式高估收入;应在结算后计数。更广泛的验证到结算竞态在 [x402#1169](https://github.com/x402-foundation/x402/issues/1169) 中讨论。
在支付中间件运行之前,用原子插入将每个授权只认领一次:
在结算器验证授权之后、处理程序运行之前,将每个授权只认领一次。资源服务器的 `onAfterVerify` 钩子恰好在这一时刻运行,从中返回的 `abort` 会变成 402,处理程序不会运行:
```typescript
import { createHash } from "node:crypto";
import express from "express";
import { paymentMiddleware, x402ResourceServer } from "@x402/express";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { getBase64Encoder, getTransactionDecoder } from "@solana/kit";
import { createClient } from "redis";
// The guard must never be what takes the seller down. With the offline queue
// disabled, a command sent while disconnected fails at once instead of waiting;
// the queue cap does the same when the store is connected but not replying, so
// abandoned claims cannot pile up; the error listener stops a client error from
// crashing the process; and the connection is made in the background, so route
// registration never waits on it.
// The claim store must never be what takes the seller down. With the offline
// queue disabled, a command sent while disconnected fails at once; the queue
// cap does the same when the store is connected but not replying; the error
// listener stops a client error from crashing the process; and the connection
// is made in the background, so startup never waits on it.
const redis = createClient({
url: process.env.REDIS_URL,
disableOfflineQueue: true,
@@ -330,51 +333,46 @@ redis.on("error", (err) => console.warn(`replay store: ${err.message}`));
redis.connect().catch(() => {});
const CLAIM_MIN_S = 900; // longer than maxTimeoutSeconds plus your slowest handler run
const CLAIM_MAX_S = 366 * 86_400; // ceiling for a signer-chosen validBefore
const CLAIM_TIMEOUT_MS = 250; // a slow store must not stall paid requests (the command itself is not cancelled)
const MAX_HEADER_BYTES = 16_384; // claims are stored before anything is verified
const CLAIM_MAX_S = 366 * 86_400; // ceiling for a signer-chosen expiry
const CLAIM_TIMEOUT_MS = 250; // a slow store must not stall paid requests
// When the store is unavailable: refuse before the handler runs (true), or run
// it without duplicate protection (false). Fail open only if the handler is
// idempotent or has its own side-effect boundary.
const FAIL_CLOSED = true;
// One key per authorization, not per header: re-encoding the same signed
// payment must not make it look new.
function claimKey(header: string): string {
try {
const p = JSON.parse(Buffer.from(header, "base64").toString("utf8"));
const network = p.accepted?.network ?? "";
const auth = p.payload?.authorization;
if (auth?.from && auth?.nonce) {
// EVM exact (EIP-3009): each token contract accepts a (from, nonce) once,
// so the asset is part of the authorization's identity.
const asset = String(p.accepted?.asset ?? "").toLowerCase();
return `x402:${network}:${asset}:${String(auth.from).toLowerCase()}:${String(auth.nonce).toLowerCase()}`;
}
if (typeof p.payload?.transaction === "string") {
// SVM exact: hash the message the payer signed, not the whole transaction.
// The fee payer's signature slot sits outside the payer's signature, so a
// copy with different bytes there is still the same payment. The message
// already names the mint.
const tx = getTransactionDecoder().decode(getBase64Encoder().encode(p.payload.transaction));
return `x402:${network}:${createHash("sha256").update(new Uint8Array(tx.messageBytes)).digest("hex")}`;
}
} catch {
// Unparseable: fall through to the raw header, which still catches exact copies.
}
return `x402:raw:${createHash("sha256").update(header).digest("hex")}`;
}
// Keep a claim until the authorization itself expires. The signer picks the
// EIP-3009 validBefore and verification does not cap it at maxTimeoutSeconds,
// so a claim dropped earlier would let a copy of a still-valid authorization
// whose settlement failed run the handler again.
function claimUntil(header: string): number {
// Identifies a verified authorization, however its JSON is encoded, and keeps
// the claim until the authorization can no longer settle: the signer picks
// that expiry, and verification does not cap it at maxTimeoutSeconds.
function claimOf(paymentPayload, requirements): { key: string; until: number } | undefined {
const now = Math.floor(Date.now() / 1000);
let validBefore = 0;
try {
const p = JSON.parse(Buffer.from(header, "base64").toString("utf8"));
validBefore = Number(p.payload?.authorization?.validBefore) || 0;
} catch {
// No EVM expiry to honour: the floor applies.
const keep = (expiry: number) => Math.min(Math.max(now + CLAIM_MIN_S, expiry + 60), now + CLAIM_MAX_S);
const p = paymentPayload.payload;
if (p.authorization?.nonce) {
// EVM exact, EIP-3009: each token contract accepts a (from, nonce) once.
const { from, nonce, validBefore } = p.authorization;
const asset = requirements.asset.toLowerCase();
return {
key: `x402:${requirements.network}:${asset}:${from.toLowerCase()}:${nonce.toLowerCase()}`,
until: keep(Number(validBefore) || 0),
};
}
return Math.min(Math.max(now + CLAIM_MIN_S, validBefore + 60), now + CLAIM_MAX_S);
if (p.permit2Authorization?.nonce !== undefined) {
// EVM exact, Permit2: uint256 nonces per owner, across tokens. The signed
// value is BigInt(nonce), so "1", "01" and "0x1" are one authorization.
const { from, nonce, deadline } = p.permit2Authorization;
return {
key: `x402:${requirements.network}:permit2:${from.toLowerCase()}:${BigInt(nonce)}`,
until: keep(Number(deadline) || 0),
};
}
if (typeof p.transaction === "string") {
// SVM exact: hash the message the payer signed, not the whole transaction.
// The fee payer's signature slot is outside the payer's signature.
const tx = getTransactionDecoder().decode(getBase64Encoder().encode(p.transaction));
const digest = createHash("sha256").update(new Uint8Array(tx.messageBytes)).digest("hex");
return { key: `x402:${requirements.network}:${digest}`, until: now + CLAIM_MIN_S };
}
return undefined;
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
@@ -384,36 +382,51 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
]);
}
// Register on paid routes, before the x402 payment middleware.
async function replayGuard(req, res, next) {
const header = req.get("payment-signature") ?? req.get("x-payment");
if (!header) return next();
if (header.length > MAX_HEADER_BYTES) {
return res.status(431).json({ error: "payment_header_too_large" });
}
let first = true;
try {
// NX is atomic: of two simultaneous copies exactly one wins.
const claimed = redis.set(claimKey(header), "1", {
condition: "NX",
expiration: { type: "EXAT", value: claimUntil(header) },
});
first = (await withTimeout(claimed, CLAIM_TIMEOUT_MS)) === "OK";
} catch {
// Fail open: the facilitator still refuses a second settlement, so a store
// outage degrades to the unguarded behaviour instead of blocking buyers.
}
if (!first) {
return res.status(402).json({
error: "payment_replayed",
detail: "This payment authorization was already presented. Sign a fresh one for each request.",
});
}
next();
}
const resourceServer = new x402ResourceServer(
new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL }),
)
.register("eip155:8453", new ExactEvmScheme())
.onAfterVerify(async ({ paymentPayload, requirements, result }) => {
if (!result.isValid) return; // an invalid payment never takes a claim
const claim = claimOf(paymentPayload, requirements);
if (!claim) return;
try {
// NX is atomic: of two simultaneous copies exactly one wins.
const set = redis.set(claim.key, "1", {
condition: "NX",
expiration: { type: "EXAT", value: claim.until },
});
if ((await withTimeout(set, CLAIM_TIMEOUT_MS)) === "OK") return;
return {
abort: true,
reason: "payment_replayed",
message: "This payment authorization was already presented.",
};
} catch {
// Return, don't throw: the resource server logs and skips a hook that
// throws, which would run the handler unguarded.
if (FAIL_CLOSED) {
return { abort: true, reason: "claim_unavailable", message: "Retry shortly." };
}
}
});
const app = express();
app.use(express.json());
app.use(
paymentMiddleware(
{
"POST /report": {
accepts: [{ scheme: "exact", price: "$0.01", network: "eip155:8453", payTo: process.env.PAY_TO }],
},
},
resourceServer,
),
);
app.post("/report", async (req, res) => res.json(await buildReport(req.body)));
```
每个认领都是在结算器验证支付之前写入的,因此应像对待任何未经认证的写入一样对待这条路径:请求头上限限制了单个请求能存储的内容,并且该路由应处于常规速率限制之后。任何支持条件插入的存储都可以:DynamoDB 带 `attribute_not_exists` 的 `PutItem`,或 Postgres 的唯一索引。客户端的代价是,使用同一签名重试的请求(例如在 5xx 之后,结算被跳过时)现在会被拒绝;上游客户端本来就会为每个 402 签署新的授权。这只能阻止同一授权的重复副本。在验证和结算之间使支付失效的付款方(例如转走资金)仍能触发一次处理程序运行;对于高成本路由,应在执行工作前先锁定资金。
任何支持原子条件插入的存储都可以:DynamoDB 带 `attribute_not_exists` 的 `PutItem`,或 Postgres 的唯一索引。有两件事守卫不会替你决定。存储不可用时,`FAIL_CLOSED` 以可用性为代价保住“每个授权只运行一次”的保证;失败开放(fail open)能让路由继续服务,但在存储恢复之前每个副本都会运行处理程序,因此只应用于幂等的处理程序。另外,`payment_replayed` 表示该授权之前已被提交过,并不表示第一次尝试失败:如果客户端丢失了第一次的响应,工作可能已经执行、支付可能已经结算,因此在签署新授权之前应先核对原始请求的结果。这只能阻止同一授权的重复副本。在验证和结算之间使支付失效的付款方(例如转走资金)仍能触发一次处理程序运行;对于高成本路由,应在执行工作前先锁定资金。
**发现。** 实现 x402 集市扩展的结算器会公开 `/discovery/resources` 端点——可在 `https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources` 查询 CDP 目录,在 `https://facilitator.payai.network/discovery/resources` 查询 PayAI 目录。对于 Solana 可付费服务,还有 Solana 基金会的精选目录 [pay.sh](https://pay.sh)。
@@ -10,7 +10,7 @@
"id": "hand-seeded",
"source": "manual-curation-pending-regeneration"
},
"registryDigest": "846ac8826dd91e81cd838c2b55d7be536af427eb453efa127501f891e5778ff8",
"registryDigest": "cf9e89098f96034220d65245e50a5e25eb640661ba3883d31de1ce9298257649",
"schemaVersion": 1,
"triggers": {
"skill:agent-runtime-gateway-smoke-test": [
+1304
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -563,8 +563,12 @@
"@eslint/js": "9.39.2",
"@opencode-ai/plugin": "1.4.3",
"@types/node": "26.6.4",
"@x402/core": "2.28.0",
"@x402/evm": "2.28.0",
"@x402/express": "2.28.0",
"c8": "11.0.0",
"eslint": "10.11.0",
"express": "4.21.2",
"globals": "17.13.0",
"markdownlint-cli": "0.49.1",
"typescript": "6.0.3"
+90 -77
View File
@@ -308,19 +308,22 @@ This bites hardest when payouts go to freshly provisioned or custodial wallets,
**Sellers: run the route once per payment authorization.** The middleware verifies the payment, runs your handler, then settles. Verification does not consume the authorization (settlement does), so one signed payment sent several times at once passes verify every time and runs your handler every time. The facilitator settles only one copy, and the others get a 402 without the response, so nobody receives free output. But every side effect in the handler (an LLM call, a queued job, an on-chain transaction) happens once per copy. On a production seller (Python `x402` 2.9.0, FastAPI), one authorization sent three times concurrently produced three handler runs and one settlement. A paid-call counter inside the handler overstates revenue the same way; count after settlement instead. The wider verify-to-settle race is discussed in [x402#1169](https://github.com/x402-foundation/x402/issues/1169).
Claim each authorization once, with an atomic insert, before the payment middleware runs:
Claim each authorization once, after the facilitator has verified it and before the handler runs. The resource server's `onAfterVerify` hook runs at exactly that point, and an `abort` from it becomes a 402 without running the handler:
```typescript
import { createHash } from "node:crypto";
import express from "express";
import { paymentMiddleware, x402ResourceServer } from "@x402/express";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { ExactEvmScheme } from "@x402/evm/exact/server";
import { getBase64Encoder, getTransactionDecoder } from "@solana/kit";
import { createClient } from "redis";
// The guard must never be what takes the seller down. With the offline queue
// disabled, a command sent while disconnected fails at once instead of waiting;
// the queue cap does the same when the store is connected but not replying, so
// abandoned claims cannot pile up; the error listener stops a client error from
// crashing the process; and the connection is made in the background, so route
// registration never waits on it.
// The claim store must never be what takes the seller down. With the offline
// queue disabled, a command sent while disconnected fails at once; the queue
// cap does the same when the store is connected but not replying; the error
// listener stops a client error from crashing the process; and the connection
// is made in the background, so startup never waits on it.
const redis = createClient({
url: process.env.REDIS_URL,
disableOfflineQueue: true,
@@ -331,51 +334,46 @@ redis.on("error", (err) => console.warn(`replay store: ${err.message}`));
redis.connect().catch(() => {});
const CLAIM_MIN_S = 900; // longer than maxTimeoutSeconds plus your slowest handler run
const CLAIM_MAX_S = 366 * 86_400; // ceiling for a signer-chosen validBefore
const CLAIM_TIMEOUT_MS = 250; // a slow store must not stall paid requests (the command itself is not cancelled)
const MAX_HEADER_BYTES = 16_384; // claims are stored before anything is verified
const CLAIM_MAX_S = 366 * 86_400; // ceiling for a signer-chosen expiry
const CLAIM_TIMEOUT_MS = 250; // a slow store must not stall paid requests
// When the store is unavailable: refuse before the handler runs (true), or run
// it without duplicate protection (false). Fail open only if the handler is
// idempotent or has its own side-effect boundary.
const FAIL_CLOSED = true;
// One key per authorization, not per header: re-encoding the same signed
// payment must not make it look new.
function claimKey(header: string): string {
try {
const p = JSON.parse(Buffer.from(header, "base64").toString("utf8"));
const network = p.accepted?.network ?? "";
const auth = p.payload?.authorization;
if (auth?.from && auth?.nonce) {
// EVM exact (EIP-3009): each token contract accepts a (from, nonce) once,
// so the asset is part of the authorization's identity.
const asset = String(p.accepted?.asset ?? "").toLowerCase();
return `x402:${network}:${asset}:${String(auth.from).toLowerCase()}:${String(auth.nonce).toLowerCase()}`;
}
if (typeof p.payload?.transaction === "string") {
// SVM exact: hash the message the payer signed, not the whole transaction.
// The fee payer's signature slot sits outside the payer's signature, so a
// copy with different bytes there is still the same payment. The message
// already names the mint.
const tx = getTransactionDecoder().decode(getBase64Encoder().encode(p.payload.transaction));
return `x402:${network}:${createHash("sha256").update(new Uint8Array(tx.messageBytes)).digest("hex")}`;
}
} catch {
// Unparseable: fall through to the raw header, which still catches exact copies.
}
return `x402:raw:${createHash("sha256").update(header).digest("hex")}`;
}
// Keep a claim until the authorization itself expires. The signer picks the
// EIP-3009 validBefore and verification does not cap it at maxTimeoutSeconds,
// so a claim dropped earlier would let a copy of a still-valid authorization
// whose settlement failed run the handler again.
function claimUntil(header: string): number {
// Identifies a verified authorization, however its JSON is encoded, and keeps
// the claim until the authorization can no longer settle: the signer picks
// that expiry, and verification does not cap it at maxTimeoutSeconds.
function claimOf(paymentPayload, requirements): { key: string; until: number } | undefined {
const now = Math.floor(Date.now() / 1000);
let validBefore = 0;
try {
const p = JSON.parse(Buffer.from(header, "base64").toString("utf8"));
validBefore = Number(p.payload?.authorization?.validBefore) || 0;
} catch {
// No EVM expiry to honour: the floor applies.
const keep = (expiry: number) => Math.min(Math.max(now + CLAIM_MIN_S, expiry + 60), now + CLAIM_MAX_S);
const p = paymentPayload.payload;
if (p.authorization?.nonce) {
// EVM exact, EIP-3009: each token contract accepts a (from, nonce) once.
const { from, nonce, validBefore } = p.authorization;
const asset = requirements.asset.toLowerCase();
return {
key: `x402:${requirements.network}:${asset}:${from.toLowerCase()}:${nonce.toLowerCase()}`,
until: keep(Number(validBefore) || 0),
};
}
return Math.min(Math.max(now + CLAIM_MIN_S, validBefore + 60), now + CLAIM_MAX_S);
if (p.permit2Authorization?.nonce !== undefined) {
// EVM exact, Permit2: uint256 nonces per owner, across tokens. The signed
// value is BigInt(nonce), so "1", "01" and "0x1" are one authorization.
const { from, nonce, deadline } = p.permit2Authorization;
return {
key: `x402:${requirements.network}:permit2:${from.toLowerCase()}:${BigInt(nonce)}`,
until: keep(Number(deadline) || 0),
};
}
if (typeof p.transaction === "string") {
// SVM exact: hash the message the payer signed, not the whole transaction.
// The fee payer's signature slot is outside the payer's signature.
const tx = getTransactionDecoder().decode(getBase64Encoder().encode(p.transaction));
const digest = createHash("sha256").update(new Uint8Array(tx.messageBytes)).digest("hex");
return { key: `x402:${requirements.network}:${digest}`, until: now + CLAIM_MIN_S };
}
return undefined;
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
@@ -385,36 +383,51 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
]);
}
// Register on paid routes, before the x402 payment middleware.
async function replayGuard(req, res, next) {
const header = req.get("payment-signature") ?? req.get("x-payment");
if (!header) return next();
if (header.length > MAX_HEADER_BYTES) {
return res.status(431).json({ error: "payment_header_too_large" });
}
let first = true;
try {
// NX is atomic: of two simultaneous copies exactly one wins.
const claimed = redis.set(claimKey(header), "1", {
condition: "NX",
expiration: { type: "EXAT", value: claimUntil(header) },
});
first = (await withTimeout(claimed, CLAIM_TIMEOUT_MS)) === "OK";
} catch {
// Fail open: the facilitator still refuses a second settlement, so a store
// outage degrades to the unguarded behaviour instead of blocking buyers.
}
if (!first) {
return res.status(402).json({
error: "payment_replayed",
detail: "This payment authorization was already presented. Sign a fresh one for each request.",
});
}
next();
}
const resourceServer = new x402ResourceServer(
new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL }),
)
.register("eip155:8453", new ExactEvmScheme())
.onAfterVerify(async ({ paymentPayload, requirements, result }) => {
if (!result.isValid) return; // an invalid payment never takes a claim
const claim = claimOf(paymentPayload, requirements);
if (!claim) return;
try {
// NX is atomic: of two simultaneous copies exactly one wins.
const set = redis.set(claim.key, "1", {
condition: "NX",
expiration: { type: "EXAT", value: claim.until },
});
if ((await withTimeout(set, CLAIM_TIMEOUT_MS)) === "OK") return;
return {
abort: true,
reason: "payment_replayed",
message: "This payment authorization was already presented.",
};
} catch {
// Return, don't throw: the resource server logs and skips a hook that
// throws, which would run the handler unguarded.
if (FAIL_CLOSED) {
return { abort: true, reason: "claim_unavailable", message: "Retry shortly." };
}
}
});
const app = express();
app.use(express.json());
app.use(
paymentMiddleware(
{
"POST /report": {
accepts: [{ scheme: "exact", price: "$0.01", network: "eip155:8453", payTo: process.env.PAY_TO }],
},
},
resourceServer,
),
);
app.post("/report", async (req, res) => res.json(await buildReport(req.body)));
```
Every claim is written before the facilitator has verified the payment, so treat this path like any unauthenticated write: the header cap bounds what one request can store, and the route should sit behind your normal rate limits. Any store with a conditional insert works: DynamoDB `PutItem` with `attribute_not_exists`, or a unique index in Postgres. The client-side cost is that a request retried with the same signature (for example after a 5xx, when settlement was skipped) is now refused; the upstream clients already sign a fresh authorization for each 402. This closes duplicate copies of one authorization only. A payer who invalidates the payment between verify and settle (for example by moving the funds) still gets one handler run; for expensive routes, hold funds before doing the work.
Any store with an atomic conditional insert works: DynamoDB `PutItem` with `attribute_not_exists`, or a unique index in Postgres. Two things the guard does not decide for you. If the store is down, `FAIL_CLOSED` keeps the once-per-authorization guarantee at the cost of availability; failing open keeps the route up but lets every copy run the handler until the store recovers, so use it only for idempotent handlers. And `payment_replayed` means the authorization was presented before, not that the first attempt failed: if the client lost the first response, the work may have run and the payment may have settled, so it should reconcile the original request before signing a new authorization. This closes duplicate copies of one authorization only. A payer who invalidates the payment between verify and settle (for example by moving the funds) still gets one handler run; for expensive routes, hold funds before doing the work.
**Discovery.** Facilitators that implement the x402 bazaar extension expose a `/discovery/resources` endpoint — query the CDP catalog at `https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources` and the PayAI catalog at `https://facilitator.payai.network/discovery/resources`. For Solana-payable services there is also [pay.sh](https://pay.sh), the Solana Foundation's curated catalog.
+168 -92
View File
@@ -1,46 +1,92 @@
'use strict';
// Execute the canonical and translated seller replay guard offline. Redis and
// @solana/kit are replaced with in-memory stubs; node:crypto is real. No
// network, key, or real payment is used.
// Run the canonical and translated seller replay guard against the real x402
// Express middleware and resource server, so verification, the onAfterVerify
// hook, the handler and settlement happen in their real order. Payments are
// signed by the real @x402/evm client with a throwaway key; the facilitator is
// a local stand-in that checks the EIP-3009 signature with viem and settles
// each nonce once. Redis and @solana/kit are stubbed. No network or funds.
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const vm = require('node:vm');
const ts = require('typescript');
const express = require('express');
const x402Express = require('@x402/express');
const coreServer = require('@x402/core/server');
const { x402Client, x402HTTPClient } = require('@x402/core/client');
const { ExactEvmScheme: ExactEvmServer } = require('@x402/evm/exact/server');
const { ExactEvmScheme: ExactEvmClient } = require('@x402/evm/exact/client');
const { toClientEvmSigner } = require('@x402/evm');
const viemFromEvm = createRequire(require.resolve('@x402/evm'));
const { verifyTypedData } = viemFromEvm('viem');
const { privateKeyToAccount, generatePrivateKey } = viemFromEvm('viem/accounts');
const ROOT = path.resolve(__dirname, '../..');
const DOCS = [
'skills/agent-payment-x402/SKILL.md',
'docs/ja-JP/skills/agent-payment-x402/SKILL.md',
'docs/zh-CN/skills/agent-payment-x402/SKILL.md',
];
const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const NOW_S = 1_800_000_000; // fixed clock for the guard's expiry arithmetic
const PAY_TO = '0x000000000000000000000000000000000000dEaD';
// Wire format: compact-u16 signature count (one byte below 128), 64 bytes per
// signature, then the message. Enough of a decoder for the stubbed kit.
const kit = {
getBase64Encoder: () => ({ encode: text => new Uint8Array(Buffer.from(text, 'base64')) }),
getTransactionDecoder: () => ({
decode: bytes => ({ messageBytes: bytes.slice(1 + 64 * bytes[0]) }),
}),
getTransactionDecoder: () => ({ decode: bytes => ({ messageBytes: bytes.slice(1 + 64 * bytes[0]) }) }),
};
function harness(doc, mode = 'ok') {
// Stand-in facilitator: real signature check, each nonce settles once.
class Facilitator {
constructor() { this.settles = 0; this.consumed = new Set(); }
async getSupported() {
return { kinds: [{ x402Version: 2, scheme: 'exact', network: 'eip155:8453' }], extensions: [], signers: {} };
}
async verify(payload, req) {
const a = payload.payload.authorization;
const valid = await verifyTypedData({
address: a.from,
domain: { name: req.extra.name, version: req.extra.version, chainId: 8453, verifyingContract: req.asset },
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: { ...a, value: BigInt(a.value), validAfter: BigInt(a.validAfter), validBefore: BigInt(a.validBefore) },
signature: payload.payload.signature,
}).catch(() => false);
if (!valid) return { isValid: false, invalidReason: 'invalid_exact_evm_payload_signature', payer: a.from };
if (this.consumed.has(a.nonce)) return { isValid: false, invalidReason: 'invalid_exact_evm_payload_authorization_nonce_used', payer: a.from };
return { isValid: true, payer: a.from };
}
async settle(payload) {
this.settles++;
const { nonce, from } = payload.payload.authorization;
if (this.consumed.has(nonce)) return { success: false, errorReason: 'nonce_used', transaction: '', network: 'eip155:8453', payer: from };
this.consumed.add(nonce);
return { success: true, transaction: `0x${'ab'.repeat(32)}`, network: 'eip155:8453', payer: from };
}
}
function load(doc, { mode = 'ok', failClosed = true } = {}) {
const text = fs.readFileSync(path.join(ROOT, doc), 'utf8');
const blocks = [...text.matchAll(/```typescript\n([\s\S]*?)\n```/g)];
const block = blocks.find(match => match[1].includes('async function replayGuard('));
const block = blocks.find(match => match[1].includes('.onAfterVerify('));
assert.ok(block, 'document must retain a runnable seller replay guard');
let source = block[1];
if (!failClosed) source = source.replace('const FAIL_CLOSED = true;', 'const FAIL_CLOSED = false;');
const facilitator = new Facilitator();
const store = new Set();
const redis = {
options: null, listeners: {}, sets: 0, badOptions: 0, lastUntil: 0,
options: null, listeners: {}, sets: [],
on(event, fn) { this.listeners[event] = fn; return this; },
connect: () => Promise.reject(new Error('ECONNREFUSED')),
async set(key, value, opts) {
this.sets++;
// Recorded, not asserted: the guard's own fail-open catch would hide a throw.
if (opts.condition !== 'NX' || opts.expiration?.type !== 'EXAT') this.badOptions++;
this.lastUntil = opts.expiration?.value;
this.sets.push({ key, opts });
if (mode === 'offline') throw new Error('The client is offline');
if (mode === 'hang') return new Promise(() => {});
if (store.has(key)) return null;
@@ -50,108 +96,138 @@ function harness(doc, mode = 'ok') {
};
const modules = {
'node:crypto': crypto,
express,
'@x402/express': x402Express,
'@x402/core/server': { ...coreServer, HTTPFacilitatorClient: function HTTPFacilitatorClient() { return facilitator; } },
'@x402/evm/exact/server': { ExactEvmScheme: ExactEvmServer },
'@solana/kit': kit,
redis: { createClient: options => { redis.options = options; return redis; } },
};
const source = `${block[1]}\nmodule.exports = { claimKey, replayGuard };`;
const compiled = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
const compiled = ts.transpileModule(`${source}\nmodule.exports = { app, claimOf };`, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
reportDiagnostics: true,
});
assert.equal((compiled.diagnostics || []).length, 0);
const runs = [];
class FixedDate extends Date { static now() { return NOW_S * 1000; } }
const context = vm.createContext({
require: name => modules[name], module: { exports: {} }, exports: {},
Buffer, Promise, setTimeout, console: { warn() {} }, process: { env: {} },
Buffer, Promise, setTimeout, Date: FixedDate, console: { warn() {} },
process: { env: { REDIS_URL: 'redis://stub', FACILITATOR_URL: 'http://stub', PAY_TO } },
// A handler that takes time, like the expensive ones the guard is for, so
// concurrent copies are all verified while the first one is still running.
buildReport: async () => { runs.push(1); await new Promise(r => setTimeout(r, 150)); return { report: 'ok' }; },
});
new vm.Script(compiled.outputText, { filename: doc }).runInContext(context);
const call = async header => {
const out = { status: 0, body: null, ran: false };
const res = {
status(code) { out.status = code; return this; },
json(body) { out.body = body; return this; },
};
const req = { get: name => (name === 'payment-signature' ? header : undefined) };
await context.module.exports.replayGuard(req, res, () => { out.ran = true; });
return out;
};
return { ...context.module.exports, call, redis };
return { ...context.module.exports, facilitator, redis, runs };
}
const b64 = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64');
function evm(nonce, { asset = USDC_BASE, indent, validBefore } = {}) {
return b64(JSON.stringify({
x402Version: 2,
accepted: { scheme: 'exact', network: 'eip155:8453', asset },
payload: { signature: '0x00', authorization: { from: '0xAbC0000000000000000000000000000000000001', nonce, validBefore } },
}, null, indent));
}
function svm(feeSlotByte, message = 'payer-signed-message') {
const tx = Buffer.concat([Buffer.from([2]), Buffer.alloc(64, feeSlotByte), Buffer.alloc(64, 7), Buffer.from(message)]);
return b64({ x402Version: 2, accepted: { network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' },
payload: { transaction: tx.toString('base64') } });
async function serve(h) {
const server = await new Promise(resolve => { const s = h.app.listen(0, () => resolve(s)); });
const url = `http://127.0.0.1:${server.address().port}/report`;
const client = new x402HTTPClient(new x402Client().register('eip155:8453',
new ExactEvmClient(toClientEvmSigner(privateKeyToAccount(generatePrivateKey())))));
const post = headers => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: '{}' });
const sign = async () => {
const challenge = await post({});
const required = client.getPaymentRequiredResponse(name => challenge.headers.get(name), await challenge.json().catch(() => undefined));
return client.encodePaymentSignatureHeader(await client.createPaymentPayload(required));
};
const reason = res => {
const header = res.headers.get('payment-required');
return header ? JSON.parse(Buffer.from(header, 'base64').toString()).error : undefined;
};
return { post, sign, reason, close: () => new Promise(resolve => server.close(resolve)) };
}
const b64 = value => Buffer.from(JSON.stringify(value)).toString('base64');
const evmPayload = (nonce, validBefore, from = '0xAbC0000000000000000000000000000000000001') =>
({ payload: { signature: '0x00', authorization: { from, nonce, validBefore: String(validBefore) } } });
const svmPayload = feeSlot => ({ payload: { transaction: Buffer.concat([
Buffer.from([2]), Buffer.alloc(64, feeSlot), Buffer.alloc(64, 7), Buffer.from('payer-signed-message')]).toString('base64') } });
const REQ = { network: 'eip155:8453', asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' };
const tests = [];
for (const doc of DOCS) {
const add = (name, test) => tests.push([`${doc}: ${name}`, test]);
add('runs the route once for three concurrent copies of one authorization', async () => {
const h = harness(doc);
const results = await Promise.all([h.call(evm('0x01')), h.call(evm('0x01')), h.call(evm('0x01'))]);
assert.equal(results.filter(r => r.ran).length, 1);
const refused = results.filter(r => r.status === 402);
assert.equal(refused.length, 2);
for (const r of refused) assert.equal(r.body.error, 'payment_replayed');
assert.equal(h.redis.badOptions, 0, 'claims must use SET NX with an absolute expiry');
add('three concurrent copies of one signed payment run the handler once', async () => {
const h = load(doc);
const s = await serve(h);
try {
const header = await s.sign();
const results = await Promise.all([1, 2, 3].map(() => s.post(header)));
assert.equal(h.runs.length, 1);
assert.equal(h.facilitator.settles, 1);
const refused = results.filter(r => r.status === 402);
assert.equal(refused.length, 2);
for (const r of refused) assert.equal(s.reason(r), 'payment_replayed');
for (const { opts } of h.redis.sets) {
assert.equal(opts.condition, 'NX');
assert.equal(opts.expiration.type, 'EXAT');
assert.equal(opts.expiration.value, NOW_S + 900);
}
} finally { await s.close(); }
});
add('keys EVM payments on asset, payer and nonce, not header bytes', async () => {
const h = harness(doc);
assert.equal(h.claimKey(evm('0x01')), h.claimKey(evm('0x01', { indent: 2 })));
assert.notEqual(h.claimKey(evm('0x01')), h.claimKey(evm('0x02')));
assert.notEqual(h.claimKey(evm('0x01')), h.claimKey(evm('0x01', { asset: '0x0000000000000000000000000000000000000001' })));
add('an invalid signature takes no claim; the valid payment is then served', async () => {
const h = load(doc);
const s = await serve(h);
try {
const header = await s.sign();
const payload = JSON.parse(Buffer.from(header['PAYMENT-SIGNATURE'], 'base64').toString());
const forged = { ...payload, payload: { ...payload.payload, signature: `${payload.payload.signature.slice(0, -4)}0000` } };
const bad = await s.post({ 'PAYMENT-SIGNATURE': b64(forged) });
assert.equal(bad.status, 402);
assert.equal(h.redis.sets.length, 0, 'an unverified payment must not be claimed');
assert.equal((await s.post(header)).status, 200);
assert.equal(h.runs.length, 1);
} finally { await s.close(); }
});
add('keys Solana payments on the signed message, ignoring the fee-payer slot', async () => {
const h = harness(doc);
assert.equal(h.claimKey(svm(0)), h.claimKey(svm(9)));
assert.notEqual(h.claimKey(svm(0)), h.claimKey(svm(0, 'another-message')));
assert.match(h.claimKey('not base64 json'), /^x402:raw:/);
add('fails closed when the store is offline or hangs: no handler run, no settlement', async () => {
for (const mode of ['offline', 'hang']) {
const h = load(doc, { mode });
const s = await serve(h);
try {
const started = Date.now();
const res = await s.post(await s.sign());
assert.equal(res.status, 402, mode);
assert.equal(s.reason(res), 'claim_unavailable', mode);
assert.equal(h.runs.length, 0, mode);
assert.equal(h.facilitator.settles, 0, mode);
assert.ok(Date.now() - started < 2000, `${mode}: the claim timeout bounds the wait`);
} finally { await s.close(); }
}
});
add('loads without waiting on the store and never crashes on client errors', async () => {
const h = harness(doc);
add('failing open is an explicit choice that gives up duplicate protection', async () => {
const h = load(doc, { mode: 'offline', failClosed: false });
const s = await serve(h);
try {
const header = await s.sign();
await Promise.all([1, 2].map(() => s.post(header)));
assert.equal(h.runs.length, 2);
} finally { await s.close(); }
});
add('client starts without waiting on the store and cannot be crashed by its errors', async () => {
const h = load(doc);
assert.equal(h.redis.options.disableOfflineQueue, true);
assert.ok(h.redis.options.commandsQueueMaxLength > 0, 'pending claims must be bounded');
assert.ok(h.redis.options.commandsQueueMaxLength > 0);
assert.equal(typeof h.redis.listeners.error, 'function');
h.redis.listeners.error(new Error('socket closed'));
});
add('keeps a claim until the signed authorization expires', async () => {
const h = harness(doc);
const now = () => Math.floor(Date.now() / 1000);
await h.call(evm('0x31', { validBefore: String(now() + 3600) }));
assert.ok(Math.abs(h.redis.lastUntil - (now() + 3660)) <= 2, `1 h authorization: ${h.redis.lastUntil - now()} s`);
await h.call(evm('0x32', { validBefore: String(now() + 60) }));
assert.ok(Math.abs(h.redis.lastUntil - (now() + 900)) <= 2, `short authorization keeps the floor: ${h.redis.lastUntil - now()} s`);
await h.call(evm('0x33', { validBefore: (2n ** 256n).toString() }));
assert.ok(Math.abs(h.redis.lastUntil - (now() + 366 * 86400)) <= 2, 'absurd validBefore is capped');
await h.call(svm(3));
assert.ok(Math.abs(h.redis.lastUntil - (now() + 900)) <= 2, 'Solana uses the floor');
});
add('fails open when the store is offline', async () => {
const h = harness(doc, 'offline');
const [a, b] = [await h.call(evm('0x01')), await h.call(evm('0x01'))];
assert.ok(a.ran && b.ran);
});
add('fails open when the store hangs, within the claim timeout', async () => {
const h = harness(doc, 'hang');
const started = Date.now();
assert.ok((await h.call(evm('0x01'))).ran);
assert.ok(Date.now() - started < 1000);
});
add('stores nothing for oversized or missing headers', async () => {
const h = harness(doc);
const big = await h.call('A'.repeat(20_000));
assert.equal(big.status, 431);
assert.equal(big.ran, false);
assert.ok((await h.call(undefined)).ran);
assert.equal(h.redis.sets, 0);
add('claim keys identify the authorization and last until it expires', async () => {
const { claimOf } = load(doc);
const a = claimOf(evmPayload('0xAA', NOW_S + 3600), REQ);
assert.equal(a.key, claimOf(evmPayload('0xaa', NOW_S + 3600, '0xabc0000000000000000000000000000000000001'), REQ).key);
assert.notEqual(a.key, claimOf(evmPayload('0xAB', NOW_S + 3600), REQ).key);
assert.notEqual(a.key, claimOf(evmPayload('0xAA', NOW_S + 3600), { ...REQ, asset: '0x0000000000000000000000000000000000000001' }).key);
assert.equal(a.until, NOW_S + 3660);
assert.equal(claimOf(evmPayload('0xAC', NOW_S + 60), REQ).until, NOW_S + 900);
assert.equal(claimOf(evmPayload('0xAD', (2n ** 256n).toString()), REQ).until, NOW_S + 366 * 86400);
const permit2 = nonce => ({ payload: { permit2Authorization: { from: '0xAbC0000000000000000000000000000000000001', nonce, deadline: String(NOW_S + 3600) } } });
assert.equal(claimOf(permit2('1'), REQ).key, claimOf(permit2('0x1'), REQ).key);
assert.equal(claimOf(permit2('1'), REQ).key, claimOf(permit2('01'), REQ).key);
assert.equal(claimOf(permit2('1'), REQ).until, NOW_S + 3660);
assert.equal(claimOf(svmPayload(0), REQ).key, claimOf(svmPayload(9), REQ).key);
assert.equal(claimOf(svmPayload(0), REQ).until, NOW_S + 900);
});
}
+931 -2
View File
File diff suppressed because it is too large. Load diff