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

レッスン4 — Wallet Backend を Rust で作る

問い

ユーザーが 1 分で 50 回送信しても、nonce を衝突させずに署名・送信し続けたい。さらにガス急騰時には stuck tx を置換してセッションの詰まりを防ぐ。並行送信・単調増加 nonce・stuck 処理を同時に扱うバックエンドをどう組むか?(Rust ~250 行: signer pool + nonce manager + send queue + replace-on-stuck + confirm watcher。)

公開する HTTP API はこの形:

$ curl -X POST http://localhost:7000/send \
    -H "Content-Type: application/json" \
    -d '{
      "from":  "0xAlice...",
      "to":    "0xBob...",
      "value": "0x16345785d8a0000",
      "data":  "0x"
    }'
{ "tx_hash": "0xabc...", "queued_at": "2026-05-04T12:34:56Z" }

1 回の POST で signer 取得 → nonce 予約 → ガス見積もり → 署名 → 送信 → 監視まで行う。30 秒で着地しなければ watcher が fee を引き上げて再送する。

原理(最小モデル)

  • 各アドレスは next nonce の真の source を 1 つだけ持つ — in-process 状態であって新しい RPC コールではない。 初回だけ provider.get_transaction_count(addr).pending() で初期化し、以降の予約はローカル Arc<Mutex<HashMap<Address, u64>>> で進む。forget でキャッシュ破棄 → 次回 RPC で再同期(回復手段)。
  • 送信と確認を分離する。 send_raw_transaction 後は即 return し、確認は別タスクの watcher に任せる。tx hash は「受け付けた」であって「着地した」ではない。
  • stuck は同一 nonce + 高 fee で置換する(待機でない)。 先行 nonce が詰まると後続が全部止まる。大半のノードは fee を ≥10% 上げない置換を拒否するので、bump は 25%(最低置換幅 10% より余裕)。
  • RPC 中はロックを保持しない。 watcher はキューをスナップショットしてから RPC を回す(キュー全体の直列化を避ける)。

データ経路を 1 枚で:

flowchart TB
    Client["HTTP client"] -->|POST /send| API["axum handler"]
    API -->|reserve nonce| NM["NonceManager<br/>(per-address)"]
    API -->|build & sign| Signer["PrivateKeySigner<br/>(env からロード)"]
    API -->|broadcast| Provider["Alloy Provider"]
    API -->|track| Q["pending queue<br/>(tx_hash, deadline, fee)"]
    Q -->|every 5s| Watcher["confirm watcher"]
    Watcher -->|landed?| Provider
    Watcher -->|stuck > 30s| Bump["bump fee 1.25x<br/>+ resubmit"]
    Bump --> Q

具体例

signer pool + nonce manager(コア不変条件: 各アドレスの next nonce はローカル 1 source):

use alloy_primitives::Address;
use alloy_signer_local::PrivateKeySigner;
use std::{collections::HashMap, sync::Arc};
use tokio::sync::Mutex;

pub struct SignerPool {
    inner: HashMap<Address, PrivateKeySigner>,
}

impl SignerPool {
    pub fn from_env() -> eyre::Result<Self> {
        // SIGNERS env 変数: comma 区切りの 0x プレフィクス付き秘密鍵
        let mut inner = HashMap::new();
        for hex in std::env::var("SIGNERS")?.split(',') {
            let signer: PrivateKeySigner = hex.trim().parse()?;
            inner.insert(signer.address(), signer);
        }
        Ok(Self { inner })
    }

    pub fn get(&self, addr: &Address) -> Option<&PrivateKeySigner> {
        self.inner.get(addr)
    }
}

#[derive(Clone)]
pub struct NonceManager {
    state: Arc<Mutex<HashMap<Address, u64>>>,
}

impl NonceManager {
    pub fn new() -> Self {
        Self { state: Arc::new(Mutex::new(HashMap::new())) }
    }

    /// `addr` の next nonce を予約、初回は RPC で初期化
    pub async fn reserve<P: alloy_provider::Provider>(
        &self,
        addr: Address,
        provider: &P,
    ) -> eyre::Result<u64> {
        let mut state = self.state.lock().await;
        let nonce = match state.get(&addr) {
            Some(&n) => n,
            None => provider.get_transaction_count(addr).pending().await?,
        };
        state.insert(addr, nonce + 1);
        Ok(nonce)
    }

    /// `addr` のキャッシュ nonce をリセット (回復不能な submission 失敗後に呼ぶ)
    pub async fn forget(&self, addr: Address) {
        self.state.lock().await.remove(&addr);
    }
}

ガス見積もり(EIP-1559、推定を手書きせず provider に)+ bump:

use alloy_eips::eip1559::Eip1559Estimation;

#[derive(Clone, Copy, Debug)]
pub struct GasParams {
    pub max_fee_per_gas: u128,
    pub max_priority_fee_per_gas: u128,
}

pub async fn estimate_gas<P: alloy_provider::Provider>(provider: &P) -> eyre::Result<GasParams> {
    let est: Eip1559Estimation = provider.estimate_eip1559_fees().await?;
    Ok(GasParams {
        max_fee_per_gas: est.max_fee_per_gas,
        max_priority_fee_per_gas: est.max_priority_fee_per_gas,
    })
}

pub fn bump(params: GasParams) -> GasParams {
    // 25% 引き上げ — 大半のクライアントの mempool 最低値 10% より余裕を持たせる
    GasParams {
        max_fee_per_gas: params.max_fee_per_gas * 125 / 100,
        max_priority_fee_per_gas: params.max_priority_fee_per_gas * 125 / 100,
    }
}

送信パス(nonce は署名前に予約、送信後は即 return):

use alloy_consensus::{TxEip1559, SignableTransaction};
use alloy_network::{TxSignerSync, TransactionBuilder};
use alloy_primitives::{Bytes, U256};
use alloy_rpc_types::TransactionRequest;
use std::time::{Duration, Instant};

#[derive(Clone)]
pub struct PendingTx {
    pub from: Address,
    pub nonce: u64,
    pub current_hash: alloy_primitives::B256,
    pub gas_params: GasParams,
    pub deadline: Instant,
    pub original_request: TransactionRequest,
}

pub async fn send_one<P: alloy_provider::Provider>(
    provider: &P,
    pool: &SignerPool,
    nm: &NonceManager,
    req: TransactionRequest,
) -> eyre::Result<PendingTx> {
    let from = req.from.ok_or_else(|| eyre::eyre!("from required"))?;
    let signer = pool.get(&from).ok_or_else(|| eyre::eyre!("unknown signer: {from}"))?;

    let nonce = nm.reserve(from, provider).await?;
    let gas = estimate_gas(provider).await?;
    let chain_id = provider.get_chain_id().await?;

    let req = req
        .with_nonce(nonce)
        .with_chain_id(chain_id)
        .with_gas_limit(req.gas.unwrap_or(100_000))
        .with_max_fee_per_gas(gas.max_fee_per_gas)
        .with_max_priority_fee_per_gas(gas.max_priority_fee_per_gas);

    let tx = req.clone().build(&signer.clone().into()).await?;
    let raw = tx.encoded_2718();
    let pending = provider.send_raw_transaction(&raw).await?;

    Ok(PendingTx {
        from,
        nonce,
        current_hash: *pending.tx_hash(),
        gas_params: gas,
        deadline: Instant::now() + Duration::from_secs(30),
        original_request: req,
    })
}

replace-on-stuck 付き confirm watcher(1 タスクが全 queued tx を監視、deadline 超過は bump + resubmit):

use std::collections::HashMap;
use tokio::sync::RwLock;
use tokio::time::sleep;

#[derive(Clone)]
pub struct PendingQueue {
    inner: Arc<RwLock<HashMap<alloy_primitives::B256, PendingTx>>>,
}

impl PendingQueue {
    pub fn new() -> Self { Self { inner: Arc::new(RwLock::new(HashMap::new())) } }

    pub async fn insert(&self, ptx: PendingTx) {
        self.inner.write().await.insert(ptx.current_hash, ptx);
    }
}

pub async fn watcher<P: alloy_provider::Provider + Clone>(
    provider: P,
    pool: SignerPool,
    queue: PendingQueue,
) {
    loop {
        sleep(Duration::from_secs(5)).await;

        // RPC 中に read lock を持たないようスナップショット
        let snapshot: Vec<PendingTx> = queue.inner.read().await.values().cloned().collect();

        for mut ptx in snapshot {
            // Inclusion チェック
            if let Ok(Some(_receipt)) = provider.get_transaction_receipt(ptx.current_hash).await {
                queue.inner.write().await.remove(&ptx.current_hash);
                tracing::info!(hash = ?ptx.current_hash, "landed");
                continue;
            }

            // Stuck? bump して resubmit
            if Instant::now() >= ptx.deadline {
                let bumped = bump(ptx.gas_params);
                let signer = pool.get(&ptx.from).expect("signer missing");
                let req = ptx.original_request
                    .clone()
                    .with_max_fee_per_gas(bumped.max_fee_per_gas)
                    .with_max_priority_fee_per_gas(bumped.max_priority_fee_per_gas);

                match req.build(&signer.clone().into()).await {
                    Ok(tx) => {
                        let raw = tx.encoded_2718();
                        if let Ok(p) = provider.send_raw_transaction(&raw).await {
                            let new_hash = *p.tx_hash();
                            let mut w = queue.inner.write().await;
                            w.remove(&ptx.current_hash);
                            ptx.current_hash = new_hash;
                            ptx.gas_params = bumped;
                            ptx.deadline = Instant::now() + Duration::from_secs(30);
                            w.insert(new_hash, ptx);
                            tracing::warn!("bumped + resubmitted");
                        }
                    }
                    Err(e) => tracing::error!(?e, "rebuild failed"),
                }
            }
        }
    }
}

HTTP API スケルトン(axum)— watcher を spawn して /send を配線:

use axum::{extract::State, routing::post, Json, Router};
use serde::{Deserialize, Serialize};

#[derive(Clone)]
pub struct AppState<P: alloy_provider::Provider + Clone + 'static> {
    pub provider: P,
    pub signers: Arc<SignerPool>,
    pub nonces: NonceManager,
    pub queue: PendingQueue,
}

#[derive(Deserialize)]
pub struct SendRequest {
    from: Address,
    to: Address,
    value: U256,
    data: Option<Bytes>,
    gas: Option<u64>,
}

#[derive(Serialize)]
pub struct SendResponse {
    tx_hash: alloy_primitives::B256,
}

async fn handle_send<P: alloy_provider::Provider + Clone + 'static>(
    State(state): State<AppState<P>>,
    Json(req): Json<SendRequest>,
) -> Result<Json<SendResponse>, (axum::http::StatusCode, String)> {
    let tx_req = TransactionRequest::default()
        .with_from(req.from)
        .with_to(req.to)
        .with_value(req.value)
        .with_input(req.data.unwrap_or_default())
        .with_gas_limit(req.gas.unwrap_or(100_000));

    let pending = send_one(&state.provider, &state.signers, &state.nonces, tx_req)
        .await
        .map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let hash = pending.current_hash;
    state.queue.insert(pending).await;
    Ok(Json(SendResponse { tx_hash: hash }))
}

#[tokio::main]
async fn main() -> eyre::Result<()> {
    tracing_subscriber::fmt::init();

    // Provider 例: QuickNode、Alchemy、Infura、または自前 Reth ノード。
    let provider = alloy_provider::ProviderBuilder::new()
        .connect(&std::env::var("RPC_URL")?)
        .await?;

    let state = AppState {
        provider: provider.clone(),
        signers: Arc::new(SignerPool::from_env()?),
        nonces: NonceManager::new(),
        queue: PendingQueue::new(),
    };

    // Watcher を spawn
    {
        let p = provider.clone();
        let s = (*state.signers).clone();  // Note: SignerPool に Clone が必要
        let q = state.queue.clone();
        tokio::spawn(async move { watcher(p, s, q).await });
    }

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

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

これで最小サービスは完成(インポート込みで約 250 行)。

失敗例(誤解)

「送信ごとに provider.get_transaction_count(from).await で nonce を取ればいい」は誤り — 並行する 2 つの送信が両方 nonce N を読み、両方 N で署名し、1 つしか着地しない(もう 1 つは mempool で拒否)。同様に「同じ nonce で再送信するだけ」も誤り(fee を ≥10% 上げない置換はノードが拒否、サイレントに失敗)。「eth_sendRawTransaction の戻り値を信じる」も誤り(tx hash は受け付けで、着地でない)。


ここまでで「ローカル nonce 1 source + 送信/確認の分離 + 同一 nonce 高 fee 置換」は着地した。ここから 5 ステップで組み立てる。コードは抜粋(実行時は補助コードが必要)。

🛑 予測。 「nonce 取得→署名→送信」を同一アドレスで 100ms 以内に 2 回実行すると何が壊れるか。(答え: 両方が同じ nonce N を読んで N で署名し、1 つだけ着地する。もう 1 つは「nonce too low / already known」で拒否され、ユーザの 2 件目の送信がサイレントに消える。ローカル nonce 予約はこの競合を mutex 下の nonce + 1 で潰す。)

ステップで組み立てる

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

# Cargo.toml
[package]
name = "wallet-backend"
version = "0.1.0"
edition = "2021"

[dependencies]
alloy-primitives    = "1.5"
alloy-provider      = "1.0"
alloy-rpc-types     = "1.0"
alloy-network       = "1.0"
alloy-signer        = "1.0"
alloy-signer-local  = "1.0"
alloy-consensus     = "2.0"
alloy-eips          = "1.0"
axum                = "0.7"
tokio               = { version = "1", features = ["full"] }
serde               = { version = "1", features = ["derive"] }
serde_json          = "1"
eyre                = "0.6"
tracing             = "0.1"
tracing-subscriber  = "0.3"

秘密鍵は環境変数 SIGNERS にカンマ区切りで渡す(SIGNERS=0xabc...,0xdef...)。RPC エンドポイントは RPC_URL で渡す。

Step 1-5

上の 5 ブロック: ① signer pool + nonce manager(pending() を使う、初回だけ RPC、forget は回復手段)→ ② ガス見積もり(estimate_eip1559_fees()、bump 25%)→ ③ 送信パス(nonce を署名前に予約、送信と確認を分離)→ ④ watcher(先にスナップショット、receipt=Some で着地判定、deadline 超過で bump+resubmit)→ ⑤ axum API(watcher を tokio::spawn)。署名層は差し替え可能(local / keystore / mnemonic / KMS、send フロー本体は保てる)。

production gap: 鍵カストディ(KMS/HSM/MPC へ)/ 冪等性(request_id)/ 鍵単位レート制限 / 永続キュー(DB/Redis)/ マルチ RPC ファンアウト / nonce ギャップ検出 / observability(pending_count 等)。

Step 6: 起動と疎通

$ RPC_URL=https://sepolia.infura.io/v3/$KEY \
  SIGNERS=0xabc...,0xdef... \
  cargo run --release

# 別ターミナルで送信テスト
$ curl -s -X POST http://localhost:7000/send \
    -H "Content-Type: application/json" \
    -d '{"from":"0xAlice...","to":"0xBob...","value":"0x16345785d8a0000","data":"0x"}'
{ "tx_hash": "0x...", "queued_at": "..." }

答え合わせ(Test gate)

最低ラインは不変条件 2 つ(roundtrip + 並行 nonce 単調性):

// tests/wallet_invariants.rs
use alloy::consensus::TxEnvelope;
use alloy::eips::Decodable2718;

#[tokio::test]
async fn signed_tx_roundtrips() {
    let svc = test_service().await;
    let req = SendRequest { from: ALICE, to: BOB, value: U256::from(1), data: vec![] };

    let signed_bytes = svc.sign_only(&req).await.unwrap();
    let decoded = TxEnvelope::decode_2718(&mut signed_bytes.as_slice()).unwrap();

    assert_eq!(decoded.recover_signer().unwrap(), ALICE);
    assert_eq!(decoded.tx().to(), Some(BOB));
    assert_eq!(decoded.tx().value(), U256::from(1));
}

#[tokio::test]
async fn no_nonce_gaps_under_concurrent_send() {
    let svc = test_service().await;
    let base = svc.next_nonce(ALICE).await;

    let handles: Vec<_> = (0..50)
        .map(|_| {
            let svc = svc.clone();
            tokio::spawn(async move { svc.send(stub_request(ALICE)).await.unwrap() })
        })
        .collect();
    let mut nonces: Vec<u64> = futures::future::try_join_all(handles).await.unwrap()
        .into_iter().map(|r| r.nonce).collect();
    nonces.sort();

    let expected: Vec<u64> = (base..base + 50).collect();
    assert_eq!(nonces, expected, "nonce は連続かつユニークである必要がある");
}

両方 pass まで未完了。前者が崩れると不正 tx を作り、後者が崩れると nonce 詰まりで送信が停止する。

合格基準

  • 上記 2 テスト(roundtrip + 50 並行送信で nonce base..base+50 に欠損/重複なし)が green。
  • なぜローカル nonce 状態が核か(nonce ごとの RPC 往復なしで並行送信)を 1 文で言える。
  • bump 幅を 25% にする理由(最低置換幅 10% より余裕)を即答できる。

Drill

  1. SendRequestrequest_id を足し request_id → tx_hash を 1 時間キャッシュ(重複 POST にキャッシュ hash を返す)(30 分)。
  2. /send を per-from semaphore(max 4 並行)でラップ、超過は 429(30 分)。
  3. PendingTx を insert 時に Redis、着地時に削除、起動時に復元(1.5 時間)。
  4. 2 provider に send_raw_transaction をブロードキャストし最初の Ok を返す MultiProvider(1 時間)。
  5. POST /cancel { from, nonce } を追加(同 nonce で 50% 引き上げた 0-value 自送)(1 時間)。

📺 関連動画

wJnywGB33O4 | Georgios Konstantopoulos — Foundry, a portable, fast and modular toolkit (Foundry の tx パイプライン内で使われている同じ Alloy + Rust signer 機構)

まとめ(3行)

  • wallet backend の核はローカル nonce 状態 — 各アドレスの next nonce を in-process 1 source に置き、mutex 下の nonce + 1 で並行送信の競合を潰す(送信ごとの RPC 往復なし)。
  • 送信と確認を分離: send_one は即 return、別タスクの watcher が receipt をポーリングし deadline 超過で同一 nonce + 25% bump で置換(待機でなく置換 — 先行詰まりが後続を止めるから)。
  • Test gate: tx エンコード roundtrip + 並行下の nonce 単調性。次は認証層の EIP-7702 sponsor。

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

最小限の EIP-7702 Sponsor サービス(Type 4 tx + paymaster パターン)。replay 防止(同じ auth tuple は 2 度 sponsor できない)と gas 会計(sponsor が払い、ユーザは 0)で担保する。