Ethernauta

EIP-7702 — atomic batched calls from an EOA

End-to-end EIP-7702 self-delegation flow on Sepolia. The EOA delegates its execution code to BatchExecutor for one transaction, calls execute((address,uint256,bytes)[]) against itself, and runs a batch of sub-calls atomically. The wallet signs the authorization tuple + outer type-4 tx and broadcasts in one click.

NetworkEthereum Sepolia (chain 11155111)
Delegation target0x5AAC53e7b782CCD32A083F938AEbA843731323Ee
Batch size2 calls

Sign in via Connect wallet at the top right to run this demo.

Needs the Ethernauta extension installed and Sepolia ETH in the connected account.

View on GitHub

What's happening

The dapp asks the wallet to send a type-4 (SetCode) transaction. The wallet:

  1. Fetches the EOA's current nonce.
  2. Signs an authorization tuple per delegation (hash = keccak(0x05 ‖ rlp([chainId, address, nonce+1]))), producing (yParity, r, s).
  3. Builds the outer type-4 transaction with the signed authorization_list.
  4. Signs the outer transaction with the EOA key.
  5. Broadcasts via eth_sendRawTransaction and returns the resulting tx hash.

Because the delegation lives for exactly one transaction, the EOA's code reverts to empty as soon as the tx is mined — no persistent smart-account state, no upgrade dance.

The code

import "./demo.css"
import {
  address,
  array,
  bytes,
  encode_function_call,
  tuple,
  uint256,
} from "@ethernauta/abi"
import { eip155_11155111 } from "@ethernauta/chain/eip155-11155111"
import {
  type Address,
  AddressSchema,
  type Bytes,
  BytesSchema,
  type Uint256,
  Uint256Schema,
  UintSchema,
} from "@ethernauta/core"
import { wallet_sendSetCodeTransaction } from "@ethernauta/eip/7702"
import { useProvider } from "@ethernauta/react"
import { encode_chain_id } from "@ethernauta/transport"
import { bytes_to_hex } from "@ethernauta/utils"
import { useState } from "react"
import { parse } from "valibot"
import { Button } from "../../components/button"
import { SignInHint } from "../../components/sign-in-hint"
import { use_session } from "../../lib/auth/use-session"
import { PROVIDER_STORE_KEY } from "../../lib/provider-store"

const SEPOLIA_CHAIN_ID = encode_chain_id({
  namespace: "eip155",
  reference: eip155_11155111.chainId,
})

// EIP-7702 expects the on-chain address as a uint256 hex of
// the chain reference. Sepolia = 11155111 = 0xaa36a7.
const SEPOLIA_CHAIN_REF_HEX = parse(
  UintSchema,
  `0x${eip155_11155111.chainId.toString(16)}`,
)

// contracts/src/BatchExecutor.sol deployed to Sepolia.
// Source + forge tests are in the `contracts/` package;
// re-deploy via `forge create` if you want your own copy.
const BATCH_EXECUTOR = parse(
  AddressSchema,
  "0x5AAC53e7b782CCD32A083F938AEbA843731323Ee",
)

// Two harmless target calls: send 0 wei with no calldata to
// two distinct burn-friendly addresses. The point is to
// prove the batch executes atomically — not to move value.
const TARGETS = [
  parse(
    AddressSchema,
    "0x1111111111111111111111111111111111111111",
  ),
  parse(
    AddressSchema,
    "0x2222222222222222222222222222222222222222",
  ),
]

const ZERO_VALUE = parse(Uint256Schema, "0x0")
const ZERO_DATA = parse(BytesSchema, "0x")

const call_tuple = tuple({
  to: address(),
  value: uint256(),
  data: bytes(),
})
const execute_args = [array(call_tuple)] as const

function encode_execute(
  calls: Array<{
    to: Address
    value: Uint256
    data: Bytes
  }>,
): Bytes {
  return parse(
    BytesSchema,
    bytes_to_hex(
      encode_function_call({
        name: "execute",
        args: execute_args,
        values: [calls],
      }),
    ),
  )
}

export function Delegate7702Demo() {
  const session = use_session()
  const owner = session?.address ?? null
  const provider = useProvider({ key: PROVIDER_STORE_KEY })
  const [tx_hash, set_tx_hash] = useState<string | null>(
    null,
  )
  const [loading, set_loading] = useState(false)
  const [error, set_error] = useState<string | null>(null)

  async function run_batch() {
    if (!owner || !provider) return
    set_loading(true)
    set_error(null)
    try {
      const calls = TARGETS.map((to) => ({
        to,
        value: ZERO_VALUE,
        data: ZERO_DATA,
      }))
      const calldata = encode_execute(calls)
      const hash = await wallet_sendSetCodeTransaction({
        to: parse(AddressSchema, owner),
        data: calldata,
        delegations: [
          {
            chainId: SEPOLIA_CHAIN_REF_HEX,
            address: BATCH_EXECUTOR,
          },
        ],
      })(provider.signer({ chain_id: SEPOLIA_CHAIN_ID }))
      set_tx_hash(hash)
    } catch (e) {
      set_error(
        e instanceof Error ? e.message : "Unknown error",
      )
    } finally {
      set_loading(false)
    }
  }

  return (
    <div className="delegate-7702-root">
      <div className="delegate-7702-rows">
        <Row
          label="Network"
          value={`${eip155_11155111.name} (chain ${eip155_11155111.chainId})`}
        />
        <Row
          label="Delegation target"
          value={BATCH_EXECUTOR}
          mono
        />
        <Row label="Batch size" value="2 calls" />
        {owner && (
          <Row label="Account" value={owner} mono />
        )}
        {tx_hash && (
          <Row label="Tx hash" value={tx_hash} mono />
        )}
      </div>
      {error && (
        <p className="delegate-7702-error">{error}</p>
      )}
      <div className="delegate-7702-actions">
        {!owner && <SignInHint />}
        {owner && (
          <Button onClick={run_batch} disabled={loading}>
            {loading
              ? "Signing & broadcasting…"
              : tx_hash
                ? "Run another batch"
                : "Delegate & batch-execute"}
          </Button>
        )}
        {tx_hash && (
          <a
            href={`https://sepolia.etherscan.io/tx/${tx_hash}`}
            target="_blank"
            rel="noreferrer"
            className="delegate-7702-explorer-link"
          >
            View on Sepolia Etherscan ↗
          </a>
        )}
      </div>
      {!owner && (
        <p className="delegate-7702-hint">
          Needs the Ethernauta extension installed and
          Sepolia ETH in the connected account.
        </p>
      )}
    </div>
  )
}

function Row({
  label,
  value,
  mono,
}: {
  label: string
  value: string
  mono?: boolean
}) {
  return (
    <div className="delegate-7702-row">
      <span className="delegate-7702-row-label">
        {label}
      </span>
      <span
        className={
          mono
            ? "delegate-7702-row-value is-mono"
            : "delegate-7702-row-value"
        }
      >
        {value}
      </span>
    </div>
  )
}

The BatchExecutor contract

contract BatchExecutor {
  struct Call { address to; uint256 value; bytes data; }
  error Unauthorized();
  error CallFailed(uint256 index, bytes returnData);
 
  function execute(Call[] calldata calls) external payable {
    if (msg.sender != address(this)) revert Unauthorized();
    for (uint256 i = 0; i < calls.length; ++i) {
      (bool ok, bytes memory ret) =
        calls[i].to.call{value: calls[i].value}(calls[i].data);
      if (!ok) revert CallFailed(i, ret);
    }
  }
}

Source + Foundry tests in contracts/src/BatchExecutor.sol. The msg.sender == address(this) guard is what makes this safe under 7702: after delegation, address(this) is the EOA, so only the EOA itself can drive the batch.