BchainPay logoBchainPay
EngineeringEthereumEIP-7702Account AbstractionStablecoins

EIP-7702 for merchant checkout: session keys and sponsored paymasters

How EIP-7702 turns a regular EOA into a temporary smart account for one checkout — session keys, sponsored gas, batched approvals and the failure modes we hit shipping it.

By Cipher · Founding engineer, BchainPay9 min read
Illustration for EIP-7702 for merchant checkout: session keys and sponsored paymasters

EIP-3009 solved gasless USDC checkout for a single transfer. EIP-4337 solved smart-account UX for users who were willing to migrate to a contract wallet. The interesting space — and the one most merchants actually live in — sits between them: shoppers who already have a MetaMask or Rabby EOA, who want to pay in any token (not just USDC), and who don't want to move their funds to a brand-new wallet address just to buy a pair of shoes.

EIP-7702 is the mechanism that closes that gap. Since the Pectra upgrade activated it on Ethereum mainnet, an EOA can attach contract code to itself for the duration of a single transaction (or indefinitely, if you want to). That tiny primitive — temporary code on an EOA — is enough to give a regular wallet batched calls, sponsored gas via a paymaster, atomic multi-step flows, and scoped session keys, without the user changing addresses.

This post is how we use EIP-7702 in the BchainPay checkout, the delegate contract design we landed on, and the specific things that will bite you if you treat 7702 as "4337 lite". It will not bite you gently.

What EIP-7702 actually does#

A new EIP-7702 transaction (type 0x04, SetCode) carries an authorization_list. Each entry is signed by an EOA and says: "For the duration of this transaction, treat my account as if its code is the contract at address X." When the transaction is mined, the EOA's code slot is set to a 23-byte delegation pointer (0xef0100 || X). After execution it can be cleared, kept for future transactions, or overwritten.

The mental model that works: an EOA can now opt into being a delegated smart account. Its address, its history and its existing balances are unchanged. Its behaviour, while delegated, is whatever the delegate contract says it is.

Three properties matter for payments:

  1. Atomic batching. A single user signature can perform approve(USDC, merchantSweep) and transfer(USDC, merchantSweep) in one call frame. No more two-popup checkouts.
  2. Sponsored gas. Because the EOA itself is now the entry point to a contract execution, an external relayer (or a 4337-style paymaster) can pay gas without owning the user's funds. The user pays in the token they hold.
  3. Scoped session keys. The delegate can authorise a secondary key to act on the EOA's behalf, but only for this merchant, this token, this amount cap, this expiry. The session key has no power outside that scope.

EIP-7702 does not replace EIP-3009, EIP-4337, or Permit2. It composes with all three. Most production checkouts will use 7702 + a Permit2 allowance + a sponsored relayer.

The minimum-viable delegate#

The delegate contract is the trust surface. The user is signing "please execute whatever code lives at address X as if it were me", so X has to be (a) immutable, (b) auditable in 5 minutes by a reasonable engineer, and (c) narrow.

Ours is ~180 lines of Solidity. The interface that matters for checkout:

contract MerchantDelegate {
  // Replay protection: monotonic per-EOA nonce for batched ops.
  mapping(address => uint256) public opNonce;
 
  // Session keys scoped to (merchant, token, cap, expiry).
  struct Session {
    address merchant;
    address token;
    uint128 spendCap;
    uint64  validUntil;
  }
  mapping(address => mapping(address => Session)) public sessions;
 
  function authorizeSession(
    address sessionKey,
    Session calldata s,
    uint256 nonce,
    bytes calldata sig
  ) external;
 
  function pay(
    address token,
    address to,
    uint256 amount,
    uint256 nonce,
    bytes calldata sig
  ) external;
 
  function batchPay(Call[] calldata calls, uint256 nonce, bytes calldata sig)
    external;
}

Three rules we baked in and would not relax:

  • No delegatecall to arbitrary targets. Every call the delegate makes is to a known token or to the merchant's sweep address. A generic "execute whatever the user signed" entry point is how you ship a wallet drainer by accident.
  • No upgradeability. The delegate is deployed once per chain and pinned by hash. Upgrading means a new delegate at a new address and a fresh user opt-in. Painful, deliberate, safe.
  • Per-op nonce is on the delegate, not the EOA. The EOA's transaction nonce only increments when the EOA itself sends a tx. When a relayer pays gas, the EOA's nonce stays put and the delegate's opNonce[user] is what protects against replay.

The checkout flow#

Concretely, a one-click "Pay with USDC" flow against the BchainPay API looks like this:

1. Merchant POST /v1/payment_intents
     → returns { intentId, chainId, token, amount, merchantSweep,
                 delegateAddr, sessionKeyPub, sessionExpiry }

2. Shopper signs ONCE (EIP-712, typed data):
     - SetCode authorization for delegateAddr (one-shot, this tx only)
     - authorizeSession(sessionKeyPub, { merchantSweep, token,
                                        spendCap=amount, validUntil })

3. Shopper POST /v1/payment_intents/:id/authorize
     { authorizationList, sessionAuthSig }

4. BchainPay relayer submits a single type-0x04 tx:
     - delegates the EOA to MerchantDelegate
     - calls authorizeSession(...) to install the session key
     - calls pay(USDC, merchantSweep, amount, nonce, sessionSig)

5. Indexer fires payment_intent.succeeded webhook with txHash and
   the receipt-side amount actually moved (post-fee).

From the shopper's perspective: one signature, one wallet popup, no ETH required, no contract wallet to migrate to, and the sweep address they paid is the same address they would have paid with a plain transfer. The merchant's reconciliation logic is unchanged.

Why session keys, not direct signing every time#

You can use 7702 without session keys — sign each pay() call individually with the EOA. We do this for one-off invoices. But for two flows it's worth installing a short-lived session key:

  • Multi-step checkout. Shopper picks shipping, applies a coupon, changes quantity. Each tweak re-prices the intent. With a session key scoped to spendCap = max(intentAmount) and `validUntil = now
    • 10 min, the relayer can submit the final pay()` without going back to the wallet for another signature.
  • Subscriptions. A 30-day session key with spendCap per period lets the merchant pull recurring USDC without a wallet popup every month, while the user retains the ability to revoke at any time by calling revokeSession(sessionKey) directly from their wallet.

The session key is a fresh ECDSA keypair the merchant generates server-side, scoped on-chain to a single token + recipient + cap + expiry. If the merchant's hot key is compromised, the blast radius is "that merchant's pending intents up to their per-user cap", not "the user's wallet". That's still serious, so we rotate session keys per merchant on a 24-hour schedule.

Replay, cross-chain and the address-poisoning problem#

Three failure modes we tripped on, in roughly the order they happen in production:

  1. Cross-chain replay of the SetCode authorization. EIP-7702 authorizations include a chain_id, and crucially chain_id = 0 means any chain. Never sign a chain_id = 0 authorization for a merchant flow. We reject them at the API. If a wallet hands us one, we re-prompt with the explicit chain id derived from eth_chainId.
  2. Stale authorizations. A SetCode authorization has no validBefore. If a user signs one and the relayer sits on it for six hours, it's still valid. We bind the authorization to a short-lived intentId by including the intent's keccak hash in the authorizeSession payload's Session struct (via a bytes32 intentTag field). If the intent expired, the session call reverts.
  3. Address poisoning into the session. A common attack is to dust the user's wallet with a transaction from an address that looks like the merchant's sweep address (same first/last 4 hex chars). If the front-end builds the Session.merchant field from "last address the user paid to", the attacker becomes the session recipient. Always derive merchantSweep server-side from the intent, never from the wallet's history.

Gas, paymasters and who actually pays#

The simple model: BchainPay's relayer EOA pays gas in ETH, and we deduct an equivalent USDC fee from the payment before sweeping to the merchant. That works on every chain that supports type-0x04 and needs no extra infrastructure.

Where it gets interesting is when you want a true 4337-style paymaster, e.g. to let the merchant sponsor gas as a marketing spend. EIP-7702 composes here cleanly: the delegate exposes a validatePaymasterUserOp-shaped hook, and you wire it into a standard 4337 EntryPoint. The user is still an EOA. The paymaster is still a 4337 contract. The delegate is the bridge.

A few practical numbers from our mainnet rollout:

  • A bare transfer is ~52k gas. A 7702 `delegate + authorizeSession
    • pay` in one tx is ~165k gas. The overhead is real but bounded.
  • The 23-byte delegation pointer costs ~13k gas to set per transaction if the EOA isn't already delegated. We persist delegation across sessions for repeat customers, dropping per-checkout cost back to ~110k.
  • Simulation (eth_call with the same payload) catches ~94% of failures before they consume gas. We refuse to broadcast any 7702 tx that doesn't simulate cleanly.

When to reach for 7702 vs the alternatives#

Use EIP-3009 when the user is paying in USDC (or another 3009 token) and you need the absolute minimum complexity. One signature, one transfer, done.

Use Permit2 + a relayer when you need an allowance you can re-use across multiple subsequent transactions, and the user is comfortable with a second on-chain step.

Use EIP-4337 when the user is happy to onboard a brand-new smart account and you want full account abstraction features (social recovery, multi-sig, plugin modules).

Use EIP-7702 when:

  • The user already has an EOA and isn't going to migrate.
  • You need atomic multi-step (e.g., approve then swap then transfer) inside one signature.
  • You want session keys without sending the user to a new address.
  • You're paying in a token that doesn't implement EIP-3009.

Key takeaways#

  • EIP-7702 lets a regular EOA temporarily behave like a smart account, unlocking batched calls, sponsored gas and session keys without changing the user's address.
  • Treat the delegate contract as the security boundary. Keep it minimal, non-upgradeable, and audited. No arbitrary delegatecall.
  • Always bind authorizations to a specific chain_id and to a short-lived intent tag. chain_id = 0 is a footgun; reject it.
  • Session keys belong on the delegate, scoped to (merchant, token, cap, expiry). Rotate them on a fixed schedule.
  • Compose, don't replace. Pair 7702 with EIP-3009 for USDC, with Permit2 for re-usable allowances, and with a 4337 paymaster when the merchant wants to sponsor gas.
  • Budget ~110-165k gas per checkout, simulate every tx before broadcast, and persist delegation for repeat customers.

Try it yourself

Spin up a sandbox merchant in under 60 seconds.

One REST endpoint, signed webhooks, five chains. No credit card required.

Related reading