arb_node/
chainspec.rs

1//! Chain spec parser that pre-populates the genesis `alloc` with
2//! ArbOS state when the spec declares `config.arbitrum.InitialArbOSVersion`
3//! but does not include the ArbOS state account in the alloc.
4
5use std::{path::Path, str::FromStr, sync::Arc};
6
7use alloy_genesis::GenesisAccount;
8use alloy_primitives::{Address, B256, U256, hex};
9use arbos::arbos_types::ParsedInitMessage;
10use eyre::eyre;
11use reth_chainspec::ChainSpec;
12use reth_cli::chainspec::ChainSpecParser;
13use reth_ethereum_cli::chainspec::EthereumChainSpecParser;
14use revm::database::{EmptyDB, State, StateBuilder};
15use revm_database::states::bundle_state::BundleRetention;
16use serde_json::Value;
17
18use crate::genesis;
19
20/// Block gas limit at genesis (`l2pricing.GethBlockGasLimit = 1 << 50`).
21const NITRO_GENESIS_GAS_LIMIT: u64 = 1 << 50;
22/// Initial L2 base fee in wei at genesis (0.1 gwei).
23const NITRO_GENESIS_BASE_FEE: u64 = 100_000_000;
24/// JSON pointer for the flag that suppresses ArbOS alloc injection (used when the genesis already
25/// carries a complete pre-seeded alloc).
26const SKIP_GENESIS_INJECTION_POINTER: &str = "/config/arbitrum/SkipGenesisInjection";
27/// Default initial L1 base fee when no override is provided (50 gwei).
28const DEFAULT_INITIAL_L1_BASE_FEE_WEI: u64 = 50_000_000_000;
29
30#[derive(Debug, Clone, Default)]
31#[non_exhaustive]
32pub struct ArbChainSpecParser;
33
34impl ChainSpecParser for ArbChainSpecParser {
35    type ChainSpec = ChainSpec;
36
37    const SUPPORTED_CHAINS: &'static [&'static str] = EthereumChainSpecParser::SUPPORTED_CHAINS;
38
39    fn parse(s: &str) -> eyre::Result<Arc<ChainSpec>> {
40        if EthereumChainSpecParser::SUPPORTED_CHAINS.contains(&s) {
41            return EthereumChainSpecParser::parse(s);
42        }
43
44        let raw = if Path::new(s).exists() {
45            std::fs::read_to_string(s).map_err(|e| eyre!("read chain spec {s}: {e}"))?
46        } else {
47            s.to_string()
48        };
49
50        let mut value: Value =
51            serde_json::from_str(&raw).map_err(|e| eyre!("parse chain spec JSON: {e}"))?;
52
53        let initial_arbos = value
54            .pointer("/config/arbitrum/InitialArbOSVersion")
55            .and_then(Value::as_u64)
56            .unwrap_or(0);
57        let chain_id = value
58            .pointer("/config/chainId")
59            .and_then(Value::as_u64)
60            .unwrap_or(0);
61        let initial_owner = value
62            .pointer("/config/arbitrum/InitialChainOwner")
63            .and_then(Value::as_str)
64            .and_then(|s| Address::from_str(s.trim_start_matches("0x")).ok())
65            .unwrap_or(Address::ZERO);
66        let arbos_init = parse_arbos_init(&value);
67
68        let skip_injection = value
69            .pointer(SKIP_GENESIS_INJECTION_POINTER)
70            .and_then(Value::as_bool)
71            .unwrap_or(false);
72
73        if initial_arbos > 0 && chain_id > 0 {
74            // SkipGenesisInjection used to gate this call, but captured cache
75            // files miss accounts (FilteredTransactionsState) and never carry
76            // ArbOS-state-account storage. The injection helper merges per-
77            // account (user-supplied fields and explicit slots win), so it is
78            // safe to run unconditionally.
79            let _ = skip_injection;
80            inject_arbos_alloc(
81                &mut value,
82                chain_id,
83                initial_arbos,
84                initial_owner,
85                arbos_init,
86            )?;
87            override_arbos_genesis_header(&mut value, initial_arbos)?;
88        }
89
90        let augmented = serde_json::to_string(&value)?;
91        EthereumChainSpecParser::parse(&augmented)
92    }
93}
94
95/// Returns the `AllowDebugPrecompiles` flag declared in the chain spec's
96/// `config.arbitrum` section, defaulting to `false`.
97pub fn allow_debug_precompiles(chain_spec: &ChainSpec) -> bool {
98    chain_spec
99        .genesis()
100        .config
101        .extra_fields
102        .get_deserialized::<serde_json::Value>("arbitrum")
103        .and_then(|v| v.ok())
104        .and_then(|arb| arb.get("AllowDebugPrecompiles").and_then(Value::as_bool))
105        .unwrap_or(false)
106}
107
108/// Force the genesis header fields that the ArbOS init path hardcodes.
109/// reth's `make_genesis_header` reads these directly from JSON, so we
110/// rewrite them here before parsing.
111fn override_arbos_genesis_header(value: &mut Value, arbos_version: u64) -> eyre::Result<()> {
112    let obj = value
113        .as_object_mut()
114        .ok_or_else(|| eyre!("chain spec is not a JSON object"))?;
115
116    // Genesis header reads the L1 init message → nonce field is set to 1.
117    obj.insert("nonce".into(), Value::String("0x1".into()));
118
119    // `extraData` carries `SendRoot` (32 zero bytes at genesis).
120    obj.insert(
121        "extraData".into(),
122        Value::String(format!("0x{}", hex::encode([0u8; 32]))),
123    );
124
125    // `mixHash` packs `[SendCount(8) | L1BlockNumber(8) | ArbOSFormatVersion(8) | flags(8)]`
126    // big-endian. At genesis only `ArbOSFormatVersion` is non-zero.
127    let mut mix_hash = [0u8; 32];
128    mix_hash[16..24].copy_from_slice(&arbos_version.to_be_bytes());
129    obj.insert(
130        "mixHash".into(),
131        Value::String(format!("0x{}", hex::encode(mix_hash))),
132    );
133
134    obj.insert("difficulty".into(), Value::String("0x1".into()));
135    obj.insert(
136        "gasLimit".into(),
137        Value::String(format!("{NITRO_GENESIS_GAS_LIMIT:#x}")),
138    );
139    obj.insert(
140        "baseFeePerGas".into(),
141        Value::String(format!("{NITRO_GENESIS_BASE_FEE:#x}")),
142    );
143    obj.insert(
144        "coinbase".into(),
145        Value::String(format!("0x{}", hex::encode([0u8; 20]))),
146    );
147
148    Ok(())
149}
150
151fn parse_arbos_init(value: &Value) -> genesis::ArbOSInit {
152    let native = value
153        .pointer("/config/arbitrum/ArbOSInit/nativeTokenSupplyManagementEnabled")
154        .or_else(|| value.pointer("/config/arbitrum/nativeTokenSupplyManagementEnabled"))
155        .and_then(Value::as_bool)
156        .unwrap_or(false);
157    let filtering = value
158        .pointer("/config/arbitrum/ArbOSInit/transactionFilteringEnabled")
159        .or_else(|| value.pointer("/config/arbitrum/transactionFilteringEnabled"))
160        .and_then(Value::as_bool)
161        .unwrap_or(false);
162    genesis::ArbOSInit {
163        native_token_supply_management_enabled: native,
164        transaction_filtering_enabled: filtering,
165    }
166}
167
168fn inject_arbos_alloc(
169    value: &mut Value,
170    chain_id: u64,
171    arbos_version: u64,
172    chain_owner: Address,
173    arbos_init: genesis::ArbOSInit,
174) -> eyre::Result<()> {
175    // Pre-compute the Go-canonical chain config bytes so the resulting
176    // `chain_config` subspace slot layout matches a Go-style
177    // `json.Unmarshal` + `json.Marshal` of the same chain spec.
178    let serialized_chain_config = value
179        .get("config")
180        .map(serialize_chain_config_go_style)
181        .unwrap_or_default();
182
183    let alloc_obj = value
184        .as_object_mut()
185        .ok_or_else(|| eyre!("chain spec is not a JSON object"))?
186        .entry("alloc")
187        .or_insert_with(|| Value::Object(serde_json::Map::new()))
188        .as_object_mut()
189        .ok_or_else(|| eyre!("alloc is not a JSON object"))?;
190
191    let entries = compute_arbos_alloc_with_config(
192        chain_id,
193        arbos_version,
194        chain_owner,
195        arbos_init,
196        serialized_chain_config,
197        U256::from(DEFAULT_INITIAL_L1_BASE_FEE_WEI),
198    )?;
199    for (addr, account) in entries {
200        let key = address_lower_no_prefix(addr);
201        let prefixed = format!("0x{key}");
202        let existing_key = if alloc_obj.contains_key(&key) {
203            Some(key.clone())
204        } else if alloc_obj.contains_key(&prefixed) {
205            Some(prefixed.clone())
206        } else {
207            None
208        };
209        let injected = serde_json::to_value(&account)?;
210        match existing_key {
211            None => {
212                alloc_obj.insert(prefixed, injected);
213            }
214            Some(k) => {
215                // Merge injected entry into the user-supplied one. User-set
216                // fields (balance, nonce, code, individual storage slots)
217                // win on conflict so fixture overrides replace bootstrap
218                // values; injected fields fill in anything the user didn't
219                // specify.
220                let user = alloc_obj
221                    .get_mut(&k)
222                    .ok_or_else(|| eyre!("alloc[{k}] disappeared while merging ArbOS state"))?;
223                let user_obj = user
224                    .as_object_mut()
225                    .ok_or_else(|| eyre!("alloc[{k}] is not an object"))?;
226                let injected_obj = injected
227                    .as_object()
228                    .ok_or_else(|| eyre!("generated ArbOS alloc[{k}] is not an object"))?;
229                for (field, val) in injected_obj {
230                    if field == "storage" {
231                        continue;
232                    }
233                    user_obj.entry(field.clone()).or_insert(val.clone());
234                }
235                let injected_storage = injected
236                    .get("storage")
237                    .and_then(|s| s.as_object())
238                    .cloned()
239                    .unwrap_or_default();
240                let storage = user_obj
241                    .entry("storage")
242                    .or_insert_with(|| Value::Object(serde_json::Map::new()))
243                    .as_object_mut()
244                    .ok_or_else(|| eyre!("alloc[{k}].storage is not an object"))?;
245                for (slot, val) in injected_storage {
246                    storage.entry(slot).or_insert(val);
247                }
248            }
249        }
250    }
251    Ok(())
252}
253
254fn address_lower_no_prefix(addr: Address) -> String {
255    let s = format!("{addr:x}");
256    let mut padded = String::with_capacity(40);
257    for _ in 0..(40 - s.len()) {
258        padded.push('0');
259    }
260    padded.push_str(&s);
261    padded
262}
263
264/// Run [`genesis::initialize_arbos_state`] in a scratch in-memory state
265/// and dump the resulting account/storage map. Returns one entry per
266/// account touched (the ArbOS state address plus all genesis precompile
267/// markers).
268///
269/// Equivalent to [`compute_arbos_alloc_with_config`] called with an empty
270/// chain config and a zero initial L1 base fee.
271pub fn compute_arbos_alloc(
272    chain_id: u64,
273    arbos_version: u64,
274    chain_owner: Address,
275    arbos_init: genesis::ArbOSInit,
276) -> eyre::Result<Vec<(Address, GenesisAccount)>> {
277    compute_arbos_alloc_with_config(
278        chain_id,
279        arbos_version,
280        chain_owner,
281        arbos_init,
282        Vec::new(),
283        U256::ZERO,
284    )
285}
286
287/// Run [`genesis::initialize_arbos_state`] in a scratch in-memory state
288/// using a caller-supplied serialized chain config and initial L1 base fee.
289///
290/// Use this overload when reproducing the slot set written for a given
291/// chain spec; pass `serialize_chain_config_go_style(value["config"])`
292/// for the bytes and `DEFAULT_INITIAL_L1_BASE_FEE_WEI` for the base fee.
293pub fn compute_arbos_alloc_with_config(
294    chain_id: u64,
295    arbos_version: u64,
296    chain_owner: Address,
297    arbos_init: genesis::ArbOSInit,
298    serialized_chain_config: Vec<u8>,
299    initial_l1_base_fee: U256,
300) -> eyre::Result<Vec<(Address, GenesisAccount)>> {
301    let mut state: State<EmptyDB> = StateBuilder::new()
302        .with_database(EmptyDB::default())
303        .with_bundle_update()
304        .build();
305
306    let init_msg = ParsedInitMessage {
307        chain_id: U256::from(chain_id),
308        initial_l1_base_fee,
309        serialized_chain_config,
310    };
311
312    genesis::initialize_arbos_state(
313        &mut state,
314        &init_msg,
315        chain_id,
316        arbos_version,
317        chain_owner,
318        arbos_init,
319    )
320    .map_err(|e| eyre!("initialize_arbos_state: {e}"))?;
321
322    state.merge_transitions(BundleRetention::PlainState);
323    let bundle = state.take_bundle();
324
325    let mut out = Vec::new();
326    for (addr, account) in bundle.state.iter() {
327        let info = match &account.info {
328            Some(info) => info,
329            None => continue,
330        };
331
332        let mut storage = std::collections::BTreeMap::new();
333        for (slot, slot_value) in account.storage.iter() {
334            if slot_value.present_value.is_zero() {
335                continue;
336            }
337            storage.insert(
338                B256::from(slot.to_be_bytes::<32>()),
339                B256::from(slot_value.present_value.to_be_bytes::<32>()),
340            );
341        }
342
343        let code = match &info.code {
344            Some(c) if !c.original_bytes().is_empty() => Some(c.original_bytes()),
345            _ => None,
346        };
347
348        let entry = GenesisAccount {
349            balance: info.balance,
350            nonce: Some(info.nonce),
351            code,
352            storage: if storage.is_empty() {
353                None
354            } else {
355                Some(storage)
356            },
357            private_key: None,
358        };
359        out.push((*addr, entry));
360    }
361    out.sort_by_key(|(a, _)| *a);
362    Ok(out)
363}
364
365/// Serialize a chain-config JSON object the way Go's `json.Marshal` of
366/// `params.ChainConfig` would. Field order, address casing, and which
367/// fields are emitted (vs. dropped via `omitempty`) match Go's encoder so
368/// the resulting bytes are byte-identical to what ArbOS init stores in the
369/// `chain_config` subspace at genesis.
370pub fn serialize_chain_config_go_style(config: &Value) -> Vec<u8> {
371    let mut out = Vec::with_capacity(512);
372    out.push(b'{');
373    let mut writer = JsonWriter::new(&mut out);
374
375    let cfg = config.as_object();
376
377    // Mirror the field order of `params.ChainConfig`. Number-typed fork
378    // toggles map to `*big.Int` in Go, so any non-null JSON number (incl.
379    // `0`) decodes to a non-nil pointer and is emitted.
380    writer.write_required_chain_id(cfg);
381
382    for (name, json_key) in BIG_INT_BLOCK_FIELDS {
383        writer.write_optional_big_int(cfg, json_key, name);
384    }
385
386    if let Some(map) = cfg
387        && map.get("daoForkSupport").and_then(Value::as_bool) == Some(true)
388    {
389        writer.write_bool_field("daoForkSupport", true);
390    }
391
392    for (name, json_key) in BIG_INT_BLOCK_FIELDS_AFTER_DAO {
393        writer.write_optional_big_int(cfg, json_key, name);
394    }
395
396    for (name, json_key) in TIME_FIELDS {
397        writer.write_optional_uint64(cfg, json_key, name);
398    }
399
400    writer.write_optional_big_int(cfg, "terminalTotalDifficulty", "terminalTotalDifficulty");
401
402    // Fixed-size arrays don't trigger Go's omitempty, so this slot always
403    // appears even when the address is all zeros.
404    writer.write_address_field(
405        cfg,
406        "depositContractAddress",
407        "depositContractAddress",
408        true,
409    );
410
411    if let Some(map) = cfg
412        && map.get("enableVerkleAtGenesis").and_then(Value::as_bool) == Some(true)
413    {
414        writer.write_bool_field("enableVerkleAtGenesis", true);
415    }
416
417    // Ethash and Clique are pointer types in Go, so their slots only appear
418    // when present in the input. Clique always has period+epoch (no
419    // omitempty); BlobScheduleConfig is omitted for ArbOS chains.
420    if let Some(eth) = cfg.and_then(|m| m.get("ethash")).filter(|v| v.is_object()) {
421        writer.write_raw_object("ethash", eth);
422    }
423    if let Some(clique) = cfg.and_then(|m| m.get("clique")).filter(|v| v.is_object()) {
424        writer.write_clique("clique", clique);
425    }
426
427    // ArbitrumChainParams is a value type in Go, so the field is always
428    // emitted (even if every sub-field is zero).
429    let arbitrum = cfg
430        .and_then(|m| m.get("arbitrum"))
431        .filter(|v| v.is_object());
432    writer.write_arbitrum(arbitrum);
433
434    out.push(b'}');
435    out
436}
437
438/// Big-int (block number) fields emitted before `daoForkSupport`.
439const BIG_INT_BLOCK_FIELDS: &[(&str, &str)] = &[
440    ("homesteadBlock", "homesteadBlock"),
441    ("daoForkBlock", "daoForkBlock"),
442];
443
444/// Big-int (block number) fields emitted after `daoForkSupport`.
445const BIG_INT_BLOCK_FIELDS_AFTER_DAO: &[(&str, &str)] = &[
446    ("eip150Block", "eip150Block"),
447    ("eip155Block", "eip155Block"),
448    ("eip158Block", "eip158Block"),
449    ("byzantiumBlock", "byzantiumBlock"),
450    ("constantinopleBlock", "constantinopleBlock"),
451    ("petersburgBlock", "petersburgBlock"),
452    ("istanbulBlock", "istanbulBlock"),
453    ("muirGlacierBlock", "muirGlacierBlock"),
454    ("berlinBlock", "berlinBlock"),
455    ("londonBlock", "londonBlock"),
456    ("arrowGlacierBlock", "arrowGlacierBlock"),
457    ("grayGlacierBlock", "grayGlacierBlock"),
458    ("mergeNetsplitBlock", "mergeNetsplitBlock"),
459];
460
461/// Timestamp-typed fork fields, emitted only when present in the input.
462const TIME_FIELDS: &[(&str, &str)] = &[
463    ("shanghaiTime", "shanghaiTime"),
464    ("cancunTime", "cancunTime"),
465    ("pragueTime", "pragueTime"),
466    ("osakaTime", "osakaTime"),
467    ("bpo1Time", "bpo1Time"),
468    ("bpo2Time", "bpo2Time"),
469    ("bpo3Time", "bpo3Time"),
470    ("bpo4Time", "bpo4Time"),
471    ("bpo5Time", "bpo5Time"),
472    ("amsterdamTime", "amsterdamTime"),
473    ("verkleTime", "verkleTime"),
474];
475
476/// Order-preserving JSON-fragment writer scoped to a single object body.
477struct JsonWriter<'a> {
478    buf: &'a mut Vec<u8>,
479    first: bool,
480}
481
482impl<'a> JsonWriter<'a> {
483    fn new(buf: &'a mut Vec<u8>) -> Self {
484        Self { buf, first: true }
485    }
486
487    fn comma(&mut self) {
488        if self.first {
489            self.first = false;
490        } else {
491            self.buf.push(b',');
492        }
493    }
494
495    fn write_key(&mut self, name: &str) {
496        self.comma();
497        self.buf.push(b'"');
498        self.buf.extend_from_slice(name.as_bytes());
499        self.buf.extend_from_slice(b"\":");
500    }
501
502    fn write_required_chain_id(&mut self, cfg: Option<&serde_json::Map<String, Value>>) {
503        let chain_id = cfg
504            .and_then(|m| m.get("chainId"))
505            .map(value_to_decimal_int)
506            .unwrap_or_else(|| "0".to_string());
507        self.write_key("chainId");
508        self.buf.extend_from_slice(chain_id.as_bytes());
509    }
510
511    fn write_optional_big_int(
512        &mut self,
513        cfg: Option<&serde_json::Map<String, Value>>,
514        json_key: &str,
515        name: &str,
516    ) {
517        let map = match cfg {
518            Some(m) => m,
519            None => return,
520        };
521        let v = match map.get(json_key) {
522            Some(v) if !v.is_null() => v,
523            _ => return,
524        };
525        let s = value_to_decimal_int(v);
526        self.write_key(name);
527        self.buf.extend_from_slice(s.as_bytes());
528    }
529
530    fn write_optional_uint64(
531        &mut self,
532        cfg: Option<&serde_json::Map<String, Value>>,
533        json_key: &str,
534        name: &str,
535    ) {
536        let map = match cfg {
537            Some(m) => m,
538            None => return,
539        };
540        let v = match map.get(json_key) {
541            Some(v) if !v.is_null() => v,
542            _ => return,
543        };
544        // Pointer-typed timestamps emit their value when non-nil; even a
545        // value of 0 should appear so we keep parity with Go's encoder.
546        let s = value_to_decimal_int(v);
547        self.write_key(name);
548        self.buf.extend_from_slice(s.as_bytes());
549    }
550
551    fn write_bool_field(&mut self, name: &str, val: bool) {
552        self.write_key(name);
553        self.buf
554            .extend_from_slice(if val { b"true" } else { b"false" });
555    }
556
557    fn write_address_field(
558        &mut self,
559        cfg: Option<&serde_json::Map<String, Value>>,
560        json_key: &str,
561        name: &str,
562        emit_zero: bool,
563    ) {
564        let addr = cfg
565            .and_then(|m| m.get(json_key))
566            .and_then(Value::as_str)
567            .map(|s| s.trim_start_matches("0x").to_lowercase())
568            .unwrap_or_default();
569        let normalized = pad_address_lower(&addr);
570        if !emit_zero && normalized == "0".repeat(40) {
571            return;
572        }
573        self.write_key(name);
574        self.buf.push(b'"');
575        self.buf.extend_from_slice(b"0x");
576        self.buf.extend_from_slice(normalized.as_bytes());
577        self.buf.push(b'"');
578    }
579
580    fn write_raw_object(&mut self, name: &str, val: &Value) {
581        // Used for value-stable JSON sub-objects (e.g. ethash struct
582        // which has no fields). Writes `"name":{}` to match Go's encoder.
583        self.write_key(name);
584        if let Some(obj) = val.as_object() {
585            if obj.is_empty() {
586                self.buf.extend_from_slice(b"{}");
587                return;
588            }
589            // Fall back to serde_json for unknown shapes (best-effort).
590            let bytes = serde_json::to_vec(val).unwrap_or_else(|_| b"{}".to_vec());
591            self.buf.extend_from_slice(&bytes);
592        } else {
593            self.buf.extend_from_slice(b"null");
594        }
595    }
596
597    fn write_clique(&mut self, name: &str, val: &Value) {
598        self.write_key(name);
599        let map = val.as_object();
600        let period = map
601            .and_then(|m| m.get("period"))
602            .map(value_to_decimal_int)
603            .unwrap_or_else(|| "0".to_string());
604        let epoch = map
605            .and_then(|m| m.get("epoch"))
606            .map(value_to_decimal_int)
607            .unwrap_or_else(|| "0".to_string());
608        self.buf.extend_from_slice(b"{\"period\":");
609        self.buf.extend_from_slice(period.as_bytes());
610        self.buf.extend_from_slice(b",\"epoch\":");
611        self.buf.extend_from_slice(epoch.as_bytes());
612        self.buf.push(b'}');
613    }
614
615    fn write_arbitrum(&mut self, arbitrum: Option<&Value>) {
616        self.write_key("arbitrum");
617        self.buf.push(b'{');
618        let mut inner = JsonWriter::new(self.buf);
619        let map = arbitrum.and_then(Value::as_object);
620
621        // The first six fields have no `omitempty` tag, so they always
622        // appear even when set to false / 0 / zero address.
623        let always_bool = [
624            "EnableArbOS",
625            "AllowDebugPrecompiles",
626            "DataAvailabilityCommittee",
627        ];
628        for key in always_bool {
629            let v = map
630                .and_then(|m| m.get(key))
631                .and_then(Value::as_bool)
632                .unwrap_or(false);
633            inner.write_bool_field(key, v);
634        }
635
636        let initial_arbos = map
637            .and_then(|m| m.get("InitialArbOSVersion"))
638            .map(value_to_decimal_int)
639            .unwrap_or_else(|| "0".to_string());
640        inner.write_key("InitialArbOSVersion");
641        inner.buf.extend_from_slice(initial_arbos.as_bytes());
642
643        inner.write_address_field(map, "InitialChainOwner", "InitialChainOwner", true);
644
645        let genesis_block = map
646            .and_then(|m| m.get("GenesisBlockNum"))
647            .map(value_to_decimal_int)
648            .unwrap_or_else(|| "0".to_string());
649        inner.write_key("GenesisBlockNum");
650        inner.buf.extend_from_slice(genesis_block.as_bytes());
651
652        // The remaining numeric fields use `omitempty`, so a zero value
653        // is dropped to match Go's encoder.
654        for json_key in ["MaxCodeSize", "MaxInitCodeSize", "MaxUncompressedBatchSize"] {
655            if let Some(v) = map.and_then(|m| m.get(json_key)) {
656                let n = value_to_decimal_int(v);
657                if n != "0" {
658                    inner.write_key(json_key);
659                    inner.buf.extend_from_slice(n.as_bytes());
660                }
661            }
662        }
663
664        self.buf.push(b'}');
665    }
666}
667
668fn value_to_decimal_int(v: &Value) -> String {
669    match v {
670        Value::Number(n) => {
671            if let Some(u) = n.as_u64() {
672                return u.to_string();
673            }
674            if let Some(i) = n.as_i64() {
675                return i.to_string();
676            }
677            n.to_string()
678        }
679        Value::String(s) => {
680            if let Some(rest) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
681                return U256::from_str_radix(rest, 16)
682                    .map(|x| x.to_string())
683                    .unwrap_or_else(|_| "0".to_string());
684            }
685            s.clone()
686        }
687        Value::Bool(b) => (*b as u64).to_string(),
688        _ => "0".to_string(),
689    }
690}
691
692fn pad_address_lower(s: &str) -> String {
693    let trimmed = s.trim_start_matches("0x").to_lowercase();
694    if trimmed.len() >= 40 {
695        return trimmed[trimmed.len() - 40..].to_string();
696    }
697    let mut out = String::with_capacity(40);
698    for _ in 0..(40 - trimmed.len()) {
699        out.push('0');
700    }
701    out.push_str(&trimmed);
702    out
703}
704
705#[cfg(test)]
706mod tests {
707    use serde_json::json;
708
709    use super::*;
710
711    #[test]
712    fn inject_arbos_alloc_rejects_non_object_account() {
713        let key = address_lower_no_prefix(arb_storage::ARBOS_STATE_ADDRESS);
714        let mut alloc = serde_json::Map::new();
715        alloc.insert(key.clone(), Value::String("malformed".into()));
716        let mut chain_spec = json!({
717            "config": { "chainId": 421614 },
718            "alloc": alloc,
719        });
720
721        let error = inject_arbos_alloc(
722            &mut chain_spec,
723            421614,
724            10,
725            Address::ZERO,
726            genesis::ArbOSInit::default(),
727        )
728        .expect_err("a non-object alloc entry must be rejected");
729
730        assert_eq!(error.to_string(), format!("alloc[{key}] is not an object"));
731    }
732
733    #[test]
734    fn serialize_chain_config_matches_v10_default_layout() {
735        // The byte sequence here is what gets written to chain_config
736        // slots (0x...7700+) at genesis for the v10 default spec.
737        let cfg = json!({
738            "chainId": 421614,
739            "homesteadBlock": 0,
740            "daoForkSupport": true,
741            "eip150Block": 0,
742            "eip155Block": 0,
743            "eip158Block": 0,
744            "byzantiumBlock": 0,
745            "constantinopleBlock": 0,
746            "petersburgBlock": 0,
747            "istanbulBlock": 0,
748            "muirGlacierBlock": 0,
749            "berlinBlock": 0,
750            "londonBlock": 0,
751            "depositContractAddress": "0x0000000000000000000000000000000000000000",
752            "clique": {"period": 0, "epoch": 0},
753            "arbitrum": {
754                "EnableArbOS": true,
755                "AllowDebugPrecompiles": false,
756                "DataAvailabilityCommittee": false,
757                "InitialArbOSVersion": 10,
758                "InitialChainOwner": "0x71B61c2E250AFa05dFc36304D6c91501bE0965D8",
759                "GenesisBlockNum": 0u64,
760            }
761        });
762        let bytes = serialize_chain_config_go_style(&cfg);
763        let s = std::str::from_utf8(&bytes).unwrap();
764
765        let expected = "{\"chainId\":421614,\"homesteadBlock\":0,\"daoForkSupport\":true,\"eip150Block\":0,\"eip155Block\":0,\"eip158Block\":0,\"byzantiumBlock\":0,\"constantinopleBlock\":0,\"petersburgBlock\":0,\"istanbulBlock\":0,\"muirGlacierBlock\":0,\"berlinBlock\":0,\"londonBlock\":0,\"depositContractAddress\":\"0x0000000000000000000000000000000000000000\",\"clique\":{\"period\":0,\"epoch\":0},\"arbitrum\":{\"EnableArbOS\":true,\"AllowDebugPrecompiles\":false,\"DataAvailabilityCommittee\":false,\"InitialArbOSVersion\":10,\"InitialChainOwner\":\"0x71b61c2e250afa05dfc36304d6c91501be0965d8\",\"GenesisBlockNum\":0}}";
766        assert_eq!(s, expected, "canonical chain config bytes mismatch");
767        assert_eq!(bytes.len(), 549, "expected 549-byte serialization");
768    }
769
770    #[test]
771    fn serialize_skips_null_and_default_fields() {
772        // Pointer-typed fields with `null` should be skipped; bool fields
773        // default to `false` and are skipped via Go's omitempty for bool.
774        let cfg = json!({
775            "chainId": 421614,
776            "homesteadBlock": 0,
777            "daoForkBlock": null,
778            "daoForkSupport": false,
779            "eip150Block": 0,
780            "arbitrum": {
781                "InitialArbOSVersion": 10,
782            }
783        });
784        let bytes = serialize_chain_config_go_style(&cfg);
785        let s = std::str::from_utf8(&bytes).unwrap();
786        assert!(!s.contains("daoForkBlock"));
787        assert!(!s.contains("daoForkSupport"));
788        // Defaults still emit the 6 required Arbitrum fields.
789        assert!(s.contains("\"EnableArbOS\":false"));
790        assert!(s.contains("\"GenesisBlockNum\":0"));
791        assert!(s.contains("\"InitialChainOwner\":\"0x0000000000000000000000000000000000000000\""));
792    }
793
794    #[test]
795    fn serialize_includes_terminal_total_difficulty_when_set() {
796        let cfg = json!({
797            "chainId": 421614,
798            "terminalTotalDifficulty": 0,
799            "arbitrum": { "InitialArbOSVersion": 10 }
800        });
801        let bytes = serialize_chain_config_go_style(&cfg);
802        let s = std::str::from_utf8(&bytes).unwrap();
803        assert!(s.contains("\"terminalTotalDifficulty\":0"));
804    }
805
806    #[test]
807    fn serialize_lowercases_addresses_and_strips_prefix() {
808        let cfg = json!({
809            "chainId": 1,
810            "depositContractAddress": "0xABCDEF0000000000000000000000000000000123",
811            "arbitrum": {
812                "InitialChainOwner": "0xABCDEF0000000000000000000000000000000123",
813            }
814        });
815        let bytes = serialize_chain_config_go_style(&cfg);
816        let s = std::str::from_utf8(&bytes).unwrap();
817        assert!(
818            s.contains("\"depositContractAddress\":\"0xabcdef0000000000000000000000000000000123\"")
819        );
820        assert!(s.contains("\"InitialChainOwner\":\"0xabcdef0000000000000000000000000000000123\""));
821    }
822}