From 390027dae035299a8d04482a93702e05deca0d76 Mon Sep 17 00:00:00 2001 From: Alexey Shekhirin <5773434+shekhirin@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:36:04 +0000 Subject: [PATCH] feat(node): derive L1 portal addresses from genesis Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com> --- Justfile | 3 - README.md | 10 ++ contrib/bench/provision-topology.sh | 1 - crates/evm/src/lib.rs | 17 ++- crates/node/src/cli.rs | 167 +++++++++++++++------------- crates/node/src/dev.rs | 2 - docs/ZONES.md | 2 +- 7 files changed, 115 insertions(+), 87 deletions(-) diff --git a/Justfile b/Justfile index ec79299c1..d18992129 100644 --- a/Justfile +++ b/Justfile @@ -246,7 +246,6 @@ zone-up name reset="false" profile="dev" args="": echo "Error: $GENESIS_JSON not found. Run 'just create-zone {{name}}' first." >&2 exit 1 fi - PORTAL=$(jq -r '.portal' "$ZONE_JSON") ANCHOR_BLOCK=$(jq -r '.tempoAnchorBlock' "$ZONE_JSON") ZONE_ID=$(jq -r '.zoneId' "$ZONE_JSON") SEQ_KEY_FILE="${SEQUENCER_KEY_FILE:-}" @@ -271,7 +270,6 @@ zone-up name reset="false" profile="dev" args="": node \ --chain "$GENESIS_JSON" \ --l1.rpc-url "${L1_RPC_URL:?Set L1_RPC_URL env var (wss://...)}" \ - --l1.portal-address "$PORTAL" \ --zone.id "$ZONE_ID" \ --http \ --http.addr 0.0.0.0 \ @@ -959,7 +957,6 @@ deploy-zone name token="" access_enforced="false" gateway_enforced="false": node \ --chain "$OUTPUT/genesis.json" \ --l1.rpc-url "$L1_RPC" \ - --l1.portal-address "$PORTAL" \ --zone.id "$ZONE_ID" \ --http \ --http.addr 0.0.0.0 \ diff --git a/README.md b/README.md index 2487f9bd3..a951496cd 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,16 @@ The `deploy-zone` command generates admin and sequencer keypairs, funds them on just zone-up my-zone ``` +Nodes derive the L1 portal address locally from the zone ID encoded in the genesis +chain ID, using Tempo's deterministic `ZoneFactory` address scheme. All node +services, including the checker, use that derived address; no factory RPC call or +`L1_PORTAL_ADDRESS` configuration is needed. Re-execution and other CLI commands +using `L1_HTTP_RPC_URL` also derive the address from their chain specification. +The node's deprecated `--l1.portal-address` flag and `L1_PORTAL_ADDRESS` environment +variable remain accepted as consistency checks and must match the derived address +when supplied. Legacy portals deployed from an EOA are not supported by this node +startup path. + ### Depositing into a Zone ```bash diff --git a/contrib/bench/provision-topology.sh b/contrib/bench/provision-topology.sh index 91eb6054d..2b22dacee 100755 --- a/contrib/bench/provision-topology.sh +++ b/contrib/bench/provision-topology.sh @@ -976,7 +976,6 @@ provision_up() { "$ZONE_BIN" node \ --chain "$zone_genesis" --datadir "$zone_db" \ --l1.rpc-url ws://127.0.0.1:8545 \ - --l1.portal-address "$portal" \ --http --http.addr 127.0.0.1 --http.port 8546 \ --http.api all \ --ws --ws.addr 127.0.0.1 --ws.port 8546 \ diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index 915bc99cd..e6a8e2abd 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -288,17 +288,19 @@ impl ZoneEvmConfig { /// EVM config but don't have access to an L1 RPC connection. Tempo hardfork conditions come /// from `chain_spec` because the parent L1 spec cannot be resolved in this mode. pub fn new_without_l1(chain_spec: Arc) -> Self { + let portal_address = tempo_precompiles::zone_factory::portal_address(chain_spec.zone_id()); let cache = L1StateCache::default(); let provider = ProviderBuilder::new_with_network::() .connect_http("http://127.0.0.1:1".parse().expect("valid fallback URL")) .erased(); let runtime_handle = tokio::runtime::Handle::current(); let config = L1StateProviderConfig { + portal_address, max_sync_attempts: Some(NonZeroU32::MIN), ..Default::default() }; let l1_provider = L1StateProvider::new_raw(config, cache, provider, runtime_handle); - Self::new(chain_spec, l1_provider, Address::ZERO) + Self::new(chain_spec, l1_provider, portal_address) } } @@ -515,6 +517,19 @@ mod tests { use zone_precompiles::{tempo_state::TEMPO_BLOCK_NUMBER_SLOT, test_utils::MockL1Reader}; use zone_primitives::constants::{TEMPO_STATE_ADDRESS, ZONE_INBOX_ADDRESS, zone_chain_id}; + #[tokio::test] + async fn offline_evm_derives_portal_from_genesis() { + let mut genesis = MODERATO.genesis().clone(); + genesis.config.chain_id = zone_chain_id(MODERATO.chain().id(), 7).unwrap(); + let chain_spec = Arc::new(ZoneChainSpec::from_genesis(genesis).unwrap()); + let config = ZoneEvmConfig::new_without_l1(chain_spec); + + assert_eq!( + config.zone_factory.portal_address, + address!("5ad0000000000000000000000000000000000007") + ); + } + #[test] fn l1_storage_recorder_deduplicates_successful_reads() { let account = Address::repeat_byte(0xaa); diff --git a/crates/node/src/cli.rs b/crates/node/src/cli.rs index e990f3538..5dbc9839e 100644 --- a/crates/node/src/cli.rs +++ b/crates/node/src/cli.rs @@ -11,6 +11,7 @@ use reth_ethereum::cli::Cli; use reth_tracing::tracing::{info, warn}; use tempo_alloy::TempoNetwork; use tempo_evm::consensus::TempoConsensus; +use tempo_precompiles::zone_factory::portal_address; use zeroize::Zeroizing; use zone_chainspec::{ZoneChainSpec, ZoneChainSpecParser}; use zone_evm::ZoneEvmConfig; @@ -104,19 +105,7 @@ fn run_node(mut cli: Cli) -> eyre::Result<()> { let url = url .parse() .map_err(|error| eyre::eyre!("invalid L1_HTTP_RPC_URL: {error}"))?; - let portal_address: Address = std::env::var("L1_PORTAL_ADDRESS") - .map_err(|error| { - eyre::eyre!( - "L1_PORTAL_ADDRESS must be set when L1_HTTP_RPC_URL is set: {error}" - ) - })? - .parse() - .map_err(|error| eyre::eyre!("invalid L1_PORTAL_ADDRESS: {error}"))?; - eyre::ensure!( - !portal_address.is_zero(), - "L1_PORTAL_ADDRESS must be nonzero" - ); - Some((url, portal_address)) + Some(url) } Ok(_) | Err(std::env::VarError::NotPresent) => None, Err(error) => return Err(eyre::eyre!("invalid L1_HTTP_RPC_URL: {error}")), @@ -141,6 +130,8 @@ fn run_node(mut cli: Cli) -> eyre::Result<()> { let zone_id = builder.config().chain.zone_id(); validate_deprecated_zone_id(args.zone_id, zone_id)?; + let portal_address = resolve_portal_address(args.portal_address, &builder.config().chain)?; + info!(target: "reth::cli", %portal_address, zone_id, "Derived L1 portal address from genesis chain ID"); let manifest_mode = args.sequencer_manifest.is_some(); validate_p2p_transaction_size_limit( @@ -163,7 +154,7 @@ fn run_node(mut cli: Cli) -> eyre::Result<()> { let mut node = ZoneNode::new( args.l1_rpc_url.clone(), - args.portal_address, + portal_address, args.l1_fetch_concurrency, Duration::from_millis(args.l1_retry_connection_interval_ms), ) @@ -195,7 +186,7 @@ fn run_node(mut cli: Cli) -> eyre::Result<()> { let node = node.with_portal_evidence_retention(); let checker = CheckerExEx::new(CheckerConfig { l1_rpc_url: args.l1_rpc_url.clone(), - portal_address: args.portal_address, + portal_address, zone_id, zone_chain_id: builder.config().chain.chain().id(), database_path: builder.config().datadir().data_dir().join("checker"), @@ -214,20 +205,21 @@ fn run_node(mut cli: Cli) -> eyre::Result<()> { } /// Creates the EVM config used by CLI subcommands. -fn cli_evm_config( - chain_spec: Arc, - l1_config: Option<(url::Url, Address)>, -) -> ZoneEvmConfig { - let Some((l1_rpc_url, portal_address)) = l1_config else { +fn cli_evm_config(chain_spec: Arc, l1_rpc_url: Option) -> ZoneEvmConfig { + let Some(l1_rpc_url) = l1_rpc_url else { return ZoneEvmConfig::new_without_l1(chain_spec); }; + let portal_address = portal_address(chain_spec.zone_id()); let cache = L1StateCache::default(); let provider = ProviderBuilder::new_with_network::() .connect_http(l1_rpc_url) .erased(); let runtime_handle = tokio::runtime::Handle::current(); - let config = L1StateProviderConfig::default(); + let config = L1StateProviderConfig { + portal_address, + ..Default::default() + }; let l1_provider = L1StateProvider::new_raw(config, cache, provider, runtime_handle); ZoneEvmConfig::new(chain_spec, l1_provider, portal_address) } @@ -382,13 +374,14 @@ pub struct ZoneArgs { )] pub l1_rpc_url: String, - /// ZonePortal contract address on L1. + /// Deprecated consistency check. The L1 portal address is derived from the genesis chain ID. + /// If supplied, this must match the derived address; it cannot override it. #[arg( long = "l1.portal-address", env = "L1_PORTAL_ADDRESS", value_parser = parse_portal_address )] - pub portal_address: Address, + pub portal_address: Option
, /// Deprecated compatibility flag. Ignored. #[arg( @@ -638,6 +631,22 @@ fn parse_portal_address(value: &str) -> Result { Ok(address) } +fn resolve_portal_address( + configured: Option
, + chain_spec: &ZoneChainSpec, +) -> eyre::Result
{ + let zone_id = chain_spec.zone_id(); + let derived = portal_address(zone_id); + if let Some(configured) = configured { + eyre::ensure!( + configured == derived, + "deprecated --l1.portal-address / L1_PORTAL_ADDRESS value {configured} does not match portal address {derived} derived from zone ID {zone_id} encoded in the genesis chain ID" + ); + warn!(target: "reth::cli", "--l1.portal-address / L1_PORTAL_ADDRESS is deprecated; remove it to derive the portal address from genesis"); + } + Ok(derived) +} + fn validate_deprecated_zone_id(configured: Option, derived: u32) -> eyre::Result<()> { if let Some(configured) = configured { eyre::ensure!( @@ -656,7 +665,8 @@ mod tests { use super::{ Role, ZoneArgs, ZoneCli, load_decryption_keys, load_sequencer_signer, parse_l1_rpc_url, - parse_portal_address, validate_deprecated_zone_id, validate_p2p_transaction_size_limit, + parse_portal_address, resolve_portal_address, validate_deprecated_zone_id, + validate_p2p_transaction_size_limit, }; use zone_sequencer::MAX_WITHDRAWAL_BATCH_GAS; @@ -668,15 +678,10 @@ mod tests { #[test] fn checker_mode_defaults_to_off() { - let args = ZoneArgsParser::try_parse_from([ - "tempo-zone", - "--l1.rpc-url", - "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", - ]) - .unwrap() - .zone; + let args = + ZoneArgsParser::try_parse_from(["tempo-zone", "--l1.rpc-url", "ws://localhost:8546"]) + .unwrap() + .zone; assert_eq!(args.checker_mode, zone_checker::CheckerMode::Off); } @@ -686,8 +691,6 @@ mod tests { "tempo-zone", "--l1.rpc-url", "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", "--checker.mode", "observe", ]) @@ -702,8 +705,6 @@ mod tests { "tempo-zone", "--l1.rpc-url", "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", "--checker.mode", "enforce", ]); @@ -730,6 +731,44 @@ mod tests { assert!(parse_portal_address("0x1111111111111111111111111111111111111111").is_ok()); } + #[test] + fn portal_address_is_derived_from_genesis_without_an_argument() { + use reth_chainspec::EthChainSpec as _; + use tempo_chainspec::spec::{DEV, MODERATO, PRESTO}; + + let parsed = + ZoneArgsParser::try_parse_from(["tempo-zone", "--l1.rpc-url", "ws://localhost:8546"]) + .unwrap(); + assert_eq!(parsed.zone.portal_address, None); + + // Neither production chain ID offsets nor generic parent chain bits belong + // in the portal's zone ID suffix. + for parent in [DEV.clone(), MODERATO.clone(), PRESTO.clone()] { + let mut genesis = parent.genesis().clone(); + genesis.config.chain_id = + zone_primitives::constants::zone_chain_id(parent.chain().id(), 0x0102_0304) + .unwrap(); + let spec = zone_chainspec::ZoneChainSpec::from_genesis(genesis).unwrap(); + let expected = alloy_primitives::address!("5ad0000000000000000000000000000001020304"); + assert_eq!( + resolve_portal_address(parsed.zone.portal_address, &spec).unwrap(), + expected + ); + assert_eq!( + resolve_portal_address(Some(expected), &spec).unwrap(), + expected + ); + + for wrong in [ + alloy_primitives::Address::ZERO, + alloy_primitives::Address::repeat_byte(0x11), + ] { + let err = resolve_portal_address(Some(wrong), &spec).unwrap_err(); + assert!(err.to_string().contains("does not match portal address")); + } + } + } + #[test] fn deprecated_zone_id_is_accepted_and_validated() { assert!(validate_deprecated_zone_id(None, 7).is_ok()); @@ -741,12 +780,18 @@ mod tests { "--l1.rpc-url", "ws://localhost:8546", "--l1.portal-address", - "0x0000000000000000000000000000000000000001", + "0x5ad0000000000000000000000000000000000007", "--zone.id", "7", ]) .unwrap(); assert_eq!(parsed.zone.zone_id, Some(7)); + assert_eq!( + parsed.zone.portal_address, + Some(alloy_primitives::address!( + "5ad0000000000000000000000000000000000007" + )) + ); } #[test] @@ -771,13 +816,7 @@ mod tests { #[test] fn sequencer_key_file_is_accepted_and_inline_key_option_is_rejected() { - let common = [ - "tempo-zone", - "--l1.rpc-url", - "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", - ]; + let common = ["tempo-zone", "--l1.rpc-url", "ws://localhost:8546"]; let parsed = ZoneArgsParser::try_parse_from( common @@ -889,13 +928,7 @@ mod tests { #[test] fn manifest_mode_requires_the_p2p_key_and_conflicts_with_legacy_sequencer() { - let common = [ - "tempo-zone", - "--l1.rpc-url", - "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", - ]; + let common = ["tempo-zone", "--l1.rpc-url", "ws://localhost:8546"]; let missing_key = ZoneArgsParser::try_parse_from( common @@ -939,8 +972,6 @@ mod tests { "tempo-zone", "--l1.rpc-url", "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", "--sequencer", ]) .unwrap(); @@ -950,13 +981,7 @@ mod tests { #[test] fn zone_poll_interval_keeps_one_second_default_and_accepts_override() { - let common = [ - "tempo-zone", - "--l1.rpc-url", - "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", - ]; + let common = ["tempo-zone", "--l1.rpc-url", "ws://localhost:8546"]; let default = ZoneArgsParser::try_parse_from(common).unwrap(); assert_eq!(default.zone.zone_poll_interval_secs, 1); @@ -970,13 +995,7 @@ mod tests { #[test] fn private_rpc_port_alias_is_accepted() { - let common = [ - "tempo-zone", - "--l1.rpc-url", - "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", - ]; + let common = ["tempo-zone", "--l1.rpc-url", "ws://localhost:8546"]; let redacted = ZoneArgsParser::try_parse_from( common.into_iter().chain(["--redacted-rpc.port", "9544"]), @@ -998,8 +1017,6 @@ mod tests { "tempo-zone", "--l1.rpc-url", "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", "--withdrawal-max-batch-gas", &above_limit, ]) @@ -1009,13 +1026,7 @@ mod tests { #[test] fn p2p_ip_check_bypass_is_explicit_and_requires_manifest_mode() { - let common = [ - "tempo-zone", - "--l1.rpc-url", - "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", - ]; + let common = ["tempo-zone", "--l1.rpc-url", "ws://localhost:8546"]; let without_manifest = ZoneArgsParser::try_parse_from(common.into_iter().chain(["--p2p.bypass-ip-check"])) @@ -1047,8 +1058,6 @@ mod tests { "tempo-zone", "--l1.rpc-url", "ws://localhost:8546", - "--l1.portal-address", - "0x0000000000000000000000000000000000000001", "--sequencer.manifest", "zone.toml", "--p2p.key", diff --git a/crates/node/src/dev.rs b/crates/node/src/dev.rs index 987becfd2..88bcf245f 100644 --- a/crates/node/src/dev.rs +++ b/crates/node/src/dev.rs @@ -423,8 +423,6 @@ mod command { &genesis_path.display().to_string(), "--l1.rpc-url", &self.l1_rpc_url, - "--l1.portal-address", - &provisioned.portal.to_string(), "--http", "--http.addr", &self.http_addr, diff --git a/docs/ZONES.md b/docs/ZONES.md index 8f6b5e30f..3a00059eb 100644 --- a/docs/ZONES.md +++ b/docs/ZONES.md @@ -682,7 +682,7 @@ cast code 0x5A4d000000000000000000000000000000000000 --rpc-url "$ETH_RPC_URL" | Flag | Default | Description | |------|---------|-------------| | `--l1.rpc-url` | (required) | Certified Tempo follower WebSocket RPC URL | -| `--l1.portal-address` | (from zone.json) | ZonePortal contract on L1 | +| `--l1.portal-address` | Derived from genesis chain ID | Deprecated consistency check; if supplied, must match the derived ZonePortal address | | `--zone.id` | (deprecated) | Optional compatibility check against the zone ID encoded in the genesis chain ID. | | `--sequencer` | false | Enable sequencer mode for block production and withdrawal batch submission | | `--sequencer-key-file` | (required for sequencing) | Owner-readable file or FIFO containing the sequencer private key |