Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions modules/abstract-eth/scripts/eip7702/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# EIP-7702 On-Chain Harness (Hoodi)

Standalone scripts to sign and broadcast EIP-7702 transactions on Hoodi and
verify them on-chain. Uses the SDK's `lib/eip7702.ts` encoding core.

## Target
- **Network:** Hoodi (Pectra testnet), chain **560048**
- **Delegate implementation (deployed):** `0xd9b435f8aaa0d2c6d789eceeba8a1c3a5cf8089a`
- **Public RPC:** `https://rpc.hoodi.ethpandaops.io` (override with `HOODI_RPC_URL`)

## Prereqs — funded accounts
The harness needs Hoodi ETH on two accounts (testnet faucets: https://hoodi-faucet.pk910.de):
- **EOA** (delegating wallet): pays gas for delegation, `addSponsor`, and self-paid batch. Key in `EIP7702_EOA_PRIVATE_KEY` or `~/eip7702-eoa.env`.
- **Gas tank** (enterprise fee-address stand-in): pays gas for the sponsored batch. Key in `EIP7702_GAS_TANK_PRIVATE_KEY` or `~/eip7702-gastank.env`.

## Run (from `modules/abstract-eth`)
```bash
# 0) optional: analyze an existing tx
node -r ts-node/register/transpile-only scripts/eip7702/verify.ts
TX_HASH=0x... node -r ts-node/register/transpile-only scripts/eip7702/verify.ts

# 1) delegation: 0x04 set-code tx (EOA delegates to the implementation)
node -r ts-node/register/transpile-only scripts/eip7702/delegate.ts

# 2) self-paid batch: EOA sends to N recipients in one tx (EOA pays gas)
node -r ts-node/register/transpile-only scripts/eip7702/batch.ts

# 3) gas-tank sponsored batch: gas tank pays gas, values from EOA balance
node -r ts-node/register/transpile-only scripts/eip7702/sponsored.ts
```

Optional env: `RECIPIENT_A`, `RECIPIENT_B`, `AMOUNT_A`, `AMOUNT_B`.

## What each script does + analysis output
- **delegate.ts** — computes the authorization digest
`keccak256(0x05 ‖ rlp([chainId, address, nonce]))`, signs it with the EOA key,
computes the envelope hash `keccak256(0x04 ‖ rlp(fields))`, signs it, builds the
serialized 0x04 tx, broadcasts, prints the receipt (gas, cost, block), then
verifies on-chain that the EOA code == `0xef0100 ‖ implementation`.
- **batch.ts** — encodes `executeBatch((address,uint256,bytes)[])` for N recipients,
sends a type-2 tx from the EOA to itself (destination = EOA ⇒ runs delegate
code), prints receipt + balance deltas.
- **sponsored.ts** — EOA calls `addSponsor(gasTank)` on itself, then the **gas
tank** sends `sponsoredExecuteBatch(...)` (gas tank pays gas; values from EOA
balance). Prints receipts + balances.
- **verify.ts** — read-only: delegation indicator, implementation code length,
balances, and optional per-tx analysis via `TX_HASH`.

## Explorer
All tx hashes link to https://hoodi.beaconcha.in/tx/<hash>.

## Verified on-chain findings (Hoodi, 2026-09-17)
Three critical wire-format facts were confirmed by broadcasting real txs:
1. **Authorization nonce is a SCALAR** in the tuple RLP (geth rejects a nested
list: `expected input string or byte for uint64 ... AuthList[0].Nonce`).
`@ethereumjs/tx` v5's JSON path encodes it as a nested list — do NOT copy that.
2. **The authorization digest is over the IMPLEMENTATION address** (the
tuple's `address` field), not the delegating EOA. The node recomputes
`keccak256(0x05 || rlp([chainId, address, nonce]))` from the tuple, so
signing over the EOA recovers a different authority and the tuple is skipped.
3. **Self-delegation auth nonce = tx nonce + 1**: the spec increments the
SENDER's nonce before processing the authorization list, so the authority's
nonce at check time is tx nonce + 1.

Live on-chain proof (Hoodi):
- EOA `0xDad010e3A43A850B86bDaA61FA2cD580b600b3A0` delegated to
`0xd9b435f8aaa0d2c6d789eceeba8a1c3a5cf8089a` (code = `0xef0100...`).
- Self-paid batch: 2 recipients in 1 tx, EOA paid gas.
- Gas-tank sponsored batch: gas tank paid gas, values from EOA balance.
78 changes: 78 additions & 0 deletions modules/abstract-eth/scripts/eip7702/batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Phase 2 — self-paid batch send through the delegated EOA.
*
* After delegation (run delegate.ts first), the EOA sends ONE EIP-1559
* transaction to itself whose calldata calls `executeBatch` on the delegated
* implementation. Multiple recipients are paid in a single tx; the EOA pays
* the gas.
*
* Recipients: RECIPIENT_A / RECIPIENT_B env vars (defaults to two fresh
* random addresses, printed for on-chain lookup).
*
* Run: node -r ts-node/register/transpile-only scripts/eip7702/batch.ts
*/

import { ethers } from 'ethers';
import { encodeSetCodeExecuteBatch } from '../../src/lib/eip7702';
import {
CHAIN_ID,
loadKey,
getFeeData,
getNonce,
waitAndAnalyze,
verifyDelegation,
printBalances,
} from './common';

async function main(): Promise<void> {
const eoa = loadKey('EIP7702_EOA_PRIVATE_KEY', 'eip7702-eoa.env');
const recipientA = process.env.RECIPIENT_A || ethers.Wallet.createRandom().address;
const recipientB = process.env.RECIPIENT_B || ethers.Wallet.createRandom().address;

console.log('=== self-paid batch send (executeBatch) ===');
console.log('EOA:', eoa.address);
console.log('recipientA:', recipientA);
console.log('recipientB:', recipientB);

const delegated = await verifyDelegation(eoa.address);
if (!delegated) {
throw new Error('EOA is not delegated — run delegate.ts first');
}

const amountA = ethers.utils.parseEther(process.env.AMOUNT_A || '0.001');
const amountB = ethers.utils.parseEther(process.env.AMOUNT_B || '0.002');
const calls = [
{ to: recipientA, value: amountA.toString(), data: '0x' },
{ to: recipientB, value: amountB.toString(), data: '0x' },
];
const data = encodeSetCodeExecuteBatch(calls);
console.log(`\nbatch calldata (${data.length / 2 - 1} bytes): ${data.slice(0, 100)}...`);
console.log(`amountA: ${ethers.utils.formatEther(amountA)} ETH`);
console.log(`amountB: ${ethers.utils.formatEther(amountB)} ETH`);

const nonce = await getNonce(eoa.address);
const { maxPriorityFeePerGas, maxFeePerGas } = await getFeeData();
const gasLimit = ethers.BigNumber.from('200000');
console.log(`\nnonce: ${nonce}`);
console.log(`gasLimit: ${gasLimit.toString()}`);

const tx = await eoa.sendTransaction({
to: eoa.address, // destination = the delegated EOA -> runs delegate code
data,
value: 0,
nonce,
gasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
chainId: CHAIN_ID,
type: 2,
});
await waitAndAnalyze(tx.hash, 'self-paid batch tx');

await printBalances({ EOA: eoa.address, recipientA, recipientB });
}

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
124 changes: 124 additions & 0 deletions modules/abstract-eth/scripts/eip7702/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Shared config + helpers for the standalone EIP-7702 on-chain harness.
*
* Target: Hoodi (Pectra testnet, chain 560048). The delegate implementation
* was deployed at IMPLEMENTATION (see DEPLOYMENT.md in the BGMS repo).
*
* Keys are loaded from env vars or files in the home dir:
* EIP7702_EOA_PRIVATE_KEY -> ~/eip7702-eoa.env
* EIP7702_GAS_TANK_PRIVATE_KEY -> ~/eip7702-gastank.env
*/

import { ethers } from 'ethers';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

export const HOODI_RPC = process.env.HOODI_RPC_URL || 'https://rpc.hoodi.ethpandaops.io';
export const CHAIN_ID = 560048;
/** Deployed EIP7702Delegate on Hoodi (see BGMS DEPLOYMENT.md). */
export const IMPLEMENTATION = '0xd9b435f8aaa0d2c6d789eceeba8a1c3a5cf8089a';
export const EXPLORER = 'https://hoodi.beaconcha.in';

export const provider = new ethers.providers.JsonRpcProvider(HOODI_RPC);

/** Load a wallet from env var or a `KEY=0x...` file in the home dir. */
export function loadKey(envName: string, fileName: string): ethers.Wallet {
const fromEnv = process.env[envName];
if (fromEnv) {
return new ethers.Wallet(fromEnv, provider);
}
const p = path.join(os.homedir(), fileName);
if (fs.existsSync(p)) {
const line = fs.readFileSync(p, 'utf8').trim();
const key = line.split('=')[1];
if (key) {
return new ethers.Wallet(key, provider);
}
}
throw new Error(`No private key: set env ${envName} or create ~/${fileName} with KEY=0x...`);
}

/** Current EIP-1559 fee data from the RPC (with sane fallbacks). */
export async function getFeeData(): Promise<{ maxPriorityFeePerGas: ethers.BigNumber; maxFeePerGas: ethers.BigNumber }> {
const feeData = await provider.getFeeData();
const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.utils.parseUnits('1', 'gwei');
const maxFeePerGas = feeData.maxFeePerGas ?? ethers.utils.parseUnits('30', 'gwei');
return { maxPriorityFeePerGas, maxFeePerGas };
}

export async function getNonce(address: string): Promise<number> {
return provider.getTransactionCount(address, 'pending');
}

/** Sign a 32-byte digest with a wallet key; returns {r, s, yParity}. */
export function signDigest(
wallet: ethers.Wallet,
digest: Buffer
): { r: string; s: string; yParity: number } {
const sig = new ethers.utils.SigningKey(wallet.privateKey).signDigest('0x' + digest.toString('hex'));
return { r: sig.r, s: sig.s, yParity: sig.recoveryParam };
}

/** Wait for a tx, print exhaustive receipt analysis, return the receipt. */
export async function waitAndAnalyze(txHash: string, label: string): Promise<ethers.providers.TransactionReceipt> {
console.log(`\n=== ${label} ===`);
console.log(`tx hash: ${txHash}`);
console.log(`explorer: ${EXPLORER}/tx/${txHash}`);
const receipt = await provider.waitForTransaction(txHash, 1, 180000);
console.log(`status: ${receipt.status === 1 ? 'SUCCESS' : 'FAILED'}`);
console.log(`block: ${receipt.blockNumber}`);
console.log(`gas used: ${receipt.gasUsed.toString()}`);
console.log(`effective gas price: ${ethers.utils.formatUnits(receipt.effectiveGasPrice, 'gwei')} gwei`);
console.log(`total gas cost: ${ethers.utils.formatEther(receipt.gasUsed.mul(receipt.effectiveGasPrice))} ETH`);
return receipt;
}

/**
* Verify an EOA is delegated: its code must be exactly the EIP-7702
* delegation indicator `0xef0100 || implementation_address`.
*/
export async function verifyDelegation(eoa: string): Promise<boolean> {
const code = await provider.getCode(eoa);
const expected = '0xef0100' + IMPLEMENTATION.slice(2).toLowerCase();
const delegated = code.toLowerCase() === expected;
console.log(`\n=== delegation check: ${eoa} ===`);
console.log(`code: ${code}`);
console.log(`expected indicator: ${expected}`);
console.log(`delegated: ${delegated}`);
return delegated;
}

export async function printBalances(labels: Record<string, string>): Promise<void> {
console.log('\n=== balances ===');
for (const [label, addr] of Object.entries(labels)) {
const bal = await provider.getBalance(addr);
console.log(`${label}: ${ethers.utils.formatEther(bal)} ETH (${bal.toString()} wei)`);
}
}

/** ABI-encode `addSponsor(address)` for the delegate contract. */
export function encodeAddSponsor(sponsor: string): string {
return new ethers.utils.Interface(['function addSponsor(address)']).encodeFunctionData('addSponsor', [sponsor]);
}

/**
* Broadcast a raw signed transaction via eth_sendRawTransaction and return the
* tx hash. Used for the 0x04 set-code tx because ethers v5 cannot parse
* transaction type 4 (it would fail when building the sendTransaction result).
*/
export async function sendRawTransaction(rawHex: string): Promise<string> {
const res = await fetch(HOODI_RPC, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_sendRawTransaction', params: [rawHex] }),
});
const json = (await res.json()) as { result?: string; error?: { message?: string } };
if (json.error) {
throw new Error(`eth_sendRawTransaction failed: ${json.error.message ?? JSON.stringify(json.error)}`);
}
if (!json.result) {
throw new Error('eth_sendRawTransaction returned no result');
}
return json.result;
}
120 changes: 120 additions & 0 deletions modules/abstract-eth/scripts/eip7702/delegate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Phase 1 — EIP-7702 delegation (0x04 set-code tx).
*
* Signs the authorization digest with the EOA key, builds the 0x04
* transaction (authorization -> IMPLEMENTATION), signs the envelope, and
* broadcasts on Hoodi. Then verifies on-chain that the EOA's code is the
* delegation indicator `0xef0100 || implementation`.
*
* Run: node -r ts-node/register/transpile-only scripts/eip7702/delegate.ts
*/

import { ethers } from 'ethers';
import {
computeSetCodeAuthorizationDigest,
buildSetCodeTransaction,
getSetCodeTransactionSigningHash,
} from '../../src/lib/eip7702';
import {
CHAIN_ID,
IMPLEMENTATION,
provider,
loadKey,
getFeeData,
getNonce,
signDigest,
sendRawTransaction,
waitAndAnalyze,
verifyDelegation,
printBalances,
} from './common';

async function main(): Promise<void> {
const eoa = loadKey('EIP7702_EOA_PRIVATE_KEY', 'eip7702-eoa.env');

console.log('=== EIP-7702 delegation (0x04 set-code) ===');
console.log('EOA (delegating):', eoa.address);
console.log('implementation:', IMPLEMENTATION);
console.log('chainId:', CHAIN_ID);
await printBalances({ EOA: eoa.address });

const nonce = await getNonce(eoa.address);
// Self-delegation: the spec increments the SENDER's nonce before processing
// the authorization list, so the authorization nonce must be tx nonce + 1
// (verified on-chain on Hoodi).
const authNonce = nonce + 1;
const { maxPriorityFeePerGas, maxFeePerGas } = await getFeeData();
const gasLimit = ethers.BigNumber.from('100000');
console.log(`\nnonce: ${nonce} (authorization nonce: ${authNonce})`);
console.log(`maxFeePerGas: ${ethers.utils.formatUnits(maxFeePerGas, 'gwei')} gwei`);
console.log(`maxPriorityFeePerGas: ${ethers.utils.formatUnits(maxPriorityFeePerGas, 'gwei')} gwei`);
console.log(`gasLimit: ${gasLimit.toString()}`);

// 1) Authorization digest: keccak256(0x05 || rlp([chainId, address, nonce]))
// The `address` is the TUPLE's address field = the implementation address
// (the node recomputes the digest from the tuple, so signing over the EOA
// would recover a different authority and the tuple would be skipped).
const digest = computeSetCodeAuthorizationDigest({ chainId: CHAIN_ID, address: IMPLEMENTATION, nonce: authNonce });
console.log(`\nauthorization digest: 0x${digest.toString('hex')}`);
const authSig = signDigest(eoa, digest);
console.log(`authorization sig: r=${authSig.r}`);
console.log(` s=${authSig.s}`);
console.log(` yParity=${authSig.yParity}`);

const auth = {
chainId: CHAIN_ID,
address: IMPLEMENTATION,
nonce: authNonce,
yParity: authSig.yParity as 0 | 1,
r: authSig.r,
s: authSig.s,
};

// 2) Envelope hash: keccak256(0x04 || rlp(<fields without signature>))
const envelopeHash = getSetCodeTransactionSigningHash({
chainId: CHAIN_ID,
nonce,
maxPriorityFeePerGas: maxPriorityFeePerGas.toString(),
maxFeePerGas: maxFeePerGas.toString(),
gasLimit: gasLimit.toString(),
destination: eoa.address,
value: '0',
data: '0x',
authorizationList: [auth],
});
console.log(`\nenvelope hash: 0x${envelopeHash.toString('hex')}`);
const envSig = signDigest(eoa, envelopeHash);
console.log(`envelope sig: r=${envSig.r}`);
console.log(` s=${envSig.s}`);
console.log(` yParity=${envSig.yParity}`);

// 3) Build the serialized 0x04 tx and broadcast
const serialized = buildSetCodeTransaction({
chainId: CHAIN_ID,
nonce,
maxPriorityFeePerGas: maxPriorityFeePerGas.toString(),
maxFeePerGas: maxFeePerGas.toString(),
gasLimit: gasLimit.toString(),
destination: eoa.address,
value: '0',
data: '0x',
authorizationList: [auth],
yParity: envSig.yParity as 0 | 1,
r: envSig.r,
s: envSig.s,
});
const hex = '0x' + serialized.toString('hex');
console.log(`\nserialized 0x04 tx (${serialized.length} bytes): ${hex.slice(0, 100)}...`);

// Broadcast via raw eth_sendRawTransaction (ethers v5 cannot parse type 4).
const txHash = await sendRawTransaction(hex);
await waitAndAnalyze(txHash, 'delegation tx');

await verifyDelegation(eoa.address);
await printBalances({ EOA: eoa.address });
}

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Loading
Loading