FABRKNT
Building with the Stack — 実アプリを作る
アプリケーションパターン
レッスン 6 / 11·CONTENT45 分80 XP
コース
Building with the Stack — 実アプリを作る
レッスンの役割
CONTENT
順序
6 / 11

レッスン5 — 最小限の EIP-7702 Sponsor サービスを Rust で作る

問い

Alice は EOA(ただの鍵ペア)を持ち、ETH を持たずに 1 クリックで 2 トークン swap したい。EIP-7702(Pectra 以降、2025-03 から mainnet)の手段:「この tx の間、私の EOA をこのコントラクトのコードを持つかのように扱え」という authorization に Alice がオフチェーン署名。Sponsor がそれを Type 4 tx に包んでガスを払う。Alice は atomic な batched call を得る。同じアドレス・同じ鍵・移行なし — どう組むか?

公開する HTTP API はこの形:

$ curl -X POST http://localhost:8080/sponsor \
    -H "Content-Type: application/json" \
    -d '{
      "user":              "0xAlice...",
      "delegate":          "0xMyAccountImpl...",
      "user_authorization": "0x04f8...",
      "calls": [
        { "target": "0xToken...", "value": "0x0", "data": "0xa9059cbb..." },
        { "target": "0xRouter...", "value": "0x0", "data": "0x38ed1739..." }
      ]
    }'
{ "tx_hash": "0xabc..." }

原理(最小モデル)

  • EIP-7702 のメカニクス(3 文)。 ① Tx type 4 が新フィールド authorization_list: Vec<SignedAuthorization> を運ぶ。② Authorization { chain_id, address (delegate), nonce } が EOA によって署名される。③ tx 実行時、各 authorization は その EOA のアカウントコード を 23 byte の delegation pointer(0xef0100 || delegate_address)に その tx の残りの間だけ 書き換える。EOA のストレージ・残高・アドレスはそのまま。
  • from = sponsorto = user 外側 tx は sponsor が払い、実行主体は delegate 化された user 側に立つ。authorization の nonce はユーザの EOA nonce、外側 tx の nonce は sponsor の nonce — 役割が分かれる。
  • MAGIC プレフィクスでドメイン分離。 Authorization::signature_hash が EIP-7702 専用 MAGIC を含む(同じ RLP が他の署名済みメッセージとして誤読されるのを防ぐ)。
  • replay 防止は authorization の nonce が担う。 tx 後にユーザの EOA nonce が変わるので、古い authorization は無効化される。提出 に nonce 鮮度をチェックすれば、submission 前の同期拒否で sponsor がガスを焼かずに済む。
  • 4337 より sponsorship が安い。 entry-point オーバーヘッドなし + 単一 tx(bundler マークアップなし)。

データ経路を 1 枚で:

flowchart TB
    User["Alice (EOA)"] -->|オフチェーンで Authorization 署名| AuthPayload["Authorization<br/>chain_id, delegate, nonce"]
    User -->|POST /sponsor| API
    AuthPayload -->|HTTP body| API["axum handler"]
    API -->|Type 4 tx を構築 user_auth 付き| Sponsor["Sponsor signer<br/>(ガス支払い)"]
    Sponsor -->|broadcast| Chain
    Chain -->|delegated code が<br/>Alice のアドレスとして走る| Effects["Token transfer +<br/>Router swap atomically"]

具体例

ユーザ側で authorization に署名(フロントエンド/wallet):

// FRONTEND / wallet コード — ユーザのブラウザ / MetaMask で走る、サーバではない。
use alloy::{
    eips::eip7702::Authorization,
    primitives::{Address, U256},
    signers::{local::PrivateKeySigner, SignerSync},
};

fn sign_authorization_for_user(
    user: &PrivateKeySigner,
    delegate: Address,
    chain_id: u64,
    user_nonce: u64,
) -> eyre::Result<alloy::eips::eip7702::SignedAuthorization> {
    let auth = Authorization {
        chain_id: U256::from(chain_id),
        address: delegate,
        nonce: user_nonce,
    };
    let sig = user.sign_hash_sync(&auth.signature_hash())?;
    Ok(auth.into_signed(sig))
}

シリアライズ(EIP-2718 envelope)— サービスに届く wire 形式:

let bytes = signed_auth.encoded_2718();
let hex = format!("0x{}", hex::encode(bytes));
// この hex 文字列を JSON ボディで送る

サービスが受領 → Type 4 tx 構築(from=sponsor, to=user, authorization_list 入り):

use alloy::{
    consensus::SignableTransaction,
    eips::eip2718::Decodable2718,
    eips::eip7702::SignedAuthorization,
    network::{TransactionBuilder, TransactionBuilder7702},
    primitives::{Address, B256, Bytes, U256},
    providers::{Provider, ProviderBuilder},
    rpc::types::TransactionRequest,
    signers::local::PrivateKeySigner,
    sol,
};

sol! {
    // 標準的な「複数 call を実行」インターフェース — delegate がこれを実装する
    function executeBatch(
        (address target, uint256 value, bytes data)[] calls
    ) external;
}

#[derive(Clone, serde::Deserialize)]
pub struct CallSpec {
    pub target: Address,
    pub value: U256,
    pub data: Bytes,
}

pub async fn build_sponsored_tx<P: Provider>(
    provider: &P,
    sponsor: &PrivateKeySigner,
    user: Address,
    user_authorization_hex: &str,
    calls: Vec<CallSpec>,
) -> eyre::Result<TransactionRequest> {
    // 1. ユーザの signed authorization を wire 形式から parse
    let auth_bytes = hex::decode(user_authorization_hex.trim_start_matches("0x"))?;
    let signed_auth = SignedAuthorization::decode_2718(&mut auth_bytes.as_slice())?;

    // 2. バッチ call を ABI エンコード
    let batch = executeBatchCall {
        calls: calls.into_iter().map(|c| (c.target, c.value, c.data)).collect(),
    };
    let calldata = batch.abi_encode();

    // 3. Type 4 tx を構築: from = sponsor、to = user (delegate される EOA)、
    //    auth_list はユーザの signed auth、calldata は delegate を呼ぶ
    let chain_id = provider.get_chain_id().await?;
    let nonce = provider.get_transaction_count(sponsor.address()).await?;
    let fee = provider.estimate_eip1559_fees().await?;

    let req = TransactionRequest::default()
        .with_from(sponsor.address())
        .with_to(user)
        .with_chain_id(chain_id)
        .with_nonce(nonce)
        .with_max_fee_per_gas(fee.max_fee_per_gas)
        .with_max_priority_fee_per_gas(fee.max_priority_fee_per_gas)
        .with_gas_limit(500_000)  // batch tx には余裕が必要; production は estimate
        .with_input(Bytes::from(calldata))
        .with_authorization_list(vec![signed_auth]);

    Ok(req)
}

提出(sponsor 鍵で署名・送信、確認は分離可能):

use alloy::providers::WalletProvider;

pub async fn submit_and_track<P: WalletProvider + Provider>(
    provider: P,
    req: TransactionRequest,
) -> eyre::Result<B256> {
    let pending = provider.send_transaction(req).await?;
    let hash = *pending.tx_hash();

    // sponsor サービスでは、即時に hash を返すのが普通正しい —
    // ユーザ UI が poll できる。サーバ側 confirmation が欲しいなら:
    // let receipt = pending.with_required_confirmations(1).get_receipt().await?;

    Ok(hash)
}

HTTP サービス(axum)— 全体 ~200 LOC:

use axum::{extract::State, routing::post, Json, Router};
use std::sync::Arc;

#[derive(serde::Deserialize)]
struct SponsorRequest {
    user: Address,
    user_authorization: String,
    calls: Vec<CallSpec>,
}

#[derive(serde::Serialize)]
struct SponsorResponse {
    tx_hash: B256,
}

#[derive(Clone)]
struct AppState<P: Provider + WalletProvider + Clone + 'static> {
    provider: P,
    sponsor: Arc<PrivateKeySigner>,
}

async fn sponsor_handler<P: Provider + WalletProvider + Clone + 'static>(
    State(state): State<AppState<P>>,
    Json(body): Json<SponsorRequest>,
) -> Result<Json<SponsorResponse>, (axum::http::StatusCode, String)> {
    let req = build_sponsored_tx(
        &state.provider,
        &state.sponsor,
        body.user,
        &body.user_authorization,
        body.calls,
    )
    .await
    .map_err(|e| (axum::http::StatusCode::BAD_REQUEST, e.to_string()))?;

    let hash = submit_and_track(state.provider.clone(), req)
        .await
        .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(SponsorResponse { tx_hash: hash }))
}

#[tokio::main]
async fn main() -> eyre::Result<()> {
    let sponsor: PrivateKeySigner = std::env::var("SPONSOR_KEY")?.parse()?;
    // Provider 例: QuickNode、Alchemy、Infura、または自前 Reth ノード。
    let provider = ProviderBuilder::new()
        .wallet(sponsor.clone())
        .connect(&std::env::var("RPC_URL")?)
        .await?;

    let state = AppState {
        provider,
        sponsor: Arc::new(sponsor),
    };

    let app = Router::new()
        .route("/sponsor", post(sponsor_handler))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

失敗例(誤解)

「サービスは入力 authorization を信頼してよい」は誤り — production では signed_auth.recover_authority()? == body.userガス支払い前に 検証する。さらに nonce 鮮度を確認しない sponsor は古い authorization で焼かれる(提出前に現ユーザ nonce と比較し、不一致は同期拒否)。delegate アドレスも allowlist 化する(未知 delegate への authorization = 悪意の可能性)。


ここまでで「7702 = type 4 + auth_list、sponsor は外側 tx + ガス、authorization が delegation pointer を一時的に書き換える」は着地した。ここから 4 ステップで組み立てる。コードは抜粋(実行時は補助コードが必要)。

🛑 予測。 なぜ Type 4 tx の from は Alice ではなく sponsor(Bob)でなければならないか。(答え: EIP-1559 の from は「この tx に nonce を使い、gas を payer として課金される人」を意味する。Bob がガスを払うので Bob が from。Alice の役割は authorization の署名者 — 「私の EOA に delegate コードを認可する」を表明するだけ。外側 tx の nonce と authorization の nonce は別物。)

ステップで組み立てる

Step 0: プロジェクトと依存

# Cargo.toml
[package]
name = "eip7702-sponsor"
version = "0.1.0"
edition = "2021"

[dependencies]
alloy = { version = "1.0", features = [
  "providers", "signer-local", "rpc-types", "network",
  "consensus", "eips", "sol-types"
] }
axum                = "0.7"
serde               = { version = "1", features = ["derive"] }
serde_json          = "1"
tokio               = { version = "1", features = ["full"] }
hex                 = "0.4"
eyre                = "0.6"

sponsor の秘密鍵は SPONSOR_KEY に、RPC は RPC_URL に。

Step 1-4

上の 4 ブロック: ① user が Authorization { chain_id, delegate, nonce } に署名(signature_hash は MAGIC でドメイン分離)→ encoded_2718 で wire 形式に → ② サービスが decode_2718 で復元、executeBatch 形式でバッチ call を ABI エンコード、with_authorization_list(vec![signed_auth]) で Type 4 化 → ③ send_transaction で sponsor 鍵で送信、即 hash 返却 → ④ axum で /sponsor を配線。alloy/examples/transactions/send_eip7702_transaction.rs の Bob+Alice 分離をサービス化したのと同じパターン。

production gap: authority 検証(recover_authority)/ nonce 鮮度 / 支出制限(ユーザ単位日次・call value 上限)/ delegate allowlist / watcher(L4 の replace-on-stuck をそのまま流用、同じ mempool)/ マルチユーザバッチング(auth_list = [Alice, Bob, Carol] + multicall delegate)/ フロントエンド SDK。

Step 5: 起動と疎通

$ RPC_URL=https://sepolia.infura.io/v3/$KEY \
  SPONSOR_KEY=0x... \
  cargo run --release

# 別ターミナルで sponsor リクエスト
$ curl -s -X POST http://localhost:8080/sponsor \
    -H "Content-Type: application/json" \
    -d @sample-sponsor-body.json
{ "tx_hash": "0x..." }

答え合わせ(Test gate)

sponsor 損失につながる失敗モード 2 つを潰す(anvil --hardfork prague か Pectra 後の forked mainnet で):

// tests/sponsor_invariants.rs
#[tokio::test]
async fn rejects_duplicate_authorization() {
    let svc = test_sponsor().await;
    let user = anvil_account(0);
    let auth = sign_authorization(&user, DELEGATE, 0).await;
    let calls = vec![simple_transfer(BOB, U256::from(1))];

    // 1 回目は着地
    let h1 = svc.sponsor(&auth, calls.clone()).await.unwrap();
    wait_for_inclusion(h1).await;

    // 同じ auth で 2 回目は、チェーンではなくサービスで拒否されるべき
    let err = svc.sponsor(&auth, calls).await.unwrap_err();
    assert!(matches!(err, SponsorError::ReplayedAuthorization));
}

#[tokio::test]
async fn gas_accounting_matches_actual_cost() {
    let svc = test_sponsor().await;
    let user = anvil_account(0);
    let sponsor_before = balance(svc.sponsor_address()).await;
    let user_before = balance(user.address()).await;

    let h = svc.sponsor(&fresh_auth(&user).await, vec![simple_transfer(BOB, U256::from(1))])
        .await.unwrap();
    let receipt = wait_for_receipt(h).await;
    let actual_cost = U256::from(receipt.gas_used) * receipt.effective_gas_price;

    let sponsor_after = balance(svc.sponsor_address()).await;
    assert_eq!(sponsor_before - sponsor_after, actual_cost);
    assert_eq!(balance(user.address()).await, user_before, "ユーザはガスを払わない");
}

両方 pass まで未完了。前者が崩れると replay 試行でガスを焼き、後者が崩れると支出制御が壊れる。

合格基準

  • 上記 2 テスト(replay 拒否 + sponsor 残高だけがガス分減る)が green。
  • なぜ 7702 が 4337 より sponsorship が安いか(entry-point オーバーヘッドなし + 単一 tx)を 1 文で言える。
  • 外側 tx の nonce と authorization の nonce の役割の違いを即答できる。

Drill

  1. signed_auth.recover_authority()? == body.user の検証を追加(15 分)。
  2. 提出前に現ユーザ nonce と auth の nonce 一致を検証(15 分)。
  3. /sponsor(user, user_authorization, calls) トリプルのリストに変更、全 auth + multicall で 1 tx(1.5 時間)。
  4. HashMap<Address, U256> でユーザ単位日次ガス上限(45 分)。
  5. L4 の watcher を持ってきて統合(30 分)。

📺 関連動画

_k5fKlKBWV4 | EIP-7702: a technical deep dive — lightclient (Devcon SEA 2024)
K2Tm1f8MIwg | Full code walkthrough of EIP-7702 in Revm — sponsor された tx を走らせるエンジン

まとめ(3行)

  • EIP-7702 は Type 4 tx + authorization_list で EOA のコードを その tx の残りの間だけ delegate に書き換える。from=sponsor(ガス払い)、to=user(実行主体)、auth の nonce はユーザの EOA nonce、外側 tx の nonce は sponsor の nonce。
  • サービスは authorization を decode → executeBatch で ABI エンコード → with_authorization_list で Type 4 化 → sponsor 鍵で送信。production では recover_authority 検証と nonce 鮮度を ガス支払い前 に。
  • Test gate: replay 防止(service 境界で同期拒否)+ ガス会計(sponsor 残高だけがガス分減る、ユーザ残高は変わらない)。次は VM 層のカスタム cheatcode。

次のレッスン(レッスン6)

Foundry スタイルのカスタム cheatcode を Rust で作る(custom precompile + 最小ハーネス)。differential テストで Rust precompile と参照実装 Solidity が 1000 件の fuzz 入力で同じ出力を返すことを assert する。