← 一覧に戻る

x402 入門(1)

x402 という決済プロトコルが Coinbase から出ていて、AI エージェント向けの「都度課金できる HTTP」みたいな文脈でよく見かけます。SDK を import すれば動く、というのは触ってみればすぐわかるのですが、ヘッダの中で具体的に何が起きているのか を自分の手で確かめないと、なんとなくのままで終わってしまいそうでした。

なので、SDK を一切使わず Python の stdlib だけで「クライアントが署名を作って投げ直す」ところまでをゼロから書いてみました。今回触ったのはクライアント (ユーザー) 側だけ で、リソースサーバーや facilitator は既存のものに任せています。また私は専門家ではなく学習者であることは最初に明記させていただきます

リポジトリは HibikiAikawa/x402-demo にあります。今回の主役は python-inspector/inspector.py です。AIに手伝ってもらった部分もありますが、大部分自分で書いています。今の時代には珍しく人間味があっていいかなと思ってそのまま載せているので読み辛いかもしれないです。

x402 ってそもそも何?

ざっくり言うと HTTP 402 Payment Required を実際に使うためのプロトコル です。HTTP のステータスコードに 402 が予約されていますが、現実にはほとんど使われていません。これを使って、「課金が必要なエンドポイントには 402 を返す」「クライアントは支払いの署名を作ってヘッダに乗せてリトライする」というフローを規格化したのが x402 です。

x402 のフロー図 (出典: x402-foundation/x402 リポジトリ)

フロー全体はこんな感じです。

  1. クライアントが保護されたリソースに GET を投げる
  2. サーバーは 402 Payment Required を返す。PAYMENT-REQUIRED ヘッダに「いくら・どこのチェーン・誰に払う・どの方式 (scheme) で」が base64 JSON で乗っている
  3. クライアントは指定された方式で支払いを準備する。今回のデモでは USDC の transferWithAuthorization を EIP-712 でオフチェーン署名 する
  4. 署名を PAYMENT-SIGNATURE ヘッダに乗せて同じ URL に再度 GET
  5. サーバーは facilitator に検証 (/verify) と決済 (/settle) を依頼する
  6. 決済が確定したら本来のレスポンスを返す

ここで重要なのは、クライアントは EOA の秘密鍵で署名するだけで、ガス代も払わないし、自分でトランザクションを送らない ということです。USDC のスマートコントラクト自体に「他人の署名を持ってきた人が代理で送金を実行できる」機能 (EIP-3009) が組み込まれていて、それを利用しています。

facilitator というのは「クライアントの署名を検証して、実際にチェーンに送るのを代行してくれるサーバー」のことで、Coinbase が testnet 用に https://x402.org/facilitator を公開しています。リソースサーバー (= 課金 API 側) はこの facilitator にチェーン関連の処理を完全に外注できるので、Solidity も RPC も一切触らずに「支払い必須の API」を作れます。

今回やったこと / やらないこと

最初に範囲を明確にしておきます。フロー図でいうと、赤枠で囲った Client ↔ Server 部分だけ を自前で実装しました。

今回実装した範囲 (Client と Server の間の通信) を赤枠で示したフロー図

  • やる: クライアント (= 支払う側) の署名フローを Python stdlib + python-dotenv だけで書く(signやkeccakに関しても実装していますがこれはAIに書かせました)。GET /api402 PAYMENT-REQUIRED を受け取り、payment payload を作って PAYMENT-SIGNATURE 付きで再度 GET し、200 Ok を受け取るところまでが対象
  • やらない: リソースサーバー側 / facilitator 側の実装。図で言えば赤枠の右側、Server ↔ Facilitator の /verify /settle と、Facilitator ↔ Blockchain の tx 送信は、既存の @x402/express と Coinbase の testnet facilitator にそのまま乗っかる

なのでこの記事の射程は「402 を返された後にクライアントが何を計算してリトライしているか」だけです。サーバー側の検証ロジックや facilitator 内部 (チェーンに transferWithAuthorization を投げる部分) は深掘りしません。

EIP-712: 構造化データに署名する

x402 の支払いはチェーンに直接送らず、いったん オフチェーンで署名を作る だけです。このときに「何に対して署名したのか」を機械的にも人間にも明確にしたいので、EIP-712 が使われます。

ふつう Ethereum で「メッセージに署名」というと personal_sign で文字列に署名するパターンが多いですが、これだと MetaMask 上で「0x4f7a... というバイト列に署名します」みたいなまったく読めない表示になります。EIP-712 は 型情報込みの JSON のような構造化データに署名できる 仕組みで、ウォレットは「USDC を 0.001 だけ 0xabc... に送る権限を 10 分間与える」みたいに人間が読める形で表示できます。

ハッシュの組み立て方は決まっていて、最終的に署名するダイジェストはこうなります。

digest = keccak256( 0x1901 || domainSeparator || structHash )

ここでの登場人物は次の 3 つです。

  • 0x1901 — EIP-712 署名であることを示すマジックバイト
  • domainSeparator — 「どのコントラクトの、どのドメインに対する署名か」を固定するハッシュ
  • structHash — 実際に署名したい構造体 (今回なら TransferWithAuthorization) のハッシュ。中身は EIP-3009 で定義されるので、次の節で扱う

domainSeparator は固定の EIP712Domain 型を使って計算します。inspector.py からそのまま抜粋するとこうです。

domain_type_hash = keccak256(
    b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
)

domain_separator = keccak256(
    domain_type_hash
    + keccak256(domain["name"].encode())
    + keccak256(domain["version"].encode())
    + enc_uint256(domain["chainId"])
    + enc_address(domain["verifyingContract"])
)

enc_uint256 / enc_address はそれぞれ 32 バイト big-endian の左パディングをするだけのヘルパーです。EIP-712 では基本型はすべて 32 バイトに揃えて連結する、というのが原則になっています。

structHash の作り方も EIP-712 でレシピが決まっていて、

typeHash  = keccak256( "型名(型1 名前1,型2 名前2,...)" )
structHash = keccak256( typeHash || enc(field1) || enc(field2) || ... )

今回 x402 が署名対象として使う TransferWithAuthorization (中身は次の節で扱う) にこのレシピを当てはめると、こうなります。

struct_type_hash = keccak256(
    b"TransferWithAuthorization(address from,address to,uint256 value,"
    b"uint256 validAfter,uint256 validBefore,bytes32 nonce)"
)

struct_hash = keccak256(
    struct_type_hash
    + enc_address(authorization["from"])
    + enc_address(authorization["to"])
    + enc_uint256(int(authorization["value"]))
    + enc_uint256(int(authorization["validAfter"]))
    + enc_uint256(int(authorization["validBefore"]))
    + enc_bytes32(authorization["nonce"])
)

domainSeparatorstruct_hash が両方そろえば、あとは冒頭の式どおりに digest を組み立てて secp256k1 で ECDSA 署名するだけです。

digest = keccak256(b"\x19\x01" + domain_separator + struct_hash)
r, s, v = sign(PRIV, digest)
signature = r.to_bytes(32, "big") + s.to_bytes(32, "big") + bytes([v])

EIP-3009: ガスを払わずに USDC を動かす

次に 誰がチェーンに送るのか という問題です。普通の transfer だと、送る本人が ETH を持っていてガスを払う必要があります。これだと「USDC は持ってるけど ETH は持ってない」というユーザーは支払いができません。

そこで使われているのが EIP-3009: Transfer With Authorization です。USDC のような ERC-20 のうち、対応しているトークンには transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s) という関数が生えていて、

  • from が「tovalue だけ送ることを許可する」という権限を署名で発行する
  • それを誰か (= 第三者) がチェーンに transferWithAuthorization として送信する
  • コントラクトが署名を検証して、OK なら from から to に送金する

という分業ができます。支払う本人はチェーンに何も送らない (= ガスも払わない)。チェーンに送るのは facilitator です。

EIP-3009 が定義しているのは「TransferWithAuthorization という構造体」と「その署名を transferWithAuthorization 関数に渡せば代理送金が走る」というところまでです。署名そのものの作り方は前節の EIP-712 のレシピをそのまま使うので、ここで気にするのはフィールドの意味だけで OK です。

構造体のフィールドはこの 6 つです。

  • from — 送金元のアドレス (= 署名する本人)
  • to — 送金先のアドレス (= リソースサーバーの受取り口座)
  • value — 送る USDC の量 (最小単位の整数)
  • validAfter / validBefore署名の有効期限。たとえば「今から 10 分以内なら誰が送ってもいい」というふうに有効期限付きのチェックを書ける
  • nonce — 32 バイトのランダム値。同じ署名を 2 回使い回すこと (replay) を防ぐ

nonce だけはクライアント側で毎回生成する必要があります。

nonce_hex = "0x" + secrets.token_bytes(32).hex()

ここまで揃えば、前節の EIP-712 のレシピで struct_hashdigest → ECDSA 署名と進めて、署名 (r || s || v の 65 バイト) ができます。クライアント側でやるのはここまでで、署名はそのまま HTTP ヘッダに乗せてサーバーに返します。あとは facilitator がチェーンに transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s) を投げてくれるので、署名した本人はガス代もブロードキャストも気にしなくていい、というのが EIP-3009 の旨味です。

全体のフロー (実際に動かしたときのログ)

理屈だけ書いても実感がわかないので、実際に uv run python inspector.py を流したときのログを並べていきます。リクエストは 2 回だけです。

1 回目: 支払いなしで GET /api/joke を投げる

conn = HTTPConnection("localhost", 4021)
conn.request("GET", "/api/joke")
resp = conn.getresponse()

返ってくるのはこれです (主要ヘッダのみ抜粋)。

status 402
Content-Type     : application/json; charset=utf-8
PAYMENT-REQUIRED : eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVp...

サーバーは「金払えよ」とだけ言って、本文は空 (Content-Length: 2{}) でした。情報は全部 PAYMENT-REQUIRED ヘッダの base64 JSON に詰まっています。デコードするとこうなっていました。

{
  "x402Version": 2,
  "error": "Payment required",
  "resource": {
    "url": "http://localhost:4021/api/joke",
    "description": "Premium joke endpoint",
    "mimeType": "application/json"
  },
  "accepts": [
    {
      "scheme": "exact",
      "network": "eip155:84532",
      "amount": "10000",
      "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
      "payTo": "0x61f29cE6cc52e357e1673cb01f43aDdCB9C82aC4",
      "maxTimeoutSeconds": 300,
      "extra": { "name": "USDC", "version": "2" }
    }
  ]
}

中身を読み解くと、

  • amount: "10000" — USDC は小数点 6 桁なので 10000 = 0.01 USDC
  • network: "eip155:84532" — CAIP-2 形式で Base Sepolia (chain id 84532)
  • asset — Base Sepolia 上の USDC コントラクトアドレス。EIP-712 ドメインの verifyingContract に入る
  • payTo — 受取り先 EOA。サーバー側が .env で指定した値
  • maxTimeoutSeconds: 300validBefore をこの秒数まで先に取って良い、という上限
  • extra.name / extra.version — USDC コントラクトの EIP-712 ドメイン名とバージョン ("USDC" / "2")。これがあるおかげで verify で domainSeparator が一致する

accepts は配列なので「USDC でも DAI でも好きな方で払って」みたいな複数提示もできる形ですが、今回のサーバーは 1 個だけ提示しています。

署名: digest を作って ECDSA する

これらを EIP-712 のレシピに突っ込んで digest を作ります。前節のコードがそのまま走るだけです。実際に出てきた digest は次の値でした。

digest 0x8d2c36e141d689c79054bc7e12a04a906dc9320c92f636c57e6a74cd8de690b3

この 32 バイトに対して秘密鍵で ECDSA 署名すれば、サーバーに渡す 65 バイトの r || s || v が手に入ります。

2 回目: PAYMENT-SIGNATURE を付けて再 GET

署名と authorization を JSON に組み立てて、base64 で PAYMENT-SIGNATURE ヘッダに乗せます。

payment_payload = {
    "x402Version": payment_required_json["x402Version"],
    "resource": payment_required_json["resource"],
    "accepted": accepted,
    "payload": {
        "signature": sig_hex,
        "authorization": authorization,
    },
}
ps_b64 = base64.b64encode(json.dumps(payment_payload).encode()).decode()
headers2 = {"Accept": "*/*", "PAYMENT-SIGNATURE": ps_b64}
conn.request("GET", "/api/joke", headers=headers2)

accepted / authorization / sig_hex の中身は、それぞれ inspector.py のもっと前のステップで作っています。順に引用します。

accepted: 1 回目の応答の accepts 配列のうち、クライアントが採用した 1 件を抜き出したものです。「どの提示条件で払うことにしたか」をサーバーに伝えるために、丸ごとそのまま入れます。

accepted = payment_required_json["accepts"][0]

authorization: EIP-3009 の TransferWithAuthorization 構造体そのものです。accepted から payToamount を、自分の鍵から from を、有効期限と nonce はその場で組み立てます。

authorization = {
    "from": WALLET_ADDRESS,
    "to": accepted["payTo"],
    "value": accepted["amount"],
    "validAfter": valid_after,
    "validBefore": valid_before,
    "nonce": nonce_hex,
}

sig_hex: 上の authorization を EIP-712 で digest にして secp256k1 で署名した結果を、0x 付き hex にしたものです。r (32 バイト) + s (32 バイト) + v (1 バイト) で計 65 バイト = 132 hex 文字になります。

r, s, v = sign(PRIV, digest)
signature = r.to_bytes(32, "big") + s.to_bytes(32, "big") + bytes([v])
sig_hex = "0x" + signature.hex()

つまり 2 回目の GET では「自分が承認した条件 (accepted) と、それを反映した支払い指示 (authorization) と、その指示にサインした証拠 (sig_hex) を一括でサーバーに渡している」という構造です。サーバー側はこの 3 つがあれば facilitator に検証と決済を依頼できます。

サーバーが裏で facilitator に /verify/settle を投げて、決済が確定してから本来のレスポンスを返してきます。実際の応答はこうでした。

status 200
Content-Type     : application/json; charset=utf-8
PAYMENT-RESPONSE : eyJzdWNjZXNzIjp0cnVlLCJwYXllciI6IjB4M2E0YjA4ODA3...

PAYMENT-RESPONSE も同じく base64 JSON で、決済の receipt が乗っています。デコードするとこうなっていました。

{
  "success": true,
  "payer": "0x3a4b08807e7755dca03b4a8788515ea0a019405c",
  "transaction": "0x5dd1daac4caaa3ddce735204099 3be40059c8a28d62ec081bea4027954f82489",
  "network": "eip155:84532"
}

(※ transaction のスペースは表示上のもの。実際はスペースなしの 32 バイトの hex)

payer が私のエージェント EOA で、transaction が Base Sepolia 上の実トランザクションハッシュです。サーバーから見ると「facilitator が transferWithAuthorization を投げて、tx が確定した」という事実だけが帰ってきている形になります。

そして本文に、ようやく目的のレスポンスが入っていました。

{ "joke": "Why do programmers prefer dark mode? Because light attracts bugs." }

クライアント側で書いたコードは「402 を読む / EIP-712 で digest を作る / secp256k1 で署名する / ヘッダに base64 で乗せて再送する」だけで、チェーンに何も送っていない というのが実感できると思います。tx の送信と確認待ちは全部 facilitator が肩代わりしてくれていて、クライアントから見ると「2 回目の GET が同期的に 200 で返ってきた」だけ、というのがこのプロトコルの気持ちよさです。

まとめ

クライアントサイドでやっているのは「402 を受け取る/PAYMENT-REQUIRED で支払い条件を把握する」「PAYMENT-SIGNATURE で署名を返す」の 2 つだけで、実際の決済の重い部分は EIP-3009 と facilitator に押し付けられていてガス代もいらないという点でノンクリプトユーザーに優しく、幅広いユーザーに使われることを意識しているなと感じました。

今後、facilitator 側で何が起きているかとか、サーバー側がどうやって PAYMENT-RESPONSE の receipt を返してくるか、というのはまだ手で書き起こせていないので、後日また挑戦したいと思います。

参考