diff --git a/docs/ja-JP/skills/agent-payment-x402/SKILL.md b/docs/ja-JP/skills/agent-payment-x402/SKILL.md index 20a09d917..84d762311 100644 --- a/docs/ja-JP/skills/agent-payment-x402/SKILL.md +++ b/docs/ja-JP/skills/agent-payment-x402/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-payment-x402 -description: タスクごとのバジェット、支出コントロール、ノンカストディアルウォレットを備えた x402 決済実行を AI エージェントに追加します。agentwallet-sdk を通じて Base をサポートし、OKX Payments / OKX エージェント決済プロトコルを通じて X Layer をサポートします。 +description: タスクごとのバジェット、支出コントロール、ノンカストディアルウォレットを備えた x402 決済実行を AI エージェントに追加します。agentwallet-sdk を通じて Base を、OKX Payments / OKX エージェント決済プロトコルを通じて X Layer を、アップストリームの x402 パッケージとファシリテーターベースの決済を通じて Solana とマルチネットワーク EVM をサポートします。 origin: community --- @@ -20,6 +20,8 @@ origin: community |------|------------------| | エージェントが Base または他の agentwallet 対応チェーンの 402 ゲート API に支払う | 厳格な支出ポリシーで `agentwallet-sdk` を MCP 決済サーバーとして使用 | | エージェントが X Layer の 402 ゲート API に支払う | `okx/onchainos-skills` の OKX エージェント決済プロトコルを使用;`okx-x402-payment` は廃止されたレガシーエイリアス | +| エージェントが Solana または他の x402 v2 ネットワークの 402 ゲート API に支払う | エージェントの HTTP クライアントをアップストリームの `@x402/fetch` または `@x402/axios` パッケージでラップし、EVM/SVM スキームを登録する;リソースサーバーのファシリテーターが検証・決済する | +| API が Solana または複数ネットワークでエージェントに課金する(TypeScript、Python、または Go) | `x402-foundation/x402` のアップストリーム x402 ミドルウェアを使用 — TypeScript は `@x402/express`、`@x402/hono`、`@x402/next`、または `@x402/fastify`、Python は `x402`、Go は `github.com/x402-foundation/x402/go/v2` | | TypeScript API がエージェントに課金する | Express、Hono、Fastify、または Next.js 向け OKX Payments TypeScript セラー SDK ドキュメントを使用 | | Go API がエージェントに課金する | Gin、Echo、または `net/http` 向け OKX Payments Go セラー SDK ドキュメントを使用 | | Rust API がエージェントに課金する | Axum 向け OKX Payments Rust セラー SDK ドキュメントを使用 | @@ -30,6 +32,7 @@ origin: community - `agentwallet-sdk`: 本番使用前に現在のネットワークカバレッジをパッケージドキュメントで確認。Base Sepolia が最も安全な開発デフォルト;Base メインネットがオリジナルスキルで説明されている本番パス。 - OKX Payments / X Layer: 現在のセラードキュメントは X Layer(`eip155:196`)と USDT0 決済を対象。決済パッケージとファシリテーターの動作が迅速に変わる可能性があるため、本番コードを生成する前に現在の SDK ドキュメントを取得すること。 +- アップストリーム x402 パッケージ: 設計上マルチネットワーク — 1 つのルートで Base と Solana を同時に提示し、買い手に選ばせることができる。パッケージのデフォルトは `x402.org` ファシリテーターで、テストネット専用(Base Sepolia `eip155:84532`、Solana デブネット `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`、加えて Stellar、Aptos、Hedera、XRPL の各テストネット)であり、メインネットルート向けではない。メインネット(Solana は `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`)では、自己ファシリテートするか、自前のファシリテーターを運用するか、アップストリームのファシリテーターリストからホスト型を選ぶ — オプション C のファシリテーター比較を参照。本番前にここにハードコードするのではなく、ファシリテーターの `/supported` エンドポイントでライブカバレッジを確認すること。 ## 仕組み @@ -98,6 +101,212 @@ X Layer x402、マルチパーティ決済(MPP)、セッション決済、 現在の OKX リポジトリを確認せずに古いドキュメントの例をコピーしないこと。現在の OKX ガイダンスはディスパッチャーとして `okx-agent-payments-protocol` を使用しており、Java セラードキュメントが利用可能になっています。 +### オプション C: アップストリーム x402 パッケージ(Solana + Base/EVM) + +エージェントが Solana、Base、またはアップストリームのプロトコル実装がサポートする他のネットワークで支払う(または API が課金する)場合にこのパスを使用します。[`x402-foundation/x402`](https://github.com/x402-foundation/x402) の正規 x402 モノレポは活発にメンテナンスされており、クライアントとミドルウェアのパッケージを直接公開しています。オプション A・B と異なり、これは別個の MCP サーバーではありません — エージェント自身の HTTP クライアントをラップし、リソースサーバーが選んだファシリテーターが検証・決済します。 + +バイヤー側エージェントフローの場合: + +1. 古いドキュメントのスニペットをコピーするのではなく、メンテナンスされている [`examples/typescript/clients`](https://github.com/x402-foundation/x402/tree/main/examples/typescript/clients)(fetch、axios、MCP)の例から始める。 +2. オプション B が OKX フローに要求するのとまったく同様に、最初の有料リクエストの署名または送信の前に明示的なユーザー確認を求める。汎用ツール呼び出しの背後に決済実行を隠さない。 +3. パッケージバージョンを固定する(例:`@x402/fetch@2.22.0`);アップストリームの全パッケージはロックステップでバージョニングされる。 +4. バジェットはクライアントに登録した `PaymentPolicy` で強制し、毎回サーバーの実際のチャレンジに対して検査が走るようにする。自分で渡した数値と突き合わせるバジェットは何も証明しない — 金額・アセット・ネットワークはすべてサーバー由来なので、署名が生成される前に 3 つとも検証すること。 + +5. 受取先と予算のポリシーとは別に、許可するリソースのオリジンを正確に設定する。以下の支払い対応 fetch はすべてリダイレクトを拒否する。セッションの確認には許可オリジン、ネットワーク、資産、受取先、人が読める単位の支出上限を明示し、並行呼び出しは同じ確認結果を共有する。クライアントとポリシーはオーケストレーター内に留める。 + +```typescript +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { ExactSvmScheme } from "@x402/svm/exact/client"; +import { privateKeyToAccount } from "viem/accounts"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { base58 } from "@scure/base"; + +// Signer keys belong to the ORCHESTRATOR's env — never hardcoded, never agent-writable. +const evmKey = process.env.EVM_PRIVATE_KEY as `0x${string}`; +const svmKey = process.env.SVM_PRIVATE_KEY; +if (!evmKey || !svmKey) { + throw new Error("Signer keys are not set — refusing to start payment client"); +} + +// One client, both network families: the buyer pays whichever chain the 402 offers. +const client = new x402Client(); +client.register("eip155:*", new ExactEvmScheme(privateKeyToAccount(evmKey))); +client.register("solana:*", new ExactSvmScheme(await createKeyPairSignerFromBytes(base58.decode(svmKey)))); + +// A PaymentPolicy filters the SERVER's payment requirements before any +// signature is created. Returning an empty array means "nothing here is +// acceptable" and the client refuses to pay rather than falling back. +const ALLOWED_NETWORKS = new Set([ + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", // Solana mainnet + "eip155:8453", // Base +]); +const MAX_AMOUNT = 10_000n; // atomic units, 6-decimal USDC: 0.01 USDC per call +const SESSION_CAP = 50_000n; // 0.05 USDC across the whole session + +// EVM addresses are case-insensitive, so compare them lowercased. Solana +// addresses are base58 and ARE case-sensitive — never lowercase those, or a +// different account could slip through. +const normalizeAddress = (a: string) => (a.startsWith("0x") ? a.toLowerCase() : a); +const ALLOWED_ASSETS = new Set( + [ + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC, Solana mainnet + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC, Base + ].map(normalizeAddress), +); +// Who you are willing to pay. Without this, any 402 an agent happens to hit +// can name its own recipient. +const ALLOWED_PAY_TO = new Set( + ["7pr7NCaQRz5PEhPy7BAeB3Z72TVkiShhjRyVCN5DA6yC"].map(normalizeAddress), +); + +client.registerPolicy((_x402Version, requirements) => + requirements.filter(r => { + if (!ALLOWED_NETWORKS.has(r.network)) return false; // wrong chain + if (!ALLOWED_ASSETS.has(normalizeAddress(r.asset))) return false; // wrong token + if (!ALLOWED_PAY_TO.has(normalizeAddress(r.payTo))) return false; // wrong recipient + try { + const amount = BigInt(r.amount); + return amount >= 0n && amount <= MAX_AMOUNT; // over budget / negative + } catch { + return false; // unparseable amount + } + }), +); + +// The policy sees one challenge at a time, so it cannot enforce a session +// total or ask a human anything. Keep the payment-enabled client private and +// route every paid call through the payOnce boundary below. + +// Supply this from your harness — a real prompt, never a stub that returns true. +declare function confirmWithUser(prompt: string): Promise; + +// Resource authorization is independent of recipient and budget policy. +// The ORCHESTRATOR supplies exact approved HTTPS origins before delegation; +// never populate this set from a server challenge or agent-controlled input. +const ALLOWED_ORIGINS = new Set(["https://api.example.com"]); +function requireAllowedOrigin(url: string): void { + const parsed = new URL(url); + if (!ALLOWED_ORIGINS.has(parsed.origin) || parsed.username || parsed.password) { + throw new Error("Resource origin is not authorized"); + } +} + +// Both approved assets above are 6-decimal USDC. Update this trusted formatting +// policy along with the asset allowlist if you support other assets/decimals. +const USDC_SCALE = 1_000_000n; +const humanUSDC = (amount: bigint) => + `${amount / USDC_SCALE}.${(amount % USDC_SCALE).toString().padStart(6, "0").replace(/0+$/, "") || "0"} USDC`; + +let sessionSpent = 0n; +let sessionApproval: Promise | undefined; + +async function payOnce(url: string, init?: RequestInit): Promise { + requireAllowedOrigin(url); // Before reservation, prompt, fetch, or signature. + // Reserve the worst case the policy allows. Settlement responses do not + // carry an amount, so counting MAX_AMOUNT per call is a deliberate + // over-estimate — it can stop early, never late. Reserve before any await, + // so concurrent calls cannot all pass the check. + if (sessionSpent + MAX_AMOUNT > SESSION_CAP) { + throw new Error("Session budget exhausted — blocked"); + } + sessionSpent += MAX_AMOUNT; + // Release the reservation only when no signed payment left this process: + // a declined or failed prompt, a challenge the policy rejected, or a free + // response. Once a signed request is sent it may settle, so keep it counted. + let signedRequestSent = false; + const trackingFetch: typeof fetch = async (input, reqInit) => { + // Override caller options on BOTH the challenge and signed retry. Native + // fetch refuses redirects, so an unauthorized host cannot return a 402 + // or receive a payment header via an automatic redirect. + const req = new Request(input, { ...reqInit, redirect: "error" }); + requireAllowedOrigin(req.url); + if (req.headers.has("PAYMENT-SIGNATURE") || req.headers.has("X-PAYMENT")) { + signedRequestSent = true; + } + const response = await fetch(req); + // Also fail closed for adapters that expose a redirect response instead. + if (response.redirected || (response.status >= 300 && response.status < 400)) { + throw new Error("Paid request redirects are blocked"); + } + return response; + }; + try { + // Install one promise before awaiting the prompt. Concurrent calls share + // the same decision, including a decline or failure; never auto-reprompt. + sessionApproval ??= Promise.resolve().then(() => confirmWithUser( + `Allow paid requests to origins: ${[...ALLOWED_ORIGINS].join(", ")}? ` + + `Networks: ${[...ALLOWED_NETWORKS].join(", ")}. ` + + `Assets: ${[...ALLOWED_ASSETS].join(", ")} (6-decimal USDC). ` + + `Recipients (payTo): ${[...ALLOWED_PAY_TO].join(", ")}. ` + + `Per-call cap: ${humanUSDC(MAX_AMOUNT)}; session cap: ${humanUSDC(SESSION_CAP)}.`, + )); + if (!await sessionApproval) { + throw new Error("User declined: no payment attempted"); + } + return await wrapFetchWithPayment(trackingFetch, client)(url, init); + } finally { + if (!signedRequestSent) sessionSpent -= MAX_AMOUNT; + } +} + +const res = await payOnce("https://api.example.com/data", { method: "GET" }); +``` + +ポリシーを登録すると、予算超過の金額、想定外のトークン、未登録のチェーンはすべてフェイルクローズドになる — 候補がすべて除外されるため、`createPaymentPayload` は署名せずに例外を投げる。デブネット USDC は `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`;開発時のみ `ALLOWED_ASSETS` に追加すること。ポリシーは敵対的にテストする — `5000000` アトミック単位を要求するチャレンジ、別のミントを提示するチャレンジ、登録していないチェーンのチャレンジを与え、いずれも署名が生成されないことを確認する。exact-SVM スキームではファシリテーターがトランザクション手数料支払者となるため、買い手のウォレットは USDC のみを保有すればよい — ガス用の SOL は不要。 + +**ファシリテーターの選択。** ファシリテーターはリソースサーバーに代わって検証と決済を行うため、これは買い手ではなくリソースサーバーの判断である。委ねる信頼が小さい順に: + +| 選択肢 | 適する場面 | +|--------|--------------| +| [インプロセスで自己ファシリテート](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/self-facilitation) | 決済経路に第三者を入れたくなく、鍵と RPC アクセスを自前で保持できる | +| 自前のファシリテーターを運用 | 同じ制御を複数サービスで共有したい | +| [`x402.org` ファシリテーター](https://x402.org/facilitator) | 開発・テストネット向け — パッケージのデフォルトでセットアップ不要。アップストリームはメインネットルート向けではないと明記している | +| ホスト型の本番ファシリテーター | インフラを運用せずにメインネット対応が欲しい | + +ホスト型を選ぶ場合は、ここに書かれた名前ではなくアップストリームドキュメントの[ファシリテーターリスト](https://docs.x402.org/dev-tools/facilitators)から選ぶこと — メンテナンスされており、網羅的ではなく、カバレッジも変わる。執筆時点では Coinbase の CDP(全トランザクションに KYT/OFAC スクリーニング)、PayAI、Corbits、Dexter、Solvador などが掲載され、複数が EVM に加えて Solana メインネットをカバーしている。どれを選ぶ場合も、本番前に `/supported` エンドポイントでライブカバレッジを確認し、ネットワークを追加したときは再確認すること。 + +> **開示**: このセクションは、掲載されているファシリテーターの 1 つである PayAI に関わる者が寄稿した。複数ある選択肢の 1 つとして記載しており、上記の自己ホストおよびアップストリームデフォルトのパスを意図的に先に挙げている。 + +**セラー側(API がエージェントに課金する)。** アップストリームのミドルウェアを使用する;1 つのルートで Base と Solana を同時に提示できる(実行可能なバージョンは [`examples/typescript/servers`](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers) を参照): + +| ランタイム | パッケージ | +|---------|---------| +| Express / Hono / Next.js / Fastify | `@x402/express@2.22.0`、`@x402/hono@2.22.0`、`@x402/next@2.22.0`、`@x402/fastify@2.22.0` | +| Python(FastAPI、Flask) | PyPI の `x402` | +| Go(Gin、Echo、`net/http`) | `github.com/x402-foundation/x402/go/v2` | + +**Solana のセラー:`payTo` アドレスには先に正規のトークンアカウントが必要。** exact-SVM クライアントは `findAssociatedTokenPda` で `payTo` の*関連トークンアカウント*(ATA)を導出してそこに送金するが、それを作成はしない。その正確なアカウントが存在しないと決済はシミュレーション段階で失敗し、402 が `transaction_simulation_failed` を返す — クライアントのバグのように見えるが、実際は受取側アカウントの欠落である。導出したアドレス自体を確認すること — オーナーのトークンアカウントを走査するのは等価ではない。同じミントの非正規な補助アカウントがあると検査は通るのに、クライアントが対象とする ATA は依然として存在しないからだ: + +```typescript +import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from "@solana-program/token"; + +const [ata] = await findAssociatedTokenPda({ + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC mainnet + owner: payToAddress, + tokenProgram: TOKEN_PROGRAM_ADDRESS, // TOKEN_2022_PROGRAM_ADDRESS for Token-2022 mints +}); +``` + +次にそのアドレスが実在することを確認する — `value: null` なら存在せず、そこへの決済は失敗する: + +```bash +curl -s https://api.mainnet-beta.solana.com -X POST -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getAccountInfo", + "params":["",{"encoding":"base64"}]}' \ + | jq '.result.value != null' +``` + +作成方法(作成者が少額のレントを負担し、支払者ではない): + +- コード上では `@solana-program/token` の `getCreateAssociatedTokenIdempotentInstruction` をオンボーディングフローに追加する — 冪等版なので再実行しても安全で、決定的に動作する唯一の方法。 +- spl-token CLI で `spl-token create-account --owner `。 +- そのトークンを `payTo` に送金する方法も使えるが、送金側が作成インストラクションを含む場合に限る — ウォレットや `spl-token transfer --fund-recipient` は含める。ATA が無い状態への素の `transferChecked` は、決済と同じように失敗する。 + +これは新規プロビジョニングされたウォレットやカストディアルウォレットへの支払いで最も問題になりやすい。そうしたウォレットは対象アセットの ATA をまだ持っていないことが多い。 + +**発見。** 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) もある。 + ## 例 ### MCP クライアントでのバジェット強制 @@ -212,7 +421,8 @@ main().catch((err) => { - **監査証跡**: タスク後のフックで `list_transactions` を使用して何が使われたかをログに記録する。 - **フェイルクローズド**: 決済ツールに到達できない場合、有料アクションをブロックする — 課金されないアクセスにフォールバックしない。 - **security-review と組み合わせる**: 決済ツールは高い権限を持つ。シェルアクセスと同じ精査を適用する。 -- **まずテストネットでテストする**: 開発には Base Sepolia を使用;本番には Base メインネットに切り替える。 +- **まずテストネットでテストする**: 開発には Base Sepolia を使用;本番には Base メインネットに切り替える。Solana では、メインネットの本番ファシリテーターに移る前に、Solana デブネット(`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`)と無料の x402.org ファシリテーターで開発する。 +- **Solana では SOL ではなく USDC を資金として入れる**: exact-SVM スキームではファシリテーターがトランザクション手数料支払者となるため、SOL を持たないウォレットでも支払える。署名前に各チャレンジの `asset` を期待する USDC ミント(メインネット `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`、デブネット `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`)と照合する — 任意のアセットに支払うラップクライアントはバジェットの穴になる。 ## 本番リファレンス @@ -222,3 +432,8 @@ main().catch((err) => { - **OKX Payments SDK**: [`okx/payments`](https://github.com/okx/payments) — X Layer x402 向け TypeScript、Go、Rust、Java セラー統合 - **OKX エージェント決済プロトコルスキル**: [`okx/onchainos-skills`](https://github.com/okx/onchainos-skills/tree/main/skills/okx-agent-payments-protocol) - **OKX Payments 概要**: [web3.okx.com/onchainos/dev-docs/payments/overview](https://web3.okx.com/onchainos/dev-docs/payments/overview) +- **アップストリーム x402 モノレポ**: [`x402-foundation/x402`](https://github.com/x402-foundation/x402) — TypeScript、Python、Go の実装と、メンテナンスされているクライアント・サーバー例 +- **x402 ドキュメント**: [docs.x402.org](https://docs.x402.org);本番ファシリテーターリストは [docs.x402.org/dev-tools/facilitators](https://docs.x402.org/dev-tools/facilitators) +- **`@x402` パッケージ**: [npmjs.com/org/x402](https://www.npmjs.com/org/x402) — `@x402/fetch`、`@x402/axios`、`@x402/express`、`@x402/hono`、`@x402/next`、`@x402/fastify`、`@x402/evm`、`@x402/svm` +- **ファシリテーター**: [自己ファシリテーションの例](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/self-facilitation)(第三者なし)、[x402.org ファシリテーター](https://x402.org/facilitator)(テストネットデフォルト)、メンテナンスされている[本番リスト](https://docs.x402.org/dev-tools/facilitators) +- **発見**: CDP と PayAI のバザールは `/discovery/resources`;Solana で支払い可能なサービスは [pay.sh](https://pay.sh) diff --git a/docs/zh-CN/skills/agent-payment-x402/SKILL.md b/docs/zh-CN/skills/agent-payment-x402/SKILL.md index f282c6d39..f79359dd8 100644 --- a/docs/zh-CN/skills/agent-payment-x402/SKILL.md +++ b/docs/zh-CN/skills/agent-payment-x402/SKILL.md @@ -1,41 +1,61 @@ --- name: agent-payment-x402 -description: 将 x402 支付执行添加到 AI 代理中——通过 MCP 工具实现每任务预算、支出控制和非托管钱包。当代理需要为 API、服务或其他代理付费时使用。 +description: 将 x402 支付执行添加到 AI 代理中,具备每任务预算、支出控制和非托管钱包。通过 agentwallet-sdk 支持 Base,通过 OKX Payments / OKX 代理支付协议支持 X Layer,并通过上游 x402 包与基于结算器的结算支持 Solana 及多网络 EVM。 origin: community --- # 代理支付执行 (x402) -让AI代理能够自主支付并内置消费控制。使用x402 HTTP支付协议和MCP工具,使代理能够为外部服务、API或其他代理支付,无需托管风险。 +让 AI 代理能够进行策略门控的支付并内置支出控制。使用 x402 HTTP 支付协议和 MCP 工具,使代理能够为外部服务、API 或其他代理付费,无托管风险。 ## 使用场景 -适用于:代理需要支付API调用、购买服务、与其他代理结算、强制执行每项任务消费限额,或管理非托管钱包。与成本感知LLM流水线和安全审查技能自然搭配。 +适用于:代理需要支付 API 调用、购买服务、与其他代理结算、强制执行每任务支出限额,或管理非托管钱包。与 cost-aware-llm-pipeline 和 security-review 技能自然搭配。 + +## 决策树 + +根据代理是购买对付费 API 的访问权,还是向他人收费,选择集成路径: + +| 需求 | 推荐路径 | +|------|------------------| +| 代理为 Base 或其他 agentwallet 支持链上的 402 门控 API 付费 | 使用 `agentwallet-sdk` 作为 MCP 支付服务器,并配置严格的支出策略 | +| 代理为 X Layer 上的 402 门控 API 付费 | 使用 `okx/onchainos-skills` 中的 OKX 代理支付协议;`okx-x402-payment` 是已弃用的旧别名 | +| 代理为 Solana 或其他 x402 v2 网络上的 402 门控 API 付费 | 用上游的 `@x402/fetch` 或 `@x402/axios` 包包装代理的 HTTP 客户端并注册 EVM/SVM 方案;由资源服务器的结算器验证和结算 | +| API 在 Solana 或多个网络上向代理收费(TypeScript、Python 或 Go) | 使用来自 `x402-foundation/x402` 的上游 x402 中间件 —— TypeScript 用 `@x402/express`、`@x402/hono`、`@x402/next` 或 `@x402/fastify`,Python 用 `x402`,Go 用 `github.com/x402-foundation/x402/go/v2` | +| TypeScript API 向代理收费 | 使用面向 Express、Hono、Fastify 或 Next.js 的 OKX Payments TypeScript 卖家 SDK 文档 | +| Go API 向代理收费 | 使用面向 Gin、Echo 或 `net/http` 的 OKX Payments Go 卖家 SDK 文档 | +| Rust API 向代理收费 | 使用面向 Axum 的 OKX Payments Rust 卖家 SDK 文档 | +| Java API 向代理收费 | 使用面向 Spring Boot 2/3、Java EE 或 Jakarta 的 OKX Payments Java 卖家 SDK 文档 | +| Python API 向代理收费 | 实现前先检查当前 OKX Payments 仓库;可能尚无 Python 卖家指南 | + +## 支持的网络 + +- `agentwallet-sdk`:在生产使用前,通过包文档确认当前网络覆盖范围。Base Sepolia 是最安全的开发默认值;Base 主网是原始技能所述的生产路径。 +- OKX Payments / X Layer:当前卖家文档面向 X Layer(`eip155:196`)和 USDT0 结算。由于支付包和结算器行为可能快速变化,生成生产代码前请获取当前 SDK 文档。 +- 上游 x402 包:设计上即多网络 —— 一条路由可以同时提供 Base 和 Solana,由买方选择。包默认使用 `x402.org` 结算器,它仅限测试网(Base Sepolia `eip155:84532`、Solana 开发网 `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`,以及 Stellar、Aptos、Hedera、XRPL 测试网),不适用于主网路由。主网(Solana 为 `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`)请选择自结算、自行运行结算器,或从上游结算器列表中挑选托管方案——参见选项 C 中的结算器对比。生产前请在结算器的 `/supported` 端点确认实时覆盖范围,而不要在此硬编码。 ## 工作原理 -### x402协议 +### x402 协议 +x402 将 HTTP 402(需要付款)扩展为机器可协商的流程。当服务器返回 `402` 时,代理的支付工具会协商价格、检查预算、签署交易,并仅在编排器设定的策略与确认边界内重试。 -x402将HTTP 402(需要付款)扩展为机器可协商的流程。当服务器返回`402`时,代理的支付工具会自动协商价格、检查预算、签署交易并重试——无需人工干预。 - -### 消费控制 - -每次支付工具调用都会强制执行`SpendingPolicy`: - -* **每任务预算** — 单次代理操作的最大支出 -* **每会话预算** — 整个会话的累计限额 -* **白名单接收方** — 限制代理可支付的地址/服务 -* **速率限制** — 每分钟/小时的最大交易数 +### 支出控制 +每次支付工具调用都会强制执行 `SpendingPolicy`: +- **每任务预算** — 单次代理操作的最大支出 +- **每会话预算** — 整个会话的累计限额 +- **白名单接收方** — 限制代理可支付的地址/服务 +- **速率限制** — 每分钟/小时的最大交易数 ### 非托管钱包 +代理通过 ERC-4337 智能账户持有自己的密钥。编排器在委托前设置策略;代理只能在限定范围内支出。无资金池,无托管风险。 -代理通过ERC-4337智能账户持有自己的密钥。编排器在委托前设置策略;代理只能在限定范围内支出。无资金池,无托管风险。 +## MCP 集成 -## MCP集成 +支付层暴露标准 MCP 工具,可无缝接入任何 Claude Code 或代理框架设置。 -支付层暴露标准MCP工具,可无缝接入任何Claude Code或代理框架设置。 +> **安全提示**:务必锁定包版本。此工具管理私钥——未锁定的 `npx` 安装会引入供应链风险。 -> **安全提示**:务必锁定包版本。此工具管理私钥——未锁定的`npx`安装会引入供应链风险。 +### 选项 A:agentwallet-sdk(Base / 多链) ```json { @@ -53,19 +73,247 @@ x402将HTTP 402(需要付款)扩展为机器可协商的流程。当服务 | 工具 | 用途 | |------|---------| | `get_balance` | 检查代理钱包余额 | -| `send_payment` | 向地址或ENS发送付款 | +| `send_payment` | 向地址或 ENS 发送付款 | | `check_spending` | 查询剩余预算 | | `list_transactions` | 所有付款的审计追踪 | -> **注意**:消费策略由**编排器**在委托给代理之前设置——而非代理本身。这防止代理自行提高消费限额。通过编排层或任务前钩子中的`set_policy`配置策略,切勿将其作为代理可调用工具。 +> **注意**:支出策略由**编排器**在委托给代理之前设置——而非代理本身。这可防止代理自行提高支出限额。通过编排层或任务前钩子中的 `set_policy` 配置策略,切勿将其作为代理可调用工具。 + +### 选项 B:OKX 代理支付协议(X Layer) + +将此路径用于 X Layer x402、多方支付(MPP)、会话支付、收费和 A2A 收费流程。 + +对于买方代理流程: + +1. 安装或引用当前的 `okx/onchainos-skills` 仓库。 +2. 使用 `skills/okx-agent-payments-protocol/SKILL.md` 作为调度器。 +3. 将 `skills/okx-x402-payment/SKILL.md` 视为已弃用的兼容别名,而非规范技能。 +4. 在钱包状态检查或支付操作前要求明确的用户确认。不要将支付执行隐藏在通用工具调用之后。 + +对于卖方 API 流程,生成代码前先获取最新的语言专用指南: + +| 运行时 | 当前指南 | +|---------|---------------| +| TypeScript | `https://raw.githubusercontent.com/okx/payments/main/typescript/SELLER.md` | +| Go | `https://raw.githubusercontent.com/okx/payments/main/go/x402/SELLER.md` | +| Rust | `https://raw.githubusercontent.com/okx/payments/main/rust/x402/SELLER.md` | +| Java | `https://raw.githubusercontent.com/okx/payments/main/java/SELLER.md` | + +不要在未检查当前 OKX 仓库的情况下复制旧文档中的示例。当前 OKX 指南使用 `okx-agent-payments-protocol` 作为调度器,且 Java 卖家文档现已可用。 + +### 选项 C:上游 x402 包(Solana + Base/EVM) + +当代理在 Solana、Base 或上游协议实现支持的其他网络上付费(或你的 API 收费)时,使用此路径。位于 [`x402-foundation/x402`](https://github.com/x402-foundation/x402) 的规范 x402 monorepo 正在积极维护,并直接发布客户端和中间件包。与选项 A 和 B 不同,这不是独立的 MCP 服务器——你包装代理自己的 HTTP 客户端,由资源服务器选择的结算器验证并结算。 + +对于买方代理流程: + +1. 从维护中的 [`examples/typescript/clients`](https://github.com/x402-foundation/x402/tree/main/examples/typescript/clients) 示例(fetch、axios、MCP)开始,而不是复制旧文档中的片段。 +2. 在签署或提交第一笔付费请求之前要求明确的用户确认,与选项 B 对 OKX 流程的要求完全一致。不要将支付执行隐藏在通用工具调用之后。 +3. 锁定包版本(例如 `@x402/fetch@2.22.0`);上游所有包以相同步调发布版本。 +4. 用注册到客户端的 `PaymentPolicy` 强制预算,使检查在每次调用时都针对服务器的真实挑战运行。与你自己传入的数字比较的预算不能证明任何事——金额、资产和网络都来自服务器,因此必须在签名产生之前全部验证。 + +5. 在收款方和预算策略之外,单独配置精确的资源来源白名单。下方所有支付 fetch 均拒绝重定向。会话确认应明确列出允许的来源、网络、资产、收款方和以可读单位表示的支出上限;并发调用共享一次确认结果。客户端和策略只能由编排器持有。 + +```typescript +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { ExactSvmScheme } from "@x402/svm/exact/client"; +import { privateKeyToAccount } from "viem/accounts"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { base58 } from "@scure/base"; + +// Signer keys belong to the ORCHESTRATOR's env — never hardcoded, never agent-writable. +const evmKey = process.env.EVM_PRIVATE_KEY as `0x${string}`; +const svmKey = process.env.SVM_PRIVATE_KEY; +if (!evmKey || !svmKey) { + throw new Error("Signer keys are not set — refusing to start payment client"); +} + +// One client, both network families: the buyer pays whichever chain the 402 offers. +const client = new x402Client(); +client.register("eip155:*", new ExactEvmScheme(privateKeyToAccount(evmKey))); +client.register("solana:*", new ExactSvmScheme(await createKeyPairSignerFromBytes(base58.decode(svmKey)))); + +// A PaymentPolicy filters the SERVER's payment requirements before any +// signature is created. Returning an empty array means "nothing here is +// acceptable" and the client refuses to pay rather than falling back. +const ALLOWED_NETWORKS = new Set([ + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", // Solana mainnet + "eip155:8453", // Base +]); +const MAX_AMOUNT = 10_000n; // atomic units, 6-decimal USDC: 0.01 USDC per call +const SESSION_CAP = 50_000n; // 0.05 USDC across the whole session + +// EVM addresses are case-insensitive, so compare them lowercased. Solana +// addresses are base58 and ARE case-sensitive — never lowercase those, or a +// different account could slip through. +const normalizeAddress = (a: string) => (a.startsWith("0x") ? a.toLowerCase() : a); +const ALLOWED_ASSETS = new Set( + [ + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC, Solana mainnet + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC, Base + ].map(normalizeAddress), +); +// Who you are willing to pay. Without this, any 402 an agent happens to hit +// can name its own recipient. +const ALLOWED_PAY_TO = new Set( + ["7pr7NCaQRz5PEhPy7BAeB3Z72TVkiShhjRyVCN5DA6yC"].map(normalizeAddress), +); + +client.registerPolicy((_x402Version, requirements) => + requirements.filter(r => { + if (!ALLOWED_NETWORKS.has(r.network)) return false; // wrong chain + if (!ALLOWED_ASSETS.has(normalizeAddress(r.asset))) return false; // wrong token + if (!ALLOWED_PAY_TO.has(normalizeAddress(r.payTo))) return false; // wrong recipient + try { + const amount = BigInt(r.amount); + return amount >= 0n && amount <= MAX_AMOUNT; // over budget / negative + } catch { + return false; // unparseable amount + } + }), +); + +// The policy sees one challenge at a time, so it cannot enforce a session +// total or ask a human anything. Keep the payment-enabled client private and +// route every paid call through the payOnce boundary below. + +// Supply this from your harness — a real prompt, never a stub that returns true. +declare function confirmWithUser(prompt: string): Promise; + +// Resource authorization is independent of recipient and budget policy. +// The ORCHESTRATOR supplies exact approved HTTPS origins before delegation; +// never populate this set from a server challenge or agent-controlled input. +const ALLOWED_ORIGINS = new Set(["https://api.example.com"]); +function requireAllowedOrigin(url: string): void { + const parsed = new URL(url); + if (!ALLOWED_ORIGINS.has(parsed.origin) || parsed.username || parsed.password) { + throw new Error("Resource origin is not authorized"); + } +} + +// Both approved assets above are 6-decimal USDC. Update this trusted formatting +// policy along with the asset allowlist if you support other assets/decimals. +const USDC_SCALE = 1_000_000n; +const humanUSDC = (amount: bigint) => + `${amount / USDC_SCALE}.${(amount % USDC_SCALE).toString().padStart(6, "0").replace(/0+$/, "") || "0"} USDC`; + +let sessionSpent = 0n; +let sessionApproval: Promise | undefined; + +async function payOnce(url: string, init?: RequestInit): Promise { + requireAllowedOrigin(url); // Before reservation, prompt, fetch, or signature. + // Reserve the worst case the policy allows. Settlement responses do not + // carry an amount, so counting MAX_AMOUNT per call is a deliberate + // over-estimate — it can stop early, never late. Reserve before any await, + // so concurrent calls cannot all pass the check. + if (sessionSpent + MAX_AMOUNT > SESSION_CAP) { + throw new Error("Session budget exhausted — blocked"); + } + sessionSpent += MAX_AMOUNT; + // Release the reservation only when no signed payment left this process: + // a declined or failed prompt, a challenge the policy rejected, or a free + // response. Once a signed request is sent it may settle, so keep it counted. + let signedRequestSent = false; + const trackingFetch: typeof fetch = async (input, reqInit) => { + // Override caller options on BOTH the challenge and signed retry. Native + // fetch refuses redirects, so an unauthorized host cannot return a 402 + // or receive a payment header via an automatic redirect. + const req = new Request(input, { ...reqInit, redirect: "error" }); + requireAllowedOrigin(req.url); + if (req.headers.has("PAYMENT-SIGNATURE") || req.headers.has("X-PAYMENT")) { + signedRequestSent = true; + } + const response = await fetch(req); + // Also fail closed for adapters that expose a redirect response instead. + if (response.redirected || (response.status >= 300 && response.status < 400)) { + throw new Error("Paid request redirects are blocked"); + } + return response; + }; + try { + // Install one promise before awaiting the prompt. Concurrent calls share + // the same decision, including a decline or failure; never auto-reprompt. + sessionApproval ??= Promise.resolve().then(() => confirmWithUser( + `Allow paid requests to origins: ${[...ALLOWED_ORIGINS].join(", ")}? ` + + `Networks: ${[...ALLOWED_NETWORKS].join(", ")}. ` + + `Assets: ${[...ALLOWED_ASSETS].join(", ")} (6-decimal USDC). ` + + `Recipients (payTo): ${[...ALLOWED_PAY_TO].join(", ")}. ` + + `Per-call cap: ${humanUSDC(MAX_AMOUNT)}; session cap: ${humanUSDC(SESSION_CAP)}.`, + )); + if (!await sessionApproval) { + throw new Error("User declined: no payment attempted"); + } + return await wrapFetchWithPayment(trackingFetch, client)(url, init); + } finally { + if (!signedRequestSent) sessionSpent -= MAX_AMOUNT; + } +} + +const res = await payOnce("https://api.example.com/data", { method: "GET" }); +``` + +注册该策略后,超预算金额、非预期代币或未注册的链都会故障关闭——所有候选项都被过滤掉,`createPaymentPayload` 会抛出异常而不是签名。开发网 USDC 是 `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`;仅在开发时把它加入 `ALLOWED_ASSETS`。对策略做对抗性测试——分别喂入要求 `5000000` 原子单位的挑战、报价另一种铸币地址的挑战,以及你从未注册的链上的挑战,并断言它们都不会产生签名。在 exact-SVM 方案中,结算器是交易费用支付方,因此买方钱包只需持有 USDC——无需 SOL 支付 gas。 + +**结算器选择。** 结算器代表资源服务器执行验证和结算,因此这是资源服务器的决定,而非买方的决定。按委托信任由少到多排列: + +| 选项 | 适用场景 | +|--------|--------------| +| [进程内自结算](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/self-facilitation) | 你不希望结算路径中有第三方,且能自行持有密钥和 RPC 访问 | +| 自行运行结算器 | 你想要同样的控制力,但在多个服务间共享 | +| [`x402.org` 结算器](https://x402.org/facilitator) | 开发与测试网——它是包的默认值,无需配置,上游明确说明它不适用于主网路由 | +| 托管的生产结算器 | 你希望获得主网覆盖而不必自己运维基础设施 | + +选择托管方案时,请从上游文档的[结算器列表](https://docs.x402.org/dev-tools/facilitators)中挑选,而不是照搬这里的名字——该列表有人维护、并不详尽,且覆盖范围会变化。撰写时它包含 Coinbase 的 CDP(对每笔交易执行 KYT/OFAC 筛查)、PayAI、Corbits、Dexter、Solvador 等,其中若干同时覆盖 EVM 与 Solana 主网。无论选择哪一个,上线前都要在其 `/supported` 端点确认实时覆盖范围,并在新增网络时重新确认。 + +> **披露**:本节由参与 PayAI(所列结算器之一)的人贡献。它只是若干选项之一,上面的自托管与上游默认路径是有意排在前面的。 + +**卖方侧(API 向代理收费)。** 使用上游中间件;一条路由可以同时提供 Base 和 Solana(可运行版本参见 [`examples/typescript/servers`](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers)): + +| 运行时 | 包 | +|---------|---------| +| Express / Hono / Next.js / Fastify | `@x402/express@2.22.0`、`@x402/hono@2.22.0`、`@x402/next@2.22.0`、`@x402/fastify@2.22.0` | +| Python(FastAPI、Flask) | PyPI 上的 `x402` | +| Go(Gin、Echo、`net/http`) | `github.com/x402-foundation/x402/go/v2` | + +**Solana 卖方:`payTo` 地址需要先有其规范代币账户。** exact-SVM 客户端用 `findAssociatedTokenPda` 为 `payTo` 推导*关联代币账户*(ATA)并转账到那里,但不会创建它。如果那个确切账户不存在,结算会在模拟阶段失败,402 返回 `transaction_simulation_failed`,看起来像客户端 bug,实际是收款方账户缺失。要检查推导出的地址本身——扫描该所有者的代币账户并不等价,因为同一铸币地址下的非规范辅助账户会让检查通过,而客户端真正要用的 ATA 仍然不存在: + +```typescript +import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from "@solana-program/token"; + +const [ata] = await findAssociatedTokenPda({ + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC mainnet + owner: payToAddress, + tokenProgram: TOKEN_PROGRAM_ADDRESS, // TOKEN_2022_PROGRAM_ADDRESS for Token-2022 mints +}); +``` + +然后确认该确切地址存在——`value: null` 表示不存在,向它结算将会失败: + +```bash +curl -s https://api.mainnet-beta.solana.com -X POST -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getAccountInfo", + "params":["",{"encoding":"base64"}]}' \ + | jq '.result.value != null' +``` + +创建方式(由创建者支付少量租金,而非付款方): + +- 在代码中,将 `@solana-program/token` 的 `getCreateAssociatedTokenIdempotentInstruction` 加入你的开通流程——幂等版本可安全重复执行,也是唯一确定性的方式。 +- 用 spl-token CLI:`spl-token create-account --owner `。 +- 向 `payTo` 转账该代币也可以,但仅当发送方包含创建指令时——钱包和 `spl-token transfer --fund-recipient` 会包含;对缺失 ATA 的裸 `transferChecked` 会像结算一样失败。 + +这个问题在向新开通的钱包或托管钱包付款时最容易出现,这类钱包往往还没有该资产的 ATA。 + +**发现。** 实现 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)。 ## 示例 -### MCP客户端中的预算执行 +### MCP 客户端中的预算执行 -在构建调用agentpay MCP服务器的编排器时,在分派付费工具调用前强制执行预算。 +在构建调用 agentpay MCP 服务器的编排器时,在分派付费工具调用前强制执行预算。 -> **前提条件**:在添加MCP配置前安装包——`npx`不带`-y`会在非交互环境中提示确认,导致服务器挂起:`npm install -g agentwallet-sdk@6.0.0` +> **前提条件**:在添加 MCP 配置前安装包——`npx` 不带 `-y` 会在非交互环境中提示确认,导致服务器挂起:`npm install -g agentwallet-sdk@6.0.0` ```typescript import { Client } from "@modelcontextprotocol/sdk/client/index.js"; @@ -168,15 +416,24 @@ main().catch((err) => { ## 最佳实践 -* **委托前设置预算**:生成子代理时,通过编排层附加SpendingPolicy。切勿让代理拥有无限支出权限。 -* **锁定依赖项**:始终在MCP配置中指定确切版本(例如`agentwallet-sdk@6.0.0`)。部署到生产环境前验证包完整性。 -* **审计追踪**:在任务后钩子中使用`list_transactions`记录支出内容和原因。 -* **故障关闭**:如果支付工具不可达,阻止付费操作——不要回退到无计量访问。 -* **配合安全审查**:支付工具是高权限操作。应用与shell访问相同的审查标准。 -* **先在测试网测试**:开发时使用Base Sepolia;生产环境切换到Base主网。 +- **委托前设置预算**:生成子代理时,通过编排层附加 SpendingPolicy。切勿让代理拥有无限支出权限。 +- **锁定依赖项**:始终在 MCP 配置中指定确切版本(例如 `agentwallet-sdk@6.0.0`)。部署到生产环境前验证包完整性。 +- **审计追踪**:在任务后钩子中使用 `list_transactions` 记录支出内容和原因。 +- **故障关闭**:如果支付工具不可达,阻止付费操作——不要回退到无计量访问。 +- **配合 security-review**:支付工具是高权限操作。应用与 shell 访问相同的审查标准。 +- **先在测试网测试**:开发时使用 Base Sepolia;生产环境切换到 Base 主网。在 Solana 上,先针对 Solana 开发网(`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`)和免费的 x402.org 结算器开发,再转到主网的生产结算器。 +- **在 Solana 上注入 USDC 而非 SOL**:exact-SVM 方案让结算器成为交易费用支付方,因此没有 SOL 的钱包也能付费。签署前将每个挑战的 `asset` 与预期的 USDC 铸币地址核对(主网 `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`,开发网 `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`)——一个会支付任意资产的包装客户端是预算上的漏洞。 ## 生产参考 -* **npm**:[`agentwallet-sdk`](https://www.npmjs.com/package/agentwallet-sdk) -* **合并到NVIDIA NeMo Agent Toolkit**:[PR #17](https://github.com/NVIDIA/NeMo-Agent-Toolkit-Examples/pull/17) — NVIDIA代理示例的x402支付工具 -* **协议规范**:[x402.org](https://x402.org) +- **npm**:[`agentwallet-sdk`](https://www.npmjs.com/package/agentwallet-sdk) +- **合并到 NVIDIA NeMo Agent Toolkit**:[PR #17](https://github.com/NVIDIA/NeMo-Agent-Toolkit-Examples/pull/17) — 面向 NVIDIA 代理示例的 x402 支付工具 +- **协议规范**:[x402.org](https://x402.org) +- **OKX Payments SDK**:[`okx/payments`](https://github.com/okx/payments) — 面向 X Layer x402 的 TypeScript、Go、Rust 和 Java 卖家集成 +- **OKX 代理支付协议技能**:[`okx/onchainos-skills`](https://github.com/okx/onchainos-skills/tree/main/skills/okx-agent-payments-protocol) +- **OKX Payments 概览**:[web3.okx.com/onchainos/dev-docs/payments/overview](https://web3.okx.com/onchainos/dev-docs/payments/overview) +- **上游 x402 monorepo**:[`x402-foundation/x402`](https://github.com/x402-foundation/x402) — TypeScript、Python 和 Go 实现,以及维护中的客户端和服务器示例 +- **x402 文档**:[docs.x402.org](https://docs.x402.org);生产结算器列表位于 [docs.x402.org/dev-tools/facilitators](https://docs.x402.org/dev-tools/facilitators) +- **`@x402` 包**:[npmjs.com/org/x402](https://www.npmjs.com/org/x402) — `@x402/fetch`、`@x402/axios`、`@x402/express`、`@x402/hono`、`@x402/next`、`@x402/fastify`、`@x402/evm`、`@x402/svm` +- **结算器**:[自结算示例](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/self-facilitation)(无第三方)、[x402.org 结算器](https://x402.org/facilitator)(测试网默认)、以及有人维护的[生产列表](https://docs.x402.org/dev-tools/facilitators) +- **发现**:CDP 与 PayAI 集市位于 `/discovery/resources`;Solana 可付费服务见 [pay.sh](https://pay.sh) diff --git a/skills/agent-payment-x402/SKILL.md b/skills/agent-payment-x402/SKILL.md index e006b1b6d..41608571d 100644 --- a/skills/agent-payment-x402/SKILL.md +++ b/skills/agent-payment-x402/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-payment-x402 -description: Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk and X Layer through OKX Payments / OKX Agent Payments Protocol. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet. +description: Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk, X Layer through OKX Payments / OKX Agent Payments Protocol, and Solana plus multi-network EVM through the upstream x402 packages with facilitator-based settlement. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet. metadata: origin: community --- @@ -21,6 +21,8 @@ Choose the integration path based on whether your agent is buying access to a pa |------|------------------| | Agent pays a 402-gated API on Base or another agentwallet-supported chain | Use `agentwallet-sdk` as an MCP payment server with strict spending policy | | Agent pays a 402-gated API on X Layer | Use OKX Agent Payments Protocol from `okx/onchainos-skills`; `okx-x402-payment` is a deprecated legacy alias | +| Agent pays a 402-gated API on Solana or another x402 v2 network | Wrap the agent's HTTP client with the upstream `@x402/fetch` or `@x402/axios` package and register the EVM/SVM schemes; the resource server's facilitator verifies and settles | +| API charges agents on Solana or multiple networks (TypeScript, Python, or Go) | Use the upstream x402 middleware from `x402-foundation/x402` — `@x402/express`, `@x402/hono`, `@x402/next`, or `@x402/fastify` for TypeScript, `x402` for Python, `github.com/x402-foundation/x402/go/v2` for Go | | TypeScript API charges agents | Use OKX Payments TypeScript seller SDK docs for Express, Hono, Fastify, or Next.js | | Go API charges agents | Use OKX Payments Go seller SDK docs for Gin, Echo, or `net/http` | | Rust API charges agents | Use OKX Payments Rust seller SDK docs for Axum | @@ -31,6 +33,7 @@ Choose the integration path based on whether your agent is buying access to a pa - `agentwallet-sdk`: use the package docs to confirm current network coverage before production. Base Sepolia is the safest development default; Base mainnet is the production path called out by the original skill. - OKX Payments / X Layer: current seller docs target X Layer (`eip155:196`) and USDT0 settlement. Fetch current SDK docs before generating production code because payment packages and facilitator behavior can change quickly. +- Upstream x402 packages: multi-network by design — one route can advertise Base and Solana simultaneously and let the buyer pick. The packages default to the `x402.org` facilitator, which is testnet-only (Base Sepolia `eip155:84532`, Solana devnet `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`, plus Stellar, Aptos, Hedera, and XRPL testnets) and is not intended for mainnet routes. For mainnet (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` for Solana), either self-facilitate, run your own facilitator, or pick a hosted one from the upstream facilitator list — see the facilitator comparison under Option C. Confirm live coverage at the facilitator's `/supported` endpoint before production rather than hardcoding it here. ## How It Works @@ -99,6 +102,212 @@ For seller-side API flows, fetch the latest language-specific guide before gener Do not copy examples from older docs without checking the current OKX repository. Current OKX guidance uses `okx-agent-payments-protocol` as the dispatcher, and Java seller docs are now available. +### Option C: Upstream x402 packages (Solana + Base/EVM) + +Use this path when the agent pays — or your API charges — on Solana, Base, or another network the upstream protocol implementation supports. The canonical x402 monorepo at [`x402-foundation/x402`](https://github.com/x402-foundation/x402) is actively maintained and publishes the client and middleware packages directly. Unlike Options A and B this is not a separate MCP server — you wrap the agent's own HTTP client, and a facilitator chosen by the resource server verifies and settles. + +For buyer-side agent flows: + +1. Start from the maintained examples in [`examples/typescript/clients`](https://github.com/x402-foundation/x402/tree/main/examples/typescript/clients) (fetch, axios, MCP) rather than copying snippets from older docs. +2. Require explicit user confirmation before signing or submitting the first paid request, exactly as Option B requires for OKX flows. Do not hide payment execution behind a generic tool call. +3. Pin package versions (for example `@x402/fetch@2.22.0`); all upstream packages version in lockstep. +4. Enforce your budget with a `PaymentPolicy` registered on the client, so the check runs against the server's actual challenge on every call. A budget compared against a number you passed in yourself proves nothing — the amount, asset, and network all come from the server, so all three must be validated before a signature exists. + +5. Configure an exact resource-origin allowlist separately from recipient and budget policy. All payment-enabled fetches below refuse redirects. Session approval names the approved origins, networks, assets, recipients, and human-unit spending caps; concurrent calls share one decision. Keep this client and its policy private to the orchestrator. + +```typescript +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { ExactSvmScheme } from "@x402/svm/exact/client"; +import { privateKeyToAccount } from "viem/accounts"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { base58 } from "@scure/base"; + +// Signer keys belong to the ORCHESTRATOR's env — never hardcoded, never agent-writable. +const evmKey = process.env.EVM_PRIVATE_KEY as `0x${string}`; +const svmKey = process.env.SVM_PRIVATE_KEY; +if (!evmKey || !svmKey) { + throw new Error("Signer keys are not set — refusing to start payment client"); +} + +// One client, both network families: the buyer pays whichever chain the 402 offers. +const client = new x402Client(); +client.register("eip155:*", new ExactEvmScheme(privateKeyToAccount(evmKey))); +client.register("solana:*", new ExactSvmScheme(await createKeyPairSignerFromBytes(base58.decode(svmKey)))); + +// A PaymentPolicy filters the SERVER's payment requirements before any +// signature is created. Returning an empty array means "nothing here is +// acceptable" and the client refuses to pay rather than falling back. +const ALLOWED_NETWORKS = new Set([ + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", // Solana mainnet + "eip155:8453", // Base +]); +const MAX_AMOUNT = 10_000n; // atomic units, 6-decimal USDC: 0.01 USDC per call +const SESSION_CAP = 50_000n; // 0.05 USDC across the whole session + +// EVM addresses are case-insensitive, so compare them lowercased. Solana +// addresses are base58 and ARE case-sensitive — never lowercase those, or a +// different account could slip through. +const normalizeAddress = (a: string) => (a.startsWith("0x") ? a.toLowerCase() : a); +const ALLOWED_ASSETS = new Set( + [ + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC, Solana mainnet + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC, Base + ].map(normalizeAddress), +); +// Who you are willing to pay. Without this, any 402 an agent happens to hit +// can name its own recipient. +const ALLOWED_PAY_TO = new Set( + ["7pr7NCaQRz5PEhPy7BAeB3Z72TVkiShhjRyVCN5DA6yC"].map(normalizeAddress), +); + +client.registerPolicy((_x402Version, requirements) => + requirements.filter(r => { + if (!ALLOWED_NETWORKS.has(r.network)) return false; // wrong chain + if (!ALLOWED_ASSETS.has(normalizeAddress(r.asset))) return false; // wrong token + if (!ALLOWED_PAY_TO.has(normalizeAddress(r.payTo))) return false; // wrong recipient + try { + const amount = BigInt(r.amount); + return amount >= 0n && amount <= MAX_AMOUNT; // over budget / negative + } catch { + return false; // unparseable amount + } + }), +); + +// The policy sees one challenge at a time, so it cannot enforce a session +// total or ask a human anything. Keep the payment-enabled client private and +// route every paid call through the payOnce boundary below. + +// Supply this from your harness — a real prompt, never a stub that returns true. +declare function confirmWithUser(prompt: string): Promise; + +// Resource authorization is independent of recipient and budget policy. +// The ORCHESTRATOR supplies exact approved HTTPS origins before delegation; +// never populate this set from a server challenge or agent-controlled input. +const ALLOWED_ORIGINS = new Set(["https://api.example.com"]); +function requireAllowedOrigin(url: string): void { + const parsed = new URL(url); + if (!ALLOWED_ORIGINS.has(parsed.origin) || parsed.username || parsed.password) { + throw new Error("Resource origin is not authorized"); + } +} + +// Both approved assets above are 6-decimal USDC. Update this trusted formatting +// policy along with the asset allowlist if you support other assets/decimals. +const USDC_SCALE = 1_000_000n; +const humanUSDC = (amount: bigint) => + `${amount / USDC_SCALE}.${(amount % USDC_SCALE).toString().padStart(6, "0").replace(/0+$/, "") || "0"} USDC`; + +let sessionSpent = 0n; +let sessionApproval: Promise | undefined; + +async function payOnce(url: string, init?: RequestInit): Promise { + requireAllowedOrigin(url); // Before reservation, prompt, fetch, or signature. + // Reserve the worst case the policy allows. Settlement responses do not + // carry an amount, so counting MAX_AMOUNT per call is a deliberate + // over-estimate — it can stop early, never late. Reserve before any await, + // so concurrent calls cannot all pass the check. + if (sessionSpent + MAX_AMOUNT > SESSION_CAP) { + throw new Error("Session budget exhausted — blocked"); + } + sessionSpent += MAX_AMOUNT; + // Release the reservation only when no signed payment left this process: + // a declined or failed prompt, a challenge the policy rejected, or a free + // response. Once a signed request is sent it may settle, so keep it counted. + let signedRequestSent = false; + const trackingFetch: typeof fetch = async (input, reqInit) => { + // Override caller options on BOTH the challenge and signed retry. Native + // fetch refuses redirects, so an unauthorized host cannot return a 402 + // or receive a payment header via an automatic redirect. + const req = new Request(input, { ...reqInit, redirect: "error" }); + requireAllowedOrigin(req.url); + if (req.headers.has("PAYMENT-SIGNATURE") || req.headers.has("X-PAYMENT")) { + signedRequestSent = true; + } + const response = await fetch(req); + // Also fail closed for adapters that expose a redirect response instead. + if (response.redirected || (response.status >= 300 && response.status < 400)) { + throw new Error("Paid request redirects are blocked"); + } + return response; + }; + try { + // Install one promise before awaiting the prompt. Concurrent calls share + // the same decision, including a decline or failure; never auto-reprompt. + sessionApproval ??= Promise.resolve().then(() => confirmWithUser( + `Allow paid requests to origins: ${[...ALLOWED_ORIGINS].join(", ")}? ` + + `Networks: ${[...ALLOWED_NETWORKS].join(", ")}. ` + + `Assets: ${[...ALLOWED_ASSETS].join(", ")} (6-decimal USDC). ` + + `Recipients (payTo): ${[...ALLOWED_PAY_TO].join(", ")}. ` + + `Per-call cap: ${humanUSDC(MAX_AMOUNT)}; session cap: ${humanUSDC(SESSION_CAP)}.`, + )); + if (!await sessionApproval) { + throw new Error("User declined: no payment attempted"); + } + return await wrapFetchWithPayment(trackingFetch, client)(url, init); + } finally { + if (!signedRequestSent) sessionSpent -= MAX_AMOUNT; + } +} + +const res = await payOnce("https://api.example.com/data", { method: "GET" }); +``` + +With the policy registered, an over-budget amount, an unexpected token, or an unregistered chain all fail closed — `createPaymentPayload` throws instead of signing, because every candidate was filtered out. Devnet USDC is `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`; add it to `ALLOWED_ASSETS` only for development. Test the policy adversarially — feed it a challenge demanding `5000000` atomic units, one quoting a different mint, and one on a chain you never registered, and assert that none of them produce a signature. In the exact-SVM scheme the facilitator is the transaction fee payer, so the buyer wallet holds USDC only — no SOL for gas. + +**Facilitator choice.** A facilitator verifies and settles on the resource server's behalf, so this is the resource server's decision, not the buyer's. In rough order of least to most trust delegated: + +| Option | When it fits | +|--------|--------------| +| [Self-facilitate in-process](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/self-facilitation) | You want no third party in the settlement path and can hold keys and RPC access yourself | +| Run your own facilitator | You want that same control but shared across several services | +| [`x402.org` facilitator](https://x402.org/facilitator) | Development and testnets — it is the package default, requires no setup, and upstream documents it as not intended for mainnet routes | +| A hosted production facilitator | You want mainnet coverage without operating infrastructure | + +For the hosted option, choose from the [facilitator list](https://docs.x402.org/dev-tools/facilitators) in the upstream docs rather than a name copied from here — it is maintained, it is not exhaustive, and coverage changes. As of writing it includes Coinbase's CDP (KYT/OFAC screening on every transaction), PayAI, Corbits, Dexter, Solvador, and others; several cover Solana mainnet alongside EVM. Whichever you pick, confirm live coverage at its `/supported` endpoint before production and re-check when you add a network. + +> **Disclosure**: this section was contributed by someone who works on PayAI, one of the listed facilitators. It is included as one option among several, and the self-hosted and upstream-default paths above are deliberately listed first. + +**Seller side (API charges agents).** Use the upstream middleware; one route can advertise Base and Solana simultaneously (see [`examples/typescript/servers`](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers) for runnable versions): + +| Runtime | Package | +|---------|---------| +| Express / Hono / Next.js / Fastify | `@x402/express@2.22.0`, `@x402/hono@2.22.0`, `@x402/next@2.22.0`, `@x402/fastify@2.22.0` | +| Python (FastAPI, Flask) | `x402` on PyPI | +| Go (Gin, Echo, `net/http`) | `github.com/x402-foundation/x402/go/v2` | + +**Solana sellers: the `payTo` address needs its canonical token account first.** The exact-SVM client derives the *associated token account* (ATA) for `payTo` with `findAssociatedTokenPda` and transfers there; it never creates it. If that exact account is missing, settlement fails at simulation and the 402 comes back with `transaction_simulation_failed`, which reads like a client bug rather than a missing recipient account. Check the derived address itself — scanning the owner's token accounts is not equivalent, because a non-canonical auxiliary account for the same mint would pass while the ATA the client targets is still absent: + +```typescript +import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from "@solana-program/token"; + +const [ata] = await findAssociatedTokenPda({ + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC mainnet + owner: payToAddress, + tokenProgram: TOKEN_PROGRAM_ADDRESS, // TOKEN_2022_PROGRAM_ADDRESS for Token-2022 mints +}); +``` + +Then confirm that exact address exists — `value: null` means it does not, and settlement to it will fail: + +```bash +curl -s https://api.mainnet-beta.solana.com -X POST -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getAccountInfo", + "params":["",{"encoding":"base64"}]}' \ + | jq '.result.value != null' +``` + +To provision it (whoever creates it pays the small rent, not the payer): + +- In code, add `getCreateAssociatedTokenIdempotentInstruction` from `@solana-program/token` to your onboarding flow — the idempotent variant is safe to re-run and is the only option that is deterministic. +- `spl-token create-account --owner ` with the spl-token CLI. +- A transfer of that token to `payTo` also works, but only when the sender includes the create instruction — wallets and `spl-token transfer --fund-recipient` do; a bare `transferChecked` to a missing ATA fails the same way settlement does. + +This bites hardest when payouts go to freshly provisioned or custodial wallets, which often have no ATA for the asset yet. + +**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. + ## Examples ### Budget enforcement in an MCP client @@ -213,7 +422,8 @@ main().catch((err) => { - **Audit trails**: Use `list_transactions` in post-task hooks to log what was spent and why. - **Fail closed**: If the payment tool is unreachable, block the paid action — don't fall back to unmetered access. - **Pair with security-review**: Payment tools are high-privilege. Apply the same scrutiny as shell access. -- **Test with testnets first**: Use Base Sepolia for development; switch to Base mainnet for production. +- **Test with testnets first**: Use Base Sepolia for development; switch to Base mainnet for production. On Solana, develop against Solana devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) and the free x402.org facilitator before moving to a production facilitator on mainnet. +- **On Solana, fund USDC not SOL**: The exact-SVM scheme makes the facilitator the transaction fee payer, so a SOL-less wallet still pays. Verify each challenge's `asset` against the expected USDC mint (mainnet `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, devnet `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`) before signing — a wrapped client that pays any asset is a hole in your budget. ## Production Reference @@ -223,3 +433,8 @@ main().catch((err) => { - **OKX Payments SDKs**: [`okx/payments`](https://github.com/okx/payments) — TypeScript, Go, Rust, and Java seller integrations for X Layer x402 - **OKX Agent Payments Protocol skill**: [`okx/onchainos-skills`](https://github.com/okx/onchainos-skills/tree/main/skills/okx-agent-payments-protocol) - **OKX Payments overview**: [web3.okx.com/onchainos/dev-docs/payments/overview](https://web3.okx.com/onchainos/dev-docs/payments/overview) +- **Upstream x402 monorepo**: [`x402-foundation/x402`](https://github.com/x402-foundation/x402) — TypeScript, Python, and Go implementations plus maintained client and server examples +- **x402 docs**: [docs.x402.org](https://docs.x402.org); production facilitator list at [docs.x402.org/dev-tools/facilitators](https://docs.x402.org/dev-tools/facilitators) +- **`@x402` packages**: [npmjs.com/org/x402](https://www.npmjs.com/org/x402) — `@x402/fetch`, `@x402/axios`, `@x402/express`, `@x402/hono`, `@x402/next`, `@x402/fastify`, `@x402/evm`, `@x402/svm` +- **Facilitators**: [self-facilitation example](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/self-facilitation) (no third party), [x402.org facilitator](https://x402.org/facilitator) (testnet default), and the maintained [production list](https://docs.x402.org/dev-tools/facilitators) +- **Discovery**: CDP and PayAI bazaars at `/discovery/resources`; [pay.sh](https://pay.sh) for Solana-payable services diff --git a/tests/ci/agent-payment-x402-doc.test.js b/tests/ci/agent-payment-x402-doc.test.js new file mode 100644 index 000000000..4cfbdce31 --- /dev/null +++ b/tests/ci/agent-payment-x402-doc.test.js @@ -0,0 +1,197 @@ +'use strict'; + +// Execute the canonical and translated TypeScript payment boundary offline. +// The x402 adapter models its documented fetch -> 402 -> policy -> sign -> retry +// lifecycle. No signer, key, RPC, user data, or real HTTP client is supplied. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); +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 ORIGIN = 'https://api.example.com'; +const URL_PAID = `${ORIGIN}/data`; +const CHALLENGE = { + network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + asset: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + payTo: '7pr7NCaQRz5PEhPy7BAeB3Z72TVkiShhjRyVCN5DA6yC', + amount: '10000', +}; + +function harness(doc, options = {}) { + 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 payOnce(')); + assert.ok(block, 'document must retain a runnable payment boundary'); + const code = block[1].slice(block[1].indexOf('const ALLOWED_NETWORKS'), block[1].indexOf('const res =')); + const stats = { prompts: [], requests: [], signs: 0 }; + let policy; + const client = { registerPolicy(fn) { policy = fn; } }; + const fetch = async request => { + stats.requests.push(request); + if (options.fetch) return options.fetch(request); + return new Response(null, { status: request.headers.has('PAYMENT-SIGNATURE') ? 200 : 402 }); + }; + const wrapFetchWithPayment = tracking => async (url, init) => { + const request = new Request(options.trackingUrl || url, init); + const response = await tracking(request); + if (response.status !== 402) return response; + const accepted = policy(2, [options.challenge || CHALLENGE]); + if (!accepted.length) throw new Error('Payment policy rejected challenge'); + stats.signs++; + const headers = new Headers(request.headers); + headers.set('PAYMENT-SIGNATURE', 'offline-synthetic-signature'); + return tracking(options.retryUrl || request, { headers }); + }; + const confirmWithUser = async prompt => { + stats.prompts.push(prompt); + return options.confirm ? options.confirm(prompt) : true; + }; + const source = `${code}\nmodule.exports = { payOnce, spent: () => sessionSpent };`; + const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + reportDiagnostics: true, + }); + assert.equal((compiled.diagnostics || []).length, 0); + const context = vm.createContext({ client, fetch, wrapFetchWithPayment, confirmWithUser, + Request, Response, Headers, URL, module: { exports: {} } }); + new vm.Script(compiled.outputText, { filename: doc }).runInContext(context); + return { ...context.module.exports, stats, policy: (...args) => policy(...args) }; +} + +const tests = []; +for (const doc of DOCS) { + const add = (name, test) => tests.push([`${doc}: ${name}`, test]); + add('rejects an unauthorized initial origin before prompt, fetch, or sign', async () => { + const h = harness(doc); + await assert.rejects(h.payOnce('https://evil.example/data'), /origin|authorized/i); + assert.equal(h.stats.prompts.length, 0); + assert.equal(h.stats.requests.length, 0); + assert.equal(h.stats.signs, 0); + assert.equal(h.spent(), 0n); + }); + add('uses exact origin matching including port, scheme, and host', async () => { + const h = harness(doc); + for (const url of ['http://api.example.com/data', 'https://api.example.com:444/data', + 'https://api.example.com.evil.example/data', 'https://api.example.com@evil.example/data']) { + await assert.rejects(h.payOnce(url), /origin|authorized/i); + } + assert.equal(h.stats.requests.length, 0); + assert.equal(h.stats.prompts.length, 0); + }); + add('checks each tracking fetch origin before network access or signing', async () => { + const h = harness(doc, { trackingUrl: 'https://evil.example/challenge' }); + await assert.rejects(h.payOnce(URL_PAID), /origin|authorized/i); + assert.equal(h.stats.requests.length, 0); + assert.equal(h.stats.signs, 0); + assert.equal(h.spent(), 0n); + }); + add('also checks a signed retry origin before sending the signature', async () => { + const h = harness(doc, { retryUrl: 'https://evil.example/paid' }); + await assert.rejects(h.payOnce(URL_PAID), /origin|authorized/i); + assert.equal(h.stats.requests.length, 1); + assert.equal(h.stats.signs, 1); + assert.equal(h.spent(), 0n); + }); + add('forces redirect error even when caller requests follow', async () => { + const h = harness(doc, { fetch: async request => { + assert.equal(request.redirect, 'error'); + throw new Error('Redirect blocked by offline fetch'); + } }); + await assert.rejects(h.payOnce(URL_PAID, { redirect: 'follow' }), /Redirect blocked/); + assert.equal(h.stats.signs, 0); + assert.equal(h.spent(), 0n); + }); + add('rejects a cross-origin redirect response before sign', async () => { + const h = harness(doc, { fetch: async () => new Response(null, { + status: 302, headers: { Location: 'https://evil.example/data' }, + }) }); + await assert.rejects(h.payOnce(URL_PAID), /redirect/i); + assert.equal(h.stats.signs, 0); + assert.equal(h.spent(), 0n); + }); + for (const [name, change] of [ + ['network', { network: 'eip155:1' }], + ['asset', { asset: 'unapproved-mint' }], + ['recipient', { payTo: 'unapproved-recipient' }], + ['over-cap amount', { amount: '10001' }], + ['negative amount', { amount: '-1' }], + ['malformed amount', { amount: 'invalid' }], + ]) { + add(`rejects ${name} challenge without signing or retaining a reservation`, async () => { + const h = harness(doc, { challenge: { ...CHALLENGE, ...change } }); + await assert.rejects(h.payOnce(URL_PAID), /policy rejected/i); + assert.equal(h.stats.signs, 0); + assert.equal(h.spent(), 0n); + }); + } + add('states origins, networks, assets, recipients and human-unit caps in approval', async () => { + const h = harness(doc); + await h.payOnce(URL_PAID); + assert.equal(h.stats.prompts.length, 1); + const prompt = h.stats.prompts[0]; + for (const value of [ORIGIN, CHALLENGE.network, 'eip155:8453', CHALLENGE.asset, + CHALLENGE.payTo, '0.01 USDC', '0.05 USDC']) assert.ok(prompt.includes(value), value); + }); + add('reserves five concurrent calls before approval and prompts exactly once', async () => { + let resolveApproval; + const approval = new Promise(resolve => { resolveApproval = resolve; }); + const h = harness(doc, { confirm: () => approval }); + const pending = Array.from({ length: 5 }, () => h.payOnce(URL_PAID)); + await assert.rejects(h.payOnce(URL_PAID), /budget exhausted/i); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(h.spent(), 50_000n); + assert.equal(h.stats.prompts.length, 1); + assert.equal(h.stats.requests.length, 0); + resolveApproval(true); + await Promise.all(pending); + assert.equal(h.stats.signs, 5); + assert.equal(h.stats.requests.length, 10); + assert.equal(h.spent(), 50_000n); + }); + add('declined shared approval releases every pending reservation', async () => { + const h = harness(doc, { confirm: async () => false }); + const outcomes = await Promise.allSettled(Array.from({ length: 5 }, () => h.payOnce(URL_PAID))); + assert.ok(outcomes.every(result => result.status === 'rejected')); + assert.equal(h.stats.prompts.length, 1); + assert.equal(h.spent(), 0n); + assert.equal(h.stats.requests.length, 0); + }); + add('failed shared approval releases every pending reservation', async () => { + const h = harness(doc, { confirm: () => { throw new Error('prompt closed'); } }); + const outcomes = await Promise.allSettled(Array.from({ length: 5 }, () => h.payOnce(URL_PAID))); + assert.ok(outcomes.every(result => result.status === 'rejected')); + assert.equal(h.stats.prompts.length, 1); + assert.equal(h.spent(), 0n); + assert.equal(h.stats.requests.length, 0); + }); + add('free responses release reservations for later calls', async () => { + const h = harness(doc, { fetch: async () => new Response(null, { status: 200 }) }); + for (let index = 0; index < 6; index++) await h.payOnce(URL_PAID); + assert.equal(h.stats.signs, 0); + assert.equal(h.spent(), 0n); + assert.equal(h.stats.prompts.length, 1); + }); + add('a failed signed send retains its worst-case reservation', async () => { + const h = harness(doc, { fetch: async request => { + if (request.headers.has('PAYMENT-SIGNATURE')) throw new Error('ambiguous send'); + return new Response(null, { status: 402 }); + } }); + await assert.rejects(h.payOnce(URL_PAID), /ambiguous send/); + assert.equal(h.spent(), 10_000n); + }); +} +(async () => { + let passed = 0; + for (const [name, test] of tests) { + try { await test(); passed++; console.log(`PASS ${name}`); } + catch (error) { console.error(`FAIL ${name}: ${error.message}`); } + } + console.log(`Passed: ${passed}\nFailed: ${tests.length - passed}`); + process.exitCode = passed === tests.length ? 0 : 1; +})();