arb_precompiles/
nodeinterface.rs

1use alloy_consensus::{SignableTransaction, TxEip1559, TxEnvelope};
2use alloy_evm::precompiles::{DynPrecompile, PrecompileInput};
3use alloy_primitives::{keccak256, Address, Bytes, ChainId, Signature, U256};
4use alloy_sol_types::SolInterface;
5use revm::precompile::{PrecompileError, PrecompileId, PrecompileOutput, PrecompileResult};
6
7use crate::{
8    arbsys::get_cached_l1_block_number,
9    interfaces::INodeInterface,
10    storage_slot::{
11        root_slot, subspace_slot, ARBOS_STATE_ADDRESS, GENESIS_BLOCK_NUM_OFFSET,
12        L1_PRICING_SUBSPACE, L2_PRICING_SUBSPACE,
13    },
14};
15
16/// NodeInterface virtual contract address (0xc8).
17pub const NODE_INTERFACE_ADDRESS: Address = Address::new([
18    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
19    0x00, 0x00, 0x00, 0xc8,
20]);
21
22// L1 pricing field offsets.
23const L1_PRICE_PER_UNIT: u64 = 7;
24
25// L2 pricing field offsets.
26const L2_BASE_FEE: u64 = 2;
27const L2_MIN_BASE_FEE: u64 = 3;
28
29// Gas costs.
30const SLOAD_GAS: u64 = 800;
31const COPY_GAS: u64 = 3;
32
33pub fn create_nodeinterface_precompile() -> DynPrecompile {
34    DynPrecompile::new_stateful(PrecompileId::custom("nodeinterface"), handler)
35}
36
37fn handler(mut input: PrecompileInput<'_>) -> PrecompileResult {
38    let gas_limit = input.gas;
39    crate::init_precompile_gas(input.data.len());
40
41    let call = match INodeInterface::NodeInterfaceCalls::abi_decode(input.data) {
42        Ok(c) => c,
43        Err(_) => return crate::burn_all_revert(gas_limit),
44    };
45
46    use INodeInterface::NodeInterfaceCalls as Calls;
47    let result = match call {
48        Calls::gasEstimateComponents(_) => handle_gas_estimate_components(&mut input),
49        Calls::gasEstimateL1Component(_) => handle_gas_estimate_l1_component(&mut input),
50        Calls::nitroGenesisBlock(_) => handle_nitro_genesis_block(&mut input),
51        Calls::blockL1Num(c) => handle_block_l1_num(&input, c.l2BlockNum),
52        // Batch-fetcher-dependent methods: return 0 when no batch fetcher is
53        // wired so bridge tooling can distinguish "unknown/pending" from
54        // "not implemented".
55        Calls::getL1Confirmations(_) => handle_zero_u64(&input),
56        Calls::findBatchContainingBlock(_) => handle_zero_u64(&input),
57        Calls::legacyLookupMessageBatchProof(_) => handle_legacy_lookup_empty(&input),
58        Calls::l2BlockRangeForL1(_)
59        | Calls::estimateRetryableTicket(_)
60        | Calls::constructOutboxProof(_) => {
61            Err(PrecompileError::other("method only available via RPC"))
62        }
63    };
64    crate::gas_check(gas_limit, result)
65}
66
67/// gasEstimateComponents(address,bool,bytes) → (uint64, uint64, uint256, uint256)
68///
69/// Returns: (gasEstimate, gasEstimateForL1, baseFee, l1BaseFeeEstimate)
70///
71/// The full gas estimate requires calling back into eth_estimateGas which
72/// is not possible from within a precompile. We return the L1 component
73/// and basefee; the total estimate is left as 0 (callers should use
74/// eth_estimateGas for the total).
75fn handle_gas_estimate_components(input: &mut PrecompileInput<'_>) -> PrecompileResult {
76    let gas_limit = input.gas;
77    load_arbos(input)?;
78
79    let l1_price = sload_field(input, subspace_slot(L1_PRICING_SUBSPACE, L1_PRICE_PER_UNIT))?;
80    let basefee = sload_field(input, subspace_slot(L2_PRICING_SUBSPACE, L2_BASE_FEE))?;
81    let min_basefee = sload_field(input, subspace_slot(L2_PRICING_SUBSPACE, L2_MIN_BASE_FEE))?;
82    let chain_id_u256 = sload_field(input, root_slot(crate::storage_slot::CHAIN_ID_OFFSET))?;
83    let chain_id: ChainId = chain_id_u256.try_into().unwrap_or(0);
84    let brotli_level = sload_field(
85        input,
86        root_slot(crate::storage_slot::BROTLI_COMPRESSION_LEVEL_OFFSET),
87    )?
88    .try_into()
89    .unwrap_or(0u64);
90
91    let gas_for_l1 = estimate_l1_gas(
92        input,
93        l1_price,
94        basefee,
95        min_basefee,
96        chain_id,
97        brotli_level,
98    );
99
100    let mut out = Vec::with_capacity(128);
101    // gasEstimate: 0 (full estimate requires eth_estimateGas)
102    out.extend_from_slice(&U256::ZERO.to_be_bytes::<32>());
103    // gasEstimateForL1
104    out.extend_from_slice(&U256::from(gas_for_l1).to_be_bytes::<32>());
105    // baseFee
106    out.extend_from_slice(&basefee.to_be_bytes::<32>());
107    // l1BaseFeeEstimate
108    out.extend_from_slice(&l1_price.to_be_bytes::<32>());
109
110    Ok(PrecompileOutput::new(
111        (2 * SLOAD_GAS + COPY_GAS).min(gas_limit),
112        out.into(),
113    ))
114}
115
116/// gasEstimateL1Component(address,bool,bytes) → (uint64, uint256, uint256)
117///
118/// Returns: (gasEstimateForL1, baseFee, l1BaseFeeEstimate)
119fn handle_gas_estimate_l1_component(input: &mut PrecompileInput<'_>) -> PrecompileResult {
120    let gas_limit = input.gas;
121    load_arbos(input)?;
122
123    let l1_price = sload_field(input, subspace_slot(L1_PRICING_SUBSPACE, L1_PRICE_PER_UNIT))?;
124    let basefee = sload_field(input, subspace_slot(L2_PRICING_SUBSPACE, L2_BASE_FEE))?;
125    let min_basefee = sload_field(input, subspace_slot(L2_PRICING_SUBSPACE, L2_MIN_BASE_FEE))?;
126    let chain_id_u256 = sload_field(input, root_slot(crate::storage_slot::CHAIN_ID_OFFSET))?;
127    let chain_id: ChainId = chain_id_u256.try_into().unwrap_or(0);
128    let brotli_level = sload_field(
129        input,
130        root_slot(crate::storage_slot::BROTLI_COMPRESSION_LEVEL_OFFSET),
131    )?
132    .try_into()
133    .unwrap_or(0u64);
134
135    let gas_for_l1 = estimate_l1_gas(
136        input,
137        l1_price,
138        basefee,
139        min_basefee,
140        chain_id,
141        brotli_level,
142    );
143
144    let mut out = Vec::with_capacity(96);
145    // gasEstimateForL1
146    out.extend_from_slice(&U256::from(gas_for_l1).to_be_bytes::<32>());
147    // baseFee
148    out.extend_from_slice(&basefee.to_be_bytes::<32>());
149    // l1BaseFeeEstimate
150    out.extend_from_slice(&l1_price.to_be_bytes::<32>());
151
152    Ok(PrecompileOutput::new(
153        (2 * SLOAD_GAS + COPY_GAS).min(gas_limit),
154        out.into(),
155    ))
156}
157
158/// nitroGenesisBlock() → uint64
159fn handle_nitro_genesis_block(input: &mut PrecompileInput<'_>) -> PrecompileResult {
160    let gas_limit = input.gas;
161    load_arbos(input)?;
162
163    let genesis_block_num = sload_field(input, root_slot(GENESIS_BLOCK_NUM_OFFSET))?;
164
165    Ok(PrecompileOutput::new(
166        (SLOAD_GAS + COPY_GAS).min(gas_limit),
167        genesis_block_num.to_be_bytes::<32>().to_vec().into(),
168    ))
169}
170
171fn handle_block_l1_num(input: &PrecompileInput<'_>, block_num: u64) -> PrecompileResult {
172    let l1_block = get_cached_l1_block_number(block_num).unwrap_or(0);
173    Ok(PrecompileOutput::new(
174        COPY_GAS.min(input.gas),
175        U256::from(l1_block).to_be_bytes::<32>().to_vec().into(),
176    ))
177}
178
179/// Encode a single uint64/uint256 zero — used when batch-fetcher methods
180/// can't resolve data.
181fn handle_zero_u64(input: &PrecompileInput<'_>) -> PrecompileResult {
182    Ok(PrecompileOutput::new(
183        COPY_GAS.min(input.gas),
184        U256::ZERO.to_be_bytes::<32>().to_vec().into(),
185    ))
186}
187
188/// legacyLookupMessageBatchProof returns the 9-value all-zero tuple —
189/// arbreth has no classic-chain outbox to look up.
190///
191/// ABI return:
192///   (bytes32[] proof, uint256 path, address l2Sender, address l1Dest,
193///    uint256 l2Block, uint256 l1Block, uint256 timestamp, uint256 amount,
194///    bytes calldataForL1)
195fn handle_legacy_lookup_empty(input: &PrecompileInput<'_>) -> PrecompileResult {
196    // 9 head slots for the tuple + 1 slot each for the two dynamic arrays'
197    // length (proof and calldataForL1), emitted inline since length is 0.
198    // Head layout:
199    //   offset 0x00: proof offset (dynamic)       → points to trailing data
200    //   offset 0x20: path
201    //   offset 0x40: l2Sender
202    //   offset 0x60: l1Dest
203    //   offset 0x80: l2Block
204    //   offset 0xA0: l1Block
205    //   offset 0xC0: timestamp
206    //   offset 0xE0: amount
207    //   offset 0x100: calldataForL1 offset (dynamic)
208    //   offset 0x120: proof length (0)
209    //   offset 0x140: calldataForL1 length (0)
210    let mut out = vec![0u8; 0x160];
211    // proof offset = 0x140 (after the 9 head words, points to proof length)
212    U256::from(0x140u64)
213        .to_be_bytes::<32>()
214        .iter()
215        .enumerate()
216        .for_each(|(i, b)| out[i] = *b);
217    // calldataForL1 offset = 0x140 + 0x20 (after proof length = 0)
218    U256::from(0x160u64)
219        .to_be_bytes::<32>()
220        .iter()
221        .enumerate()
222        .for_each(|(i, b)| out[0x100 + i] = *b);
223    Ok(PrecompileOutput::new(COPY_GAS.min(input.gas), out.into()))
224}
225
226fn estimate_l1_gas(
227    input: &PrecompileInput<'_>,
228    l1_price: U256,
229    basefee: U256,
230    min_basefee: U256,
231    chain_id: ChainId,
232    brotli_level: u64,
233) -> u64 {
234    let (to_addr, contract_creation, data) = match decode_estimate_args(input.data) {
235        Some(v) => v,
236        None => return 0,
237    };
238    compute_l1_gas_for_estimate(
239        chain_id,
240        to_addr,
241        contract_creation,
242        U256::ZERO,
243        data,
244        l1_price,
245        basefee,
246        min_basefee,
247        brotli_level,
248    )
249}
250
251/// L1 gas estimate: brotli-compress a fake EIP-1559 tx, pad units by
252/// `(units + 256) * 1.01`, multiply by `pricePerUnit`, pad posterCost by
253/// `1.10`, then divide by `max(basefee * 7/8, minBaseFee)`.
254pub fn compute_l1_gas_for_estimate(
255    chain_id: ChainId,
256    to: Address,
257    contract_creation: bool,
258    value: U256,
259    data: Bytes,
260    l1_price: U256,
261    basefee: U256,
262    min_basefee: U256,
263    brotli_level: u64,
264) -> u64 {
265    if basefee.is_zero() || l1_price.is_zero() {
266        return 0;
267    }
268    let tx_bytes = build_fake_tx_bytes(chain_id, to, contract_creation, value, data);
269    let raw_units = arbos::l1_pricing::poster_units_from_bytes(&tx_bytes, brotli_level);
270    let padded_units = raw_units
271        .saturating_add(arbos::l1_pricing::ESTIMATION_PADDING_UNITS)
272        .saturating_mul(10_000 + arbos::l1_pricing::ESTIMATION_PADDING_BASIS_POINTS)
273        / 10_000;
274    let poster_cost = l1_price.saturating_mul(U256::from(padded_units));
275    let posting_padded = poster_cost.saturating_mul(U256::from(11_000u64)) / U256::from(10_000u64);
276    let adjusted = basefee.saturating_mul(U256::from(7u64)) / U256::from(8u64);
277    let gas_price = if adjusted < min_basefee {
278        min_basefee
279    } else {
280        adjusted
281    };
282    if gas_price.is_zero() {
283        return 0;
284    }
285    (posting_padded / gas_price).try_into().unwrap_or(u64::MAX)
286}
287
288/// Decode `gasEstimateComponents(address,bool,bytes)` calldata into
289/// `(to, contractCreation, data)`.
290pub fn decode_estimate_args(data: &[u8]) -> Option<(Address, bool, Bytes)> {
291    if data.len() < 4 + 4 * 32 {
292        return None;
293    }
294    let to = Address::from_slice(&data[16..36]);
295    let creation = data[4 + 32 + 31] != 0;
296    let bytes_offset: usize = U256::from_be_slice(&data[4 + 64..4 + 96]).try_into().ok()?;
297    let bytes_pos = 4usize.checked_add(bytes_offset)?;
298    if data.len() < bytes_pos + 32 {
299        return None;
300    }
301    let bytes_len: usize = U256::from_be_slice(&data[bytes_pos..bytes_pos + 32])
302        .try_into()
303        .ok()?;
304    let data_start = bytes_pos + 32;
305    if data.len() < data_start + bytes_len {
306        return None;
307    }
308    Some((
309        to,
310        creation,
311        Bytes::copy_from_slice(&data[data_start..data_start + bytes_len]),
312    ))
313}
314
315/// Build the EIP-2718 envelope of a fake EIP-1559 tx used to size the
316/// calldata payload for gas estimation (hard-coded random
317/// nonce/tip/feeCap/gas/sig fields).
318pub fn build_fake_tx_bytes(
319    chain_id: ChainId,
320    to: Address,
321    contract_creation: bool,
322    value: U256,
323    data: Bytes,
324) -> Vec<u8> {
325    let nonce = u64::from_be_bytes(keccak256(b"Nonce")[..8].try_into().unwrap());
326    let max_priority = u128::from(u32::from_be_bytes(
327        keccak256(b"GasTipCap")[..4].try_into().unwrap(),
328    ));
329    let max_fee = u128::from(u32::from_be_bytes(
330        keccak256(b"GasFeeCap")[..4].try_into().unwrap(),
331    ));
332    let gas_limit = u64::from(u32::from_be_bytes(
333        keccak256(b"Gas")[..4].try_into().unwrap(),
334    ));
335    let r = U256::from_be_bytes(keccak256(b"R").0);
336    let s = U256::from_be_bytes(keccak256(b"S").0);
337
338    let kind = if contract_creation {
339        revm::primitives::TxKind::Create
340    } else {
341        revm::primitives::TxKind::Call(to)
342    };
343
344    let tx = TxEip1559 {
345        chain_id,
346        nonce,
347        gas_limit,
348        max_fee_per_gas: max_fee,
349        max_priority_fee_per_gas: max_priority,
350        to: kind,
351        value,
352        access_list: Default::default(),
353        input: data,
354    };
355
356    let signature = Signature::new(r, s, false);
357    let signed = tx.into_signed(signature);
358    use alloy_eips::eip2718::Encodable2718;
359    let envelope = TxEnvelope::Eip1559(signed);
360    envelope.encoded_2718()
361}
362
363fn load_arbos(input: &mut PrecompileInput<'_>) -> Result<(), PrecompileError> {
364    input
365        .internals_mut()
366        .load_account(ARBOS_STATE_ADDRESS)
367        .map_err(|e| PrecompileError::other(format!("load_account: {e:?}")))?;
368    Ok(())
369}
370
371fn sload_field(input: &mut PrecompileInput<'_>, slot: U256) -> Result<U256, PrecompileError> {
372    let val = input
373        .internals_mut()
374        .sload(ARBOS_STATE_ADDRESS, slot)
375        .map_err(|_| PrecompileError::other("sload failed"))?;
376    crate::charge_precompile_gas(SLOAD_GAS);
377    Ok(val.data)
378}