レッスン9 — ドリル: FillProvider チェーン経由でエンドツーエンドの署名済 tx
問い
読むだけではリハーサル、手を動かすことで記憶。実際の署名者を ProviderBuilder に配線、Anvil に対して署名済み tx を送信、FillProvider チェーンが nonce / gas / chain-id / 署名をスタック順に処理するのを観察。Provider + Network + Signer 3 チェーンの総決算 — どう組み合わさるか?
原理(最小モデル)
PrivateKeySignerでsign_hash直接.k256::sign_recoverableで(r, s, v)全部得られる、追加 recovery 不要。- 4 Filler のスタック順序. NonceFiller → GasFiller → ChainIdFiller → WalletFiller。「fill → sign」順序は譲れない(署名ハッシュ計算前に nonce/gas/chain_id 揃う必要)。
with_recommended_fillers()+.wallet(signer). nonce + gas + chain-id 自動 + 署名層追加。- 3 チェーン総合. Provider チェーン(
ProviderBuilder)+ Network チェーン(TransactionBuilderの.with_to/.with_value)+ Signer チェーン(.wallet())が 1 実行可能プログラム。 - Filler 外すと失敗. NonceFiller なしだと "missing nonce"、GasFiller なしだと "missing gas" 等。各 filler がギャップを 1 つずつ埋める証拠。
- タワー型の型レベル具現化.
FillProvider<JoinFill<JoinFill<JoinFill<JoinFill<Identity, GasFiller>, NonceFiller>, ChainIdFiller>, WalletFiller<EthereumWallet>>, RootProvider, Ethereum>がスタック順序を型でエンコード。
具体例
セットアップ:
# ターミナル 1: Anvil(10 アカウントを prefund)
anvil
# ターミナル 2: プロジェクト
cargo new alloy-signer-drill --bin
cd alloy-signer-drill
Cargo.toml:
[dependencies]
alloy = { version = "0.x", features = ["full", "provider-http", "signer-local"] }
tokio = { version = "1", features = ["full"] }
eyre = "0.6"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ドリル 1(ハッシュ直接署名):
use alloy::primitives::{B256, keccak256};
use alloy::signers::{Signer, local::PrivateKeySigner};
#[tokio::main]
async fn main() -> eyre::Result<()> {
tracing_subscriber::fmt().with_env_filter("info").init();
let signer = PrivateKeySigner::random();
let signer_addr = signer.address();
println!("signer address: {signer_addr}");
let message = b"hello, alloy";
let hash = keccak256(message);
let sig = signer.sign_hash(&hash).await?;
let recovered = sig.recover_address_from_prehash(&hash)?;
assert_eq!(recovered, signer_addr);
println!("recovered: {recovered} (matches: {})", recovered == signer_addr);
Ok(())
}
ドリル 2(FillProvider + 実 tx 送信):
use alloy::providers::{Provider, ProviderBuilder};
use alloy::primitives::{Address, U256, address};
// ドリル 2: FillProvider 経由で実際に署名済みの tx を送る
let funded_pk = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
let funded_signer: PrivateKeySigner = funded_pk.parse()?;
let funded_addr = funded_signer.address();
println!("funded sender: {funded_addr}");
let provider = ProviderBuilder::new()
.with_recommended_fillers() // nonce + gas + chain_id
.wallet(funded_signer) // 署名
.on_http("http://localhost:8545".parse()?);
// 新規アドレスに 1 ETH 送信
let recipient: Address = address!("000000000000000000000000000000000000beef");
let value = U256::from(1_000_000_000_000_000_000u128); // 1 ETH
let pending = provider
.send_transaction(
alloy::rpc::types::TransactionRequest::default()
.with_to(recipient)
.with_value(value)
)
.await?;
let receipt = pending.get_receipt().await?;
println!("tx hash: {:?}", receipt.transaction_hash);
println!("status: {:?}", receipt.status());
let recipient_balance = provider.get_balance(recipient).await?;
println!("recipient balance: {recipient_balance}");
期待出力:
signer address: 0x... (ランダム)
recovered: 0x... (matches: true)
funded sender: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
tx hash: 0x...
status: true
recipient balance: 1000000000000000000
4 Filler の処理表:
| Filler | 埋めたフィールド | 処理 |
|---|---|---|
| NonceFiller | nonce | eth_getTransactionCount(from, "pending") 呼ぶ |
| GasFiller | gas / gasPrice / maxFeePerGas + maxPriorityFeePerGas | eth_estimateGas + eth_gasPrice(or eth_feeHistory)呼ぶ |
| ChainIdFiller | chainId | eth_chainId を 1 度呼んでキャッシュ |
| WalletFiller | signature(TxEnvelope 化) | SignableTransaction 構築 + signer.sign_transaction() + 署名取り付け |
順序が重要: WalletFiller は他の filler の 後 に走る(署名ハッシュ計算前に nonce/gas/chain_id 揃う必要)。
Filler 外して失敗観察:
let provider_no_fillers = ProviderBuilder::new()
.wallet(funded_signer.clone()) // wallet のみ
.on_http("http://localhost:8545".parse()?);
let result = provider_no_fillers
.send_transaction(
TransactionRequest::default()
.with_to(recipient)
.with_value(U256::from(1_000_000_000_000_000_000u128))
)
.await;
println!("no-filler result: {:?}", result);
エラー: "missing nonce" or "missing gas" or "missing maxFeePerGas"。filler が実際に作業している証拠。
最終 Provider 型(任意観察):
FillProvider<JoinFill<JoinFill<JoinFill<JoinFill<Identity, GasFiller>, NonceFiller>, ChainIdFiller>, WalletFiller<EthereumWallet>>, RootProvider, Ethereum>
左から右に読む: FillProvider が内側プロバイダラップ、JoinFill<...> チェーンが 4 filler スタック、Wallet 最外で送信時最後適用。型レベルでスタック順エンコード。
失敗例(誤解)
「WalletFiller を NonceFiller 前に置いてもよい」— 致命的。署名ハッシュは nonce / gas / chain_id を含む → これらが埋まる前に署名すると無効署名。fill → sign 順序譲れない。
「with_recommended_fillers() だけで wallet 不要」— 間違い。署名がない → TxEnvelope 化できない → broadcast 不可能。両方必要。
「Anvil の prefund 秘密鍵は本番で使える」— 絶対 NG。Anvil の dev 鍵は 公開済み、本番で使うと即盗難。Anvil dev 鍵はテストのみ。
ステップで組み立てる
Step 1: PrivateKeySigner::sign_hash を直接
k256::sign_recoverable で (r, s, v) 取得、recover_address_from_prehash で検証。
Step 2: ProviderBuilder.with_recommended_fillers().wallet(signer)
4 filler スタック + 署名層追加。
Step 3: 実 tx 送信
TransactionRequest::default().with_to(recipient).with_value(value) + send_transaction().get_receipt()。
Step 4: 4 Filler 処理表
NonceFiller / GasFiller / ChainIdFiller / WalletFiller の RPC 呼び出しと埋めるフィールド。
Step 5: Filler 外して失敗観察
NonceFiller なしで "missing nonce"、各 filler のギャップ埋め確認。
Step 6: 最終 Provider 型のタワー観察
FillProvider<JoinFill<JoinFill<...>>> がスタック順を型でエンコード。
答え合わせ
PrivateKeySignerで recovery-id 不要、AwsSignerで必要な理由:PrivateKeySigner=k256::sign_recoverableが(r, s, v)全部返す → 追加処理なし。AwsSigner= AWS KMS が(r, s)のみ →vは v=0/v=1 試して キャッシュ済み address を再現するほう を選ぶ。クラウド署名のコスト。- 4 Filler の実行順と RPC 呼び出し: NonceFiller →
eth_getTransactionCount、GasFiller →eth_estimateGas+eth_gasPrice(oreth_feeHistory)、ChainIdFiller →eth_chainId(キャッシュ)、WalletFiller →signer.sign_transaction。fill → sign 順序が譲れない。 WalletFillerが filler チェーン最後の理由: 署名ハッシュ計算は nonce / gas / chain_id を含む → これらが埋まる前に署名すると 無効署名。NonceFiller より前に走らせると署名 = 古い nonce → ネットワークが reject。fill → sign 順序、譲れない。
合格基準
PrivateKeySigner.sign_hashの(r, s, v)取得を即答できる。- 4 Filler の処理表(埋めるフィールド + RPC 呼び出し)を書ける。
- WalletFiller が最後である理由を 1 文で説明できる。
- Filler 外したときの "missing nonce" / "missing gas" 失敗パターンを言える。
- 最終 Provider 型のタワー(
FillProvider<JoinFill<JoinFill<...>>>)が型レベルでスタック順エンコードと理解している。
まとめ(3行)
- 3 チェーン総決算 = Provider(
ProviderBuilder)+ Network(TransactionBuilderの.with_to/.with_value)+ Signer(.wallet())が 1 実行可能プログラム。 - 4 Filler スタック(NonceFiller → GasFiller → ChainIdFiller → WalletFiller)= fill → sign 順序譲れない、署名ハッシュ計算前に nonce/gas/chain_id 揃う必要。
- 最終 Provider 型タワーが型レベルでスタック順エンコード、Filler 外すと "missing X" で fail = 各 filler のギャップ埋めが実証される、dapp / MEV ボット / インデクサが本番投入する標準パターン。