Skip to content

Commit bbf71fb

Browse files
gregorydemayclaude
andcommitted
feat(cketh): read EIP-7702 delegations of deposit addresses in one eth_call
Adds a third deployless batcher program next to the ERC-20 and ETH balance batchers: it copies the first code word of each address with EXTCODECOPY and returns one word per address, which classifies the address as not delegated, delegated to a given delegate, or holding other code. The tests could not be committed ahead of the implementation: every one of them names an item introduced here, so the test-only commit would not compile. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X44H4nAU65nAexEUvG15MK
1 parent 49c0d40 commit bbf71fb

3 files changed

Lines changed: 305 additions & 7 deletions

File tree

rs/ethereum/cketh/minter/src/balance_scan/batcher/mod.rs

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ const MAX_CODE_SIZE: usize = 24_576;
5353
/// [EIP-3860]: https://eips.ethereum.org/EIPS/eip-3860
5454
const MAX_INITCODE_SIZE: usize = 2 * MAX_CODE_SIZE;
5555

56-
/// Maximum number of balance reads in a single deployless-batcher `eth_call`, for both batchers.
56+
/// Maximum number of entries in a single deployless-batcher `eth_call`, shared by all three
57+
/// batcher programs.
5758
///
5859
/// A create-style `eth_call` is bounded at both ends: the initcode it carries is rejected beyond
5960
/// [`MAX_INITCODE_SIZE`] (EIP-3860), and the blob the program `RETURN`s is rejected beyond
@@ -74,11 +75,13 @@ const MAX_INITCODE_SIZE: usize = 2 * MAX_CODE_SIZE;
7475
/// whole-call failure re-does that chunk on the next tick — and a chunk that always exceeds a
7576
/// provider limit fails *every* time, permanently stalling its pairs.
7677
///
77-
/// The value is derived from the ERC-20 encoding, yet it caps [`ETH_BATCHER_INITCODE`] batches
78-
/// too, with margin on both ends. The returned-blob term is shared — both programs return one
79-
/// word per entry — so the cap can never exceed `MAX_CODE_SIZE / WORD`; and the ETH initcode
80-
/// side is far looser than the ERC-20 one (one word per entry after a 78-byte program, so 1532
81-
/// reads) and never binds. `full_batch_of_eth_balance_reads_fits_both_node_limits` pins this.
78+
/// The value is derived from the ERC-20 encoding, yet it caps [`ETH_BATCHER_INITCODE`] and
79+
/// [`DELEGATION_BATCHER_INITCODE`] batches too, with margin on both ends. The returned-blob term
80+
/// is shared — all three programs return exactly one word per entry — so the cap can never
81+
/// exceed `MAX_CODE_SIZE / WORD`; and both single-word-argument programs have a far looser
82+
/// initcode side than the ERC-20 one (one word per entry after a 78- resp. 81-byte program, so
83+
/// 1532 entries) that never binds. `full_batch_of_eth_balance_reads_fits_both_node_limits` and
84+
/// `full_batch_of_delegation_reads_fits_both_node_limits` pin this.
8285
pub const MAX_CALLS_PER_BATCH: usize = {
8386
let by_initcode_size = (MAX_INITCODE_SIZE - BATCHER_INITCODE.len() - WORD) / (2 * WORD);
8487
let by_returned_code_size = MAX_CODE_SIZE / WORD;
@@ -109,6 +112,38 @@ pub const ETH_BATCHER_INITCODE: [u8; 78] = [
109112
0x61, 0x00, 0x0d, 0x56, 0x5b, 0x60, 0x00, 0x51, 0x60, 0x20, 0x02, 0x60, 0x60, 0xf3,
110113
];
111114

115+
/// Deployless delegation-batcher creation bytecode.
116+
///
117+
/// The sibling of [`ETH_BATCHER_INITCODE`] reading code instead of balances, executed the same
118+
/// way (create-style `eth_call`, `to` omitted). It reads its inputs from the calldata appended
119+
/// right after this bytecode (`[n][ address x n ]`, one 32-byte word each) and, for each address,
120+
/// copies the first 32 bytes of its code with `EXTCODECOPY` — which zero-pads beyond the code
121+
/// size, so every entry is a full word whether the account holds no code, an EIP-7702 designator
122+
/// (23 bytes) or a contract. No sub-calls, so like the ETH batcher nothing here can fail and the
123+
/// program has no revert path. It returns the prefixes as a flat `n x 32`-byte array (no ABI
124+
/// array header), classified positionally by [`decode_delegation_batch`].
125+
///
126+
/// The program is fixed regardless of `n` (only the appended args grow). It was assembled from
127+
/// the opcode listing in `delegation_initcode_matches_readable_assembly` and validated against a
128+
/// live anvil node; see `rs/ethereum/cketh/minter/tests/deposit_from_cex.rs`.
129+
pub const DELEGATION_BATCHER_INITCODE: [u8; 81] = [
130+
0x60, 0x20, 0x61, 0x00, 0x51, 0x60, 0x00, 0x39, 0x60, 0x00, 0x60, 0x20, 0x52, 0x5b, 0x60, 0x00,
131+
0x51, 0x60, 0x20, 0x51, 0x10, 0x15, 0x61, 0x00, 0x47, 0x57, 0x60, 0x20, 0x60, 0x20, 0x51, 0x60,
132+
0x20, 0x02, 0x61, 0x00, 0x71, 0x01, 0x60, 0x40, 0x39, 0x60, 0x20, 0x60, 0x00, 0x60, 0x20, 0x51,
133+
0x60, 0x20, 0x02, 0x60, 0x60, 0x01, 0x60, 0x40, 0x51, 0x3c, 0x60, 0x20, 0x51, 0x60, 0x01, 0x01,
134+
0x60, 0x20, 0x52, 0x61, 0x00, 0x0d, 0x56, 0x5b, 0x60, 0x00, 0x51, 0x60, 0x20, 0x02, 0x60, 0x60,
135+
0xf3,
136+
];
137+
138+
/// Prefix of the code an EIP-7702 delegation designator installs at an authority, per
139+
/// [EIP-7702]: the code of a delegated account is exactly `0xef0100 || delegate`.
140+
///
141+
/// [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702
142+
const DELEGATION_DESIGNATOR_PREFIX: [u8; 3] = [0xef, 0x01, 0x00];
143+
144+
/// Length of an EIP-7702 delegation designator: its 3-byte prefix plus the 20-byte delegate.
145+
const DELEGATION_DESIGNATOR_LEN: usize = DELEGATION_DESIGNATOR_PREFIX.len() + 20;
146+
112147
/// Function selector for `balanceOf(address)`, i.e. `keccak256("balanceOf(address)")[..4]`.
113148
/// Embedded in [`BATCHER_INITCODE`] right after its leading `PUSH32` opcode; asserted by tests.
114149
#[cfg(test)]
@@ -179,6 +214,80 @@ pub fn decode_balance_batch(ret: &[u8], n: usize) -> Result<Vec<Erc20Value>, Bat
179214
Ok(balances)
180215
}
181216

217+
/// What the code at a deposit address says about its EIP-7702 delegation.
218+
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
219+
pub enum Delegation {
220+
/// The address holds no code, so no delegation is installed.
221+
NotDelegated,
222+
/// The address holds an EIP-7702 delegation designator naming this delegate.
223+
Delegated(Address),
224+
/// The address holds code that is not a delegation designator, i.e. a deployed contract.
225+
/// Such an address must never be swept: a tuple cannot be applied to it.
226+
Other,
227+
}
228+
229+
/// Build the create-call `input` for a batch of delegation reads:
230+
/// `DELEGATION_BATCHER_INITCODE ++ [n] ++ [ address x n ]`, every value a 32-byte word.
231+
pub fn encode_delegation_batch(addresses: &[DepositAddress]) -> Vec<u8> {
232+
let mut out =
233+
Vec::with_capacity(DELEGATION_BATCHER_INITCODE.len() + WORD * (1 + addresses.len()));
234+
out.extend_from_slice(&DELEGATION_BATCHER_INITCODE);
235+
out.extend_from_slice(&word_from_usize(addresses.len()));
236+
for address in addresses {
237+
out.extend_from_slice(&left_padded_address(address.as_address()));
238+
}
239+
out
240+
}
241+
242+
/// Decode the flat `n x 32`-byte return blob of [`DELEGATION_BATCHER_INITCODE`] into `n`
243+
/// delegations, in call order.
244+
///
245+
/// Each word is the first 32 bytes of an account's code, zero-padded beyond its size, which
246+
/// classifies the account unambiguously:
247+
/// * an all-zero word means no code at all, since [EIP-3541] forbids deploying code starting
248+
/// with `0xef` and a shorter non-empty code would still carry a non-zero first byte;
249+
/// * `0xef0100 || delegate || 9 zero bytes` is a code of exactly 23 bytes whose only possible
250+
/// origin is an applied [EIP-7702] authorization tuple — again because EIP-3541 keeps every
251+
/// deployed contract out of the `0xef` space;
252+
/// * anything else is deployed contract code.
253+
///
254+
/// Returns `Err` if the blob length is not exactly `n` words; never panics.
255+
///
256+
/// [EIP-3541]: https://eips.ethereum.org/EIPS/eip-3541
257+
/// [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702
258+
pub fn decode_delegation_batch(
259+
ret: &[u8],
260+
n: usize,
261+
) -> Result<Vec<Delegation>, BatcherDecodeError> {
262+
let expected = n * WORD;
263+
if ret.len() != expected {
264+
return Err(BatcherDecodeError::WrongLength {
265+
expected,
266+
got: ret.len(),
267+
});
268+
}
269+
Ok(ret
270+
.chunks_exact(WORD)
271+
.map(|word| classify_code_prefix(word.try_into().expect("BUG: chunk is exactly one word")))
272+
.collect())
273+
}
274+
275+
fn classify_code_prefix(prefix: &[u8; WORD]) -> Delegation {
276+
if prefix.iter().all(|byte| *byte == 0) {
277+
return Delegation::NotDelegated;
278+
}
279+
let (designator, padding) = prefix.split_at(DELEGATION_DESIGNATOR_LEN);
280+
let is_designator = designator.starts_with(&DELEGATION_DESIGNATOR_PREFIX)
281+
&& padding.iter().all(|byte| *byte == 0);
282+
if !is_designator {
283+
return Delegation::Other;
284+
}
285+
let delegate: [u8; 20] = designator[DELEGATION_DESIGNATOR_PREFIX.len()..]
286+
.try_into()
287+
.expect("BUG: a designator holds exactly 20 address bytes");
288+
Delegation::Delegated(Address::new(delegate))
289+
}
290+
182291
fn left_padded_address(address: &Address) -> [u8; WORD] {
183292
let mut word = [0_u8; WORD];
184293
word[WORD - 20..].copy_from_slice(address.as_ref());

rs/ethereum/cketh/minter/src/balance_scan/batcher/tests.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,193 @@ fn eth_initcode_matches_readable_assembly() {
118118
assert_eq!(FIRST_HOLDER, ARGS_START + WORD as u16);
119119
}
120120

121+
#[test]
122+
fn delegation_initcode_matches_readable_assembly() {
123+
use Op::*;
124+
125+
// The byte-for-byte source of truth for DELEGATION_BATCHER_INITCODE, as a commented EVM
126+
// assembly listing. A create-style `eth_call` runs this as init code; it reads its args
127+
// appended right after the code (`[n][ address x n ]`, from code offset ARGS_START) and
128+
// RETURNs the first 32 bytes of each address' code, copied with EXTCODECOPY and zero-padded
129+
// beyond the code size. No sub-calls, so like the ETH batcher the program has no revert path.
130+
//
131+
// Memory layout (all offsets in bytes):
132+
// [0x00] n [0x20] loop counter i [0x40] address (rewritten per iteration)
133+
// [OUTPUT..] returned code prefixes (n x 32 bytes)
134+
const ARGS_START: u16 = 0x51; // == DELEGATION_BATCHER_INITCODE.len(): the `n` word sits right after the code
135+
const FIRST_ADDRESS: u16 = 0x71; // == ARGS_START + WORD: first address word
136+
const OUTPUT: u8 = 0x60; // start of the returned code prefixes in memory
137+
const LOOP: u16 = 0x0d; // JUMPDEST at the top of the per-address loop
138+
const DONE: u16 = 0x47; // JUMPDEST for the RETURN path
139+
140+
#[rustfmt::skip]
141+
let program = assemble(&[
142+
// mem[0x00] = n (one word copied from code[ARGS_START])
143+
Push1(0x20), Push2(ARGS_START), Push1(0x00), Codecopy,
144+
// mem[0x20] = i = 0
145+
Push1(0x00), Push1(0x20), Mstore,
146+
Jumpdest, // LOOP
147+
// if !(i < n) goto DONE
148+
Push1(0x00), Mload, Push1(0x20), Mload, Lt, IsZero, Push2(DONE), Jumpi,
149+
// mem[0x40] = address = code[FIRST_ADDRESS + i * 0x20]
150+
Push1(0x20), Push1(0x20), Mload, Push1(0x20), Mul, Push2(FIRST_ADDRESS), Add,
151+
Push1(0x40), Codecopy,
152+
// EXTCODECOPY(address, dest = OUTPUT + i * 0x20, offset = 0, size = 0x20)
153+
Push1(0x20), Push1(0x00),
154+
Push1(0x20), Mload, Push1(0x20), Mul, Push1(OUTPUT), Add,
155+
Push1(0x40), Mload, ExtCodeCopy,
156+
// i += 1; goto LOOP
157+
Push1(0x20), Mload, Push1(0x01), Add, Push1(0x20), Mstore, Push2(LOOP), Jump,
158+
Jumpdest, // DONE: RETURN(OUTPUT, n * 0x20)
159+
Push1(0x00), Mload, Push1(0x20), Mul, Push1(OUTPUT), Return,
160+
]);
161+
162+
assert_eq!(program, DELEGATION_BATCHER_INITCODE);
163+
// Offsets baked into the code must match the actual layout.
164+
assert_eq!(ARGS_START as usize, DELEGATION_BATCHER_INITCODE.len());
165+
assert_eq!(FIRST_ADDRESS, ARGS_START + WORD as u16);
166+
}
167+
168+
#[test]
169+
fn encode_delegation_single_address_golden_vector() {
170+
let encoded = encode_delegation_batch(&[HOLDER0]);
171+
172+
assert_eq!(encoded.len(), DELEGATION_BATCHER_INITCODE.len() + 2 * WORD);
173+
assert_eq!(
174+
&encoded[..DELEGATION_BATCHER_INITCODE.len()],
175+
&DELEGATION_BATCHER_INITCODE
176+
);
177+
let args = &encoded[DELEGATION_BATCHER_INITCODE.len()..];
178+
assert_eq!(&args[0..32], &word(1)); // n
179+
assert_eq!(&args[32..64], &left_padded_address(HOLDER0.as_address()));
180+
}
181+
182+
#[test]
183+
fn encode_delegation_two_addresses_layout() {
184+
let encoded = encode_delegation_batch(&[HOLDER0, HOLDER1]);
185+
186+
assert_eq!(
187+
encoded.len(),
188+
DELEGATION_BATCHER_INITCODE.len() + WORD * (1 + 2)
189+
);
190+
let args = &encoded[DELEGATION_BATCHER_INITCODE.len()..];
191+
assert_eq!(&args[0..32], &word(2));
192+
assert_eq!(&args[32..64], &left_padded_address(HOLDER0.as_address()));
193+
assert_eq!(&args[64..96], &left_padded_address(HOLDER1.as_address()));
194+
}
195+
196+
#[test]
197+
fn full_batch_of_delegation_reads_fits_both_node_limits() {
198+
let addresses: Vec<DepositAddress> = (0..MAX_CALLS_PER_BATCH)
199+
.map(|index| DepositAddress::new(Address::new([index as u8; 20])))
200+
.collect();
201+
202+
let returned_blob_size = addresses.len() * WORD;
203+
204+
assert!(encode_delegation_batch(&addresses).len() <= MAX_INITCODE_SIZE);
205+
assert!(returned_blob_size <= MAX_CODE_SIZE);
206+
}
207+
208+
#[test]
209+
fn decode_delegation_empty_word_is_not_delegated() {
210+
assert_eq!(
211+
decode_delegation_batch(&[0_u8; WORD], 1).unwrap(),
212+
vec![Delegation::NotDelegated]
213+
);
214+
}
215+
216+
#[test]
217+
fn decode_delegation_designator_is_delegated() {
218+
let delegate = Address::new([0x77; 20]);
219+
220+
assert_eq!(
221+
decode_delegation_batch(&designator_word(&delegate), 1).unwrap(),
222+
vec![Delegation::Delegated(delegate)]
223+
);
224+
}
225+
226+
#[test]
227+
fn decode_delegation_designator_with_a_dirty_tail_is_other() {
228+
let mut word = designator_word(&Address::new([0x77; 20]));
229+
*word.last_mut().unwrap() = 0x01;
230+
231+
assert_eq!(
232+
decode_delegation_batch(&word, 1).unwrap(),
233+
vec![Delegation::Other]
234+
);
235+
}
236+
237+
#[test]
238+
fn decode_delegation_eof_code_is_other() {
239+
let mut word = [0_u8; WORD];
240+
word[..2].copy_from_slice(&[0xef, 0x00]);
241+
242+
assert_eq!(
243+
decode_delegation_batch(&word, 1).unwrap(),
244+
vec![Delegation::Other]
245+
);
246+
}
247+
248+
#[test]
249+
fn decode_delegation_contract_code_is_other() {
250+
let word = [0x60_u8; WORD];
251+
252+
assert_eq!(
253+
decode_delegation_batch(&word, 1).unwrap(),
254+
vec![Delegation::Other]
255+
);
256+
}
257+
258+
#[test]
259+
fn decode_delegation_batch_keeps_the_call_order() {
260+
let delegate = Address::new([0x88; 20]);
261+
let mut ret = Vec::new();
262+
ret.extend_from_slice(&[0_u8; WORD]);
263+
ret.extend_from_slice(&designator_word(&delegate));
264+
ret.extend_from_slice(&[0xfe_u8; WORD]);
265+
266+
assert_eq!(
267+
decode_delegation_batch(&ret, 3).unwrap(),
268+
vec![
269+
Delegation::NotDelegated,
270+
Delegation::Delegated(delegate),
271+
Delegation::Other,
272+
]
273+
);
274+
}
275+
276+
#[test]
277+
fn decode_delegation_empty_batch_is_ok() {
278+
assert_eq!(
279+
decode_delegation_batch(&[], 0).unwrap(),
280+
Vec::<Delegation>::new()
281+
);
282+
assert_eq!(
283+
encode_delegation_batch(&[]).len(),
284+
DELEGATION_BATCHER_INITCODE.len() + WORD
285+
);
286+
}
287+
288+
#[test]
289+
fn decode_delegation_wrong_length_is_err() {
290+
let ret = vec![0_u8; WORD + 1];
291+
292+
assert_eq!(
293+
decode_delegation_batch(&ret, 2),
294+
Err(BatcherDecodeError::WrongLength {
295+
expected: 2 * WORD,
296+
got: WORD + 1,
297+
})
298+
);
299+
}
300+
301+
fn designator_word(delegate: &Address) -> [u8; WORD] {
302+
let mut word = [0_u8; WORD];
303+
word[..3].copy_from_slice(&[0xef, 0x01, 0x00]);
304+
word[3..23].copy_from_slice(delegate.as_ref());
305+
word
306+
}
307+
121308
#[test]
122309
fn encode_eth_single_holder_golden_vector() {
123310
let encoded = encode_eth_balance_batch(&[HOLDER0]);
@@ -258,6 +445,7 @@ enum Op {
258445
Eq,
259446
IsZero,
260447
Codecopy,
448+
ExtCodeCopy,
261449
Gas,
262450
Mload,
263451
Mstore,
@@ -291,6 +479,7 @@ fn assemble(ops: &[Op]) -> Vec<u8> {
291479
Op::IsZero => out.push(0x15),
292480
Op::Balance => out.push(0x31),
293481
Op::Codecopy => out.push(0x39),
482+
Op::ExtCodeCopy => out.push(0x3c),
294483
Op::Gas => out.push(0x5a),
295484
Op::Mload => out.push(0x51),
296485
Op::Mstore => out.push(0x52),

rs/ethereum/cketh/minter/src/balance_scan/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ const MIN_DEPOSITS: &[(Address, Erc20Value)] = &[
371371
), // ckSepoliaPEPE
372372
];
373373

374-
fn call_args(input: Vec<u8>, block: BlockNumber) -> evm_rpc_types::CallArgs {
374+
pub(crate) fn call_args(input: Vec<u8>, block: BlockNumber) -> evm_rpc_types::CallArgs {
375375
evm_rpc_types::CallArgs {
376376
transaction: evm_rpc_types::TransactionRequest {
377377
// Create-style call (no `to`): the node runs `input` as init code and returns its

0 commit comments

Comments
 (0)