x402 v2で「支払ったらEVMをsimulationするAPI」を作ってみた
AI Agentが自分でオンチェーントランザクションを組み立てるなら、送信前に「このcallは今のstateで通るのか」を機械的に確認したくなります。
そこで、Base/EVM向けのread-only Transaction Preflight Engineと、x402によるpay-per-requestの支払い境界をTypeScriptで分離実装し、最後に統合しました。
この記事では、まだmainnetでサービス提供していない段階の実装と、実際にテストして分かった落とし穴をまとめます。
構成
Client / AI Agent
↓ POST /preflight
x402 payment verification
↓
Preflight Engine
↓ eth_chainId / eth_blockNumber / eth_call / eth_estimateGas
Operator-configured RPC
↓
x402 settlement
↓
Result
支払いコードとsimulation engineは独立させています。Engine側はx402を知りませんし、x402側もtransaction schemaの内部実装を持ちません。
使用しているもの
現在の検証環境はNode.js 24.19.0。x402 PoCでは @x402/core / @x402/evm / @x402/express / @x402/fetch 2.28.0、Express 5.3.0、viem 2.57.10を使用しています。
PoCの支払い条件は以下です。
| 項目 | 現在のPoC |
|---|---|
| Network | Base Sepolia (eip155:84532) |
| Token | test USDC |
| x402 | v2 / exact |
| Authorization | EIP-3009 |
| 仮価格 | 0.001 USDC / request |
| Production候補 | Base mainnet + USDC(未有効化) |
0.001 USDCはあくまでPoC価格です。本番価格は別途検討しています。
Preflight Engine
入力はかなり小さくしています。
{
"schemaVersion": 1,
"chain": "eip155:84532",
"from": "0x1111111111111111111111111111111111111111",
"to": "0x2222222222222222222222222222222222222222",
"value": "0",
"calldata": "0x"
}
valueをJSON numberではなく10進文字列にしているのは、uint256をJavaScript numberで扱って精度を落とさないためです。
EngineはRPCのchain IDを確認し、block numberを取得して、そのblockに eth_call と可能なら eth_estimateGas を固定します。
返却statusは主に次の5種類です。
successrevertinvalid_requestunsupported_networkerror
revertについては Error(string)、Panic(uint256)、custom error、empty revertを区別します。分からないABIを勝手に推測して「それっぽい理由」を返さないようにしています。
x402の支払い境界
未払いの POST /preflight はHTTP 402になります。
payment-aware clientはchallengeを確認し、許可したnetwork / token / amount / payToであることを検証した上でauthorizationに署名し、再リクエストします。
ローカルテストでは資金を持たない一時キーとtest-only facilitatorを使っています。署名・金額・recipient・有効期間・nonceなどは確認しますが、実際のオンチェーン送金ではありません。
ここは重要で、syntheticなsettlement receiptが返ったからといって「USDCが動いた」とは言えません。
Engineとx402を統合する
統合後は、未払いならEngineを一度も実行しません。
一方、支払いverificationが成功した場合はEngineを実行し、その後settlementが成功して初めて結果を返します。
実際のintegration testでは次を確認しています。
- unpaid request → Engine/RPCを呼ばず402
- paid request → simulation resultを返す
- EVM revert → normalizedなrevert結果を返す
- verification failure → Engine/RPCを呼ばない
- settlement failure → simulation済みでも結果を開示しない
- 実際のserver entry pointを起動して同じフローを通す
CIの結果
記事作成時点で、GitHub ActionsのCore checksは成功しています。
npm run check はlint、typecheck、全テストを実行します。
テスト内訳は次の通りでした。
| Suite | Passed |
|---|---|
| Infra | 5 |
| Preflight Engine | 19 |
| x402 payment | 15 |
| Integration + Security | 13 |
| 合計 | 52 |
fail / skippedは0でした。
Security担当を別にしたら、ちゃんと問題が出た
今回、実装担当とは別のAI Agentにsecurity reviewをさせました。
そこで見つかったproduction blockerが2つあります。
1. settlement前に計算コストを使う
x402 middlewareのフローでは、payment verification後にhandlerが動き、その後settlementします。
つまりsettlementが最終的に失敗しても、RPC simulation自体は既に走っている場合があります。
同じauthorizationを並列利用された場合などに、高コストな処理だけ複数回実行させられる可能性があります。
現在これはresource admission、in-flight authorization、concurrency、timeoutなどを含めて修正中です。
2. 「役に立たない結果」に課金してよいのか
もう1つは技術というよりbilling policyの問題です。
例えばRPC providerが落ちて RPC_FAILURE になったとき、HTTPレスポンスとしてJSONを返せてしまうと、genericなpayment adapterではsettlement対象になる可能性があります。
一方、EVMの revert は「そのtransactionが失敗する」と事前に分かった有用なsimulation結果なので、無料にするとは限りません。
つまり、
success → charge?
revert → charge?
invalid_request → charge?
unsupported_network → charge?
RPC_FAILURE → charge?
をプロダクトとして明示的に決める必要があります。
このbilling semanticsも本番公開前のblockerとして修正しています。
ローカルでの確認
リポジトリ全体のチェックは次の構成です。
npm ci
npm run install:packages
npm run check
統合APIは環境変数にBase Sepolia RPCと受取先などを設定した上で、
npm run start:api
で起動します。
ただし現時点の統合環境はproduction serviceではありません。秘密鍵・mainnet資金を使わずに確認できるローカルfixture中心の実装です。
まとめ
今回やってみて、x402をHTTP APIに付けること自体よりも、何を「支払う価値のある結果」と定義するかの方が難しいと感じました。
また、transaction simulationも eth_call が成功しただけでは安全性の保証にはなりません。provider trust、reorg、mempool、asset delta、traceなど、見えていないものは多くあります。
だからAPIも「safe / unsafe」を雑に返すのではなく、観測できた事実とlimitationsを返す設計にしています。
現在はproduction blockerを修正中です。次はtestnetで実際のx402 settlementを確認し、その後、人間の承認を挟んでBase mainnetでの公開を検討します。
本番売上はまだ0ドルです。最終目標は、第三者のAI Agent / developerがこのAPIに本当にお金を払うのかを検証することです。