Lab 5 — Build a Minimal EIP-7702 Sponsor Service in Rust
Question
EIP-7702 lets an EOA temporarily delegate to a smart contract. A sponsor service signs the delegation + pays the gas, enabling gasless UX for users. Build it as a 400-line Rust service.
Principle (minimum model)
- What EIP-7702 does. Adds a new tx type (0x04) where an EOA includes a
setCodeauthorization. The EOA temporarily behaves like a smart contract during the tx. - Sponsor flow. User signs an authorization (delegating to a contract address) → sends it to the sponsor → sponsor wraps it in a tx, pays gas, submits to the chain → user gets the result.
- Authorization signature.
keccak256(MAGIC + chain_id + nonce + delegate_address)signed with the user's key. Standard secp256k1. - Sponsor revenue model. Charge a fee (in stable coin / ERC-20 / off-chain subscription) or absorb cost as a feature. Tempo charges via a USDC pre-payment.
- Rate limiting + abuse prevention. Per-user nonce tracking; rate-limit by user; sanity-check the delegate contract before signing.
- Test gate. Forked anvil at a 7702-enabled block; submit a sponsored tx; assert the EOA executed with the delegated code.
Worked example + steps
Build a Minimal EIP-7702 Sponsor Service in Rust
Alice has an EOA (Externally Owned Account — a regular wallet keypair, not a smart contract). She wants to swap two tokens in one click without first holding ETH for gas, and without migrating to a smart-contract account. EIP-7702 (live on mainnet since the Pectra fork, March 2025) is how: she signs an off-chain authorization that says "for this transaction, treat my EOA as if it had this contract's code." A sponsor — your service — wraps that authorization in a transaction it pays gas for. Alice gets atomic batched calls, custom validation, session keys. Same address, same keys, no migration. ~200 lines of Rust below.
📌 Scope honesty. We sponsor single-user EIP-7702 transactions: the user signs an authorization off-chain, posts it + their intended calls to our service, the service wraps it in a Type 4 transaction it pays for, submits, returns the hash. Multi-user batching (the "bundler" pattern, where you pack N users into one chain tx) is a one-loop extension covered in the drill. Account-abstraction policy logic — spending limits, session keys, recovery — is what your delegate contract decides; the sponsor just relays.
EIP-7702 in 90 seconds
Without 7702: Alice's EOA → CALL → Contract
With 7702: Alice's EOA = (delegated to) → Contract code → executes AS Alice's address
The mechanics:
- Tx type 4 carries a new field:
authorization_list: Vec<SignedAuthorization>. - An
Authorization { chain_id, address (delegate), nonce }is signed by the EOA whose code should be set. - When the tx executes, each authorization in the list rewrites that EOA's account code to a 23-byte delegation pointer (
0xef0100 || delegate_address) for the rest of that transaction. - All the EOA's storage, balance, and address stay intact. The delegated code runs as if it were the EOA's own code.
That's it. Three sentences of protocol; the rest is plumbing.
Acceptance criteria
The lesson is complete when these tests pass (full code at the end in §Test gate):
rejects_duplicate_authorization— the sameSignedAuthorizationcannot be sponsored twice; the second/sponsorcall is rejected at the service boundary, before submission.gas_accounting_matches_actual_cost— after a successful sponsored tx, the sponsor's balance dropped by the actual gas paid; the user's balance is unchanged.
Test-first reading. The walkthrough below shows the Type 4 tx construction, signed-authorization handling, and gas-accounting paths these tests exercise.
What you'll build
$ 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..." }
flowchart TB
User["Alice (EOA)"] -->|sign Authorization off-chain| AuthPayload["Authorization<br/>chain_id, delegate, nonce"]
User -->|POST /sponsor| API
AuthPayload -->|HTTP body| API["axum handler"]
API -->|build Type 4 tx with user_auth| Sponsor["Sponsor signer<br/>(pays gas)"]
Sponsor -->|broadcast| Chain
Chain -->|delegated code runs<br/>AS Alice's address| Effects["Token transfer +<br/>Router swap atomically"]
Why a sponsor service vs. native smart-account
| Approach | User experience | Cost | Migration |
|---|---|---|---|
| Native smart account (4337) | Best — full custom validation | High — every tx pays bundler markup | User funds → new account |
| Pure 7702 (user pays own gas) | OK — gets batching but still needs ETH | Low — single tx | None — same EOA |
| 7702 + sponsor (this lesson) | Best for onboarding — no ETH needed | Sponsor eats gas (charge via app subscription, fees, etc.) | None — same EOA |
The sweet spot for app-team product work: existing EOA, smart-account features, your backend covers gas as a UX investment.
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"
Step 1: The authorization payload (what the user signs)
The user (Alice) signs an Authorization off-chain — your frontend or wallet does this. The bytes she sends to your service are the result. Reproducing the signing logic here so you can see what the service expects:
// FRONTEND / wallet code — runs in the user's browser / MetaMask, NOT on the server.
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))
}
Walk:
Authorization { chain_id, address, nonce }— three fields.addressis the delegate contract whose code Alice is authorizing for her EOA.auth.signature_hash()— the hash that gets signed. Per spec:keccak256(MAGIC || rlp([chain_id, address, nonce]))whereMAGICis0x05. Don't hand-compute this — Alloy does it; if you reach for keccak yourself you've already made a mistake.user_nonce— Alice's current EOA nonce. The authorization is consumed exactly when the tx that includes it is mined; replaying it requires Alice's nonce to still match. One-shot replay protection built in.
The serialized SignedAuthorization is what hits your service. EIP-2718 envelope encoding is the canonical wire format:
let bytes = signed_auth.encoded_2718();
let hex = format!("0x{}", hex::encode(bytes));
// Send this hex string in the JSON body
🔍 Find in repo. Open
alloy-eips/src/eip7702. FindAuthorization::signature_hash. Note theMAGICconstant — that's the EIP-7702 prefix that prevents the same RLP being misread as some other signed message. Domain separation, in one byte.
Step 2: Service receives + builds the Type 4 tx
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! {
// Standard "execute multiple calls" interface — the delegate must implement this
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. Parse the user's signed authorization from the wire format.
let auth_bytes = hex::decode(user_authorization_hex.trim_start_matches("0x"))?;
let signed_auth = SignedAuthorization::decode_2718(&mut auth_bytes.as_slice())?;
// 2. ABI-encode the batched call.
let batch = executeBatchCall {
calls: calls.into_iter().map(|c| (c.target, c.value, c.data)).collect(),
};
let calldata = batch.abi_encode();
// 3. Build the Type 4 tx: from = sponsor, to = user (the EOA being delegated),
// auth_list contains the user's signed auth, calldata invokes the 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 txs need headroom; estimate for prod
.with_input(Bytes::from(calldata))
.with_authorization_list(vec![signed_auth]);
Ok(req)
}
Walk:
SignedAuthorization::decode_2718— the inverse of theencoded_2718the user sent. One round-trip, no manual byte fiddling.from = sponsor, to = user— this is the heart of the design. The tx is from the sponsor (paying gas, signing the outer envelope) but to the user's EOA (which now executes as if it were the delegate). Anyone watching the tx sees Bob as initiator and Alice's address as the call target — and the logs come from Alice's address because that's the address running the delegate's code.with_authorization_list(vec![signed_auth])— the line that makes this a Type 4. Add multipleSignedAuthorizations here and you're now batching multiple users into one tx (drill 3).- The delegate's
executeBatchis a convention, not a protocol mandate. Most EIP-7702 delegate contracts in the wild expose a similar method (see OpenZeppelin's reference impl). Pick the convention your delegate uses.
Step 3: Submit + wait for inclusion
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();
// For a sponsor service, returning the hash immediately is usually right —
// the user's UI can poll. If you want server-side confirmation:
// let receipt = pending.with_required_confirmations(1).get_receipt().await?;
Ok(hash)
}
Walk:
provider.send_transaction(req)— Alloy signs with the wallet attached to the provider (your sponsor key) and broadcasts.reqalready hasfrom = sponsor.address(), so the wallet machinery picks the right key.- The watcher pattern from the wallet-backend lesson applies here too. A 30-second deadline + bumped fee on stuck txs would make this production-grade. We omit it for clarity; copy/paste the watcher from lesson 4 if you want it.
Step 4: Wire it together as an HTTP service
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()?;
let provider = ProviderBuilder::new()
.wallet(sponsor.clone())
// Provider examples: QuickNode, Alchemy, Infura, or your own Reth node.
.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(())
}
Whole service: ~200 LOC including the imports and the helper module. The frontend produces the user_authorization hex (Step 1 code, but in the user's wallet); the service handles everything else.
🔍 Find in repo. Open
alloy/examples/transactions/send_eip7702_transaction.rs. Note that the official example has Bob send + Alice authorize — the same separation we built. The official example just hardcodes everything in main(); we wrapped it as a service. Same pattern, productionized.
What's missing for production
| Gap | What real sponsor services do |
|---|---|
| Authorization validation | Decode + verify signed_auth.recover_authority() matches the claimed user before paying. We trust the input; production checks. |
| Replay protection | The user's nonce changes after this tx; check the authorization's nonce equals the current EOA nonce before submitting. Stale authorizations should be rejected synchronously. |
| Spending limits | Per-user daily caps. Per-call value caps. Allowlist of delegate addresses. (You pay the gas; you decide who you'll sponsor for.) |
| Watcher | Lesson 4's replace-on-stuck logic. EIP-7702 txs go through the same mempool; the bumping pattern is identical. |
| Multi-user batching | One tx with auth_list = [Alice's auth, Bob's auth, Carol's auth] and a multicall style delegate call. Lower per-user gas amortization. |
| Gas sponsorship accounting | Track how much you've spent per user; expose a /balance endpoint; refill via Stripe / on-chain top-ups / app subscription. |
| Delegate version pinning | Allow only specific delegate addresses (your audited set). Reject authorizations to unknown delegates — they could be malicious. |
| Frontend SDK | A TypeScript / Swift / Kotlin client that takes (provider, calls) and returns the user_authorization hex, abstracting the signing flow from app developers. |
The architecture you wrote — accept signed authorization + intent, wrap in Type 4, sponsor the gas, submit — is what every production EIP-7702 paymaster does. Companies like Privy, Dynamic, and Coinbase Smart Wallet are running variations of this exact code path.
Drill
- Validate authority. Add a sanity check:
signed_auth.recover_authority()? == body.user. Reject mismatches with 400. (15 min) - Check nonce freshness. Before submitting, fetch the user's current nonce and verify it equals the authorization's
nonce. (15 min) - Multi-user batching. Change
/sponsorto accept a list of(user, user_authorization, calls)triples. Build one tx with all authorizations and a multicall delegate call. What's the worst case if one user's auth is invalid mid-batch? (1.5 hours) - Spending cap. Track per-user gas spent in a
HashMap<Address, U256>. Reject sponsoring requests that would exceed a configurable per-day limit. (45 min) - Replace-on-stuck. Lift the watcher from lesson 4 and integrate it. (30 min — mostly copy/paste once you understand the pattern.)
Finish drill 5 and you have a sponsor service ready for an internal app. Add SDK + spending policy + observability and you're shipping the Privy-style developer experience.
Test gate
Per Test gate — every app in this tier ships with passing tests, this lesson's minimum gate covers the two failure modes that would burn the sponsor in production:
- Replay protection — the same
SignedAuthorizationcannot be sponsored twice. (The user signs once with nonce N; if your sponsor accepts the same signed authorization a second time after the first lands, the replay-protected tx will revert at the EVM layer but your service will have already paid gas to try.) Assert that the second/sponsorcall with the same auth tuple is rejected at your service boundary, before submission. - Gas-accounting honesty — after a successful sponsored tx, the sponsor's balance dropped by exactly the gas paid (within ε for tip variance), and the user's balance is unchanged. If your accounting is off, your spending-cap drill (drill 4) silently breaks.
Run both against an anvil --hardfork prague instance (or a forked mainnet at a post-Pectra block):
// 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))];
// First request lands
let h1 = svc.sponsor(&auth, calls.clone()).await.unwrap();
wait_for_inclusion(h1).await;
// Second request with same auth must be rejected at the service, not the chain
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, "user must pay zero gas");
}
The lesson is not complete until both pass. A sponsor that fails (1) burns money on every replay attempt; one that fails (2) cannot enforce per-user spending caps.
📺 Further watching
_k5fKlKBWV4 | EIP-7702: a technical deep dive — lightclient (Devcon SEA 2024)
K2Tm1f8MIwg | Full code walkthrough of EIP-7702 in Revm — the engine running your sponsored txs
🧭 Where you are now in the stack: you've shipped an authentication-layer application — delegated authorization implemented via 7702, with replay protection and gas-accounting honesty locked in by the test gate. Same concept as OAuth 2 and DocuSign signature delegation, expressed natively on Ethereum. Next lesson moves to the VM layer: a Foundry-style cheatcode via custom precompile.
Summary (3 lines)
- EIP-7702 sponsor = tx-type-0x04 wrapper that signs authorization + pays gas. Enables gasless UX.
- Flow: user signs auth → sponsor wraps + pays gas → submits. Revenue via USDC pre-payment (Tempo pattern) or feature absorption.
- Rate limiting + delegate sanity-check are mandatory. Test gate via forked anvil at a 7702-enabled block. Next: Foundry-style cheatcode.