From 1fd81a7889cd4514839a2fa920d09680264e551d Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Sat, 8 Aug 2026 02:36:49 -0300 Subject: [PATCH] fix(lez)!: first sequencer stake ownership account on genesis block --- .../tests/sequencer_stake_demo.rs | 14 --- .../docker-all-in-one/sequencer_config.json | 35 +++++- lez/sequencer/core/Cargo.toml | 2 +- lez/sequencer/core/src/config.rs | 8 +- lez/sequencer/core/src/lib.rs | 118 ++++++++++++------ lez/sequencer/core/src/tests.rs | 23 ++-- .../core/src/tests/reconstruction.rs | 5 +- .../configs/debug/sequencer_config.json | 35 +++++- .../configs/docker/sequencer_config.json | 35 +++++- lez/system_accounts/src/lib.rs | 47 +------ lez/testnet_initial_state/src/lib.rs | 2 +- test_fixtures/src/config.rs | 9 +- 12 files changed, 205 insertions(+), 128 deletions(-) diff --git a/integration_tests/tests/sequencer_stake_demo.rs b/integration_tests/tests/sequencer_stake_demo.rs index 964435e9a..31bef1a31 100644 --- a/integration_tests/tests/sequencer_stake_demo.rs +++ b/integration_tests/tests/sequencer_stake_demo.rs @@ -247,20 +247,6 @@ async fn stake_transaction_joins_the_bedrock_committee() -> Result<()> { ); info!("Demo sequencer key removed from the Bedrock committee"); - // The stake is still fully at stake at this point: the removal only changes - // the Bedrock committee, it releases nothing on L2. - let staked_entry = stake_entry(&ctx, config_id, demo_sequencer_key.to_bytes()) - .await? - .context("config entry should still exist before FinalizeUnstake")?; - assert_eq!( - staked_entry.total_staked, FUNDING_BALANCE, - "the full amount should still be at stake before FinalizeUnstake" - ); - assert_eq!( - staked_entry.total_pending_unstake, FUNDING_BALANCE, - "the full amount should be pending release before FinalizeUnstake" - ); - // Once removed, the sequencer injects FinalizeUnstake itself; this test // never submits one. poll_until( diff --git a/lez/configs/docker-all-in-one/sequencer_config.json b/lez/configs/docker-all-in-one/sequencer_config.json index f8f9f82f6..5c6aa0547 100644 --- a/lez/configs/docker-all-in-one/sequencer_config.json +++ b/lez/configs/docker-all-in-one/sequencer_config.json @@ -79,5 +79,38 @@ 37, 37 ], - "sequencer_stake_account_id": "FHAMGz4rorhVkJzYc28DQ6HozxBPiYMtbStGfRTvqC6o" + "sequencer_stake_signing_key": [ + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55 + ] } diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index 25f746cf2..65d64c458 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -18,6 +18,7 @@ mempool.workspace = true logos-blockchain-zone-sdk.workspace = true testnet_initial_state.workspace = true faucet_core.workspace = true +authenticated_transfer_core.workspace = true bridge_core.workspace = true vault_core.workspace = true programs.workspace = true @@ -64,4 +65,3 @@ lee = { workspace = true, features = ["test-utils"] } key_protocol.workspace = true token_core.workspace = true ping_core.workspace = true -authenticated_transfer_core.workspace = true diff --git a/lez/sequencer/core/src/config.rs b/lez/sequencer/core/src/config.rs index d6775a0f3..564e6b718 100644 --- a/lez/sequencer/core/src/config.rs +++ b/lez/sequencer/core/src/config.rs @@ -68,9 +68,11 @@ pub struct SequencerConfig { pub retry_pending_blocks_timeout: Duration, /// Sequencer own signing key. pub signing_key: [u8; 32], - /// LEZ account holding this sequencer's genesis stake. The operator must - /// hold its key to top up or unstake. - pub sequencer_stake_account_id: AccountId, + /// Signing key of the LEZ account backing this sequencer's genesis stake. + /// Needed to sign the genesis `Stake` transaction, so top-up/unstake later + /// use the same account. + // TODO: move out of the config file and into its own file, like `signing_key`. + pub sequencer_stake_signing_key: [u8; 32], /// Bedrock configuration options. pub bedrock_config: BedrockConfig, /// Genesis configuration. diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index cedc9177f..db37fba9f 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -201,7 +201,6 @@ impl SequencerCore { config: &SequencerConfig, store: &SequencerStore, stored_head_state: &lee::V03State, - bootstrap_sequencer_key: Option, ) -> ChainState { let final_snapshot = store .dbio() @@ -210,7 +209,7 @@ impl SequencerCore { let (final_state, final_tip) = match final_snapshot { Some((state, meta)) => (state, Some(Tip::from(meta))), // Nothing finalized yet: replay the whole stored chain. - None => (build_initial_state(config, bootstrap_sequencer_key), None), + None => (build_initial_state(config), None), }; let boundary = final_tip.as_ref().map_or(0, |tip| tip.block_id); @@ -269,12 +268,7 @@ impl SequencerCore { let (store, state) = Self::open_or_create_store(&config, bootstrap_sequencer_key); - let chain = Arc::new(Mutex::new(Self::restore_chain_state( - &config, - &store, - &state, - bootstrap_sequencer_key, - ))); + let chain = Arc::new(Mutex::new(Self::restore_chain_state(&config, &store, &state))); let initial_checkpoint = store .get_zone_checkpoint() @@ -1639,14 +1633,11 @@ fn apply_follow_update( } } -/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock -/// holdings, and the bootstrap sequencer's own stake if `bootstrap_sequencer_key` -/// is set (this node is starting a new channel). `None` means the channel -/// already exists and this node will self-join later instead. -fn build_initial_state( - config: &SequencerConfig, - bootstrap_sequencer_key: Option, -) -> lee::V03State { +/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings, +/// the only accounts seeded outside any transaction. Everything else, including +/// the bootstrap sequencer's own stake, is applied as a genesis transaction in +/// [`build_genesis_state`] so followers replay it instead of guessing it. +fn build_initial_state(config: &SequencerConfig) -> lee::V03State { #[cfg(not(feature = "testnet"))] let base = testnet_initial_state::initial_state(); @@ -1658,29 +1649,7 @@ fn build_initial_state( // InitConfig transactions in `build_genesis_state`, not here. let holdings = bridge_lock_holdings(&config.genesis) .map(|(holder, amount)| cross_zone::build_holding_account(holder, amount)); - // Replaces the base state's (entries-empty) config account with one that - // also carries the bootstrap sequencer's own entry — `with_public_accounts` - // overwrites on a matching account id. - let bootstrap_accounts = bootstrap_sequencer_key.into_iter().flat_map(|key| { - [ - ( - config.sequencer_stake_account_id, - system_accounts::sequencer_stake_bootstrap_account(key), - ), - ( - system_accounts::sequencer_stake_config_account_id(), - system_accounts::sequencer_stake_config_account(std::collections::BTreeMap::from( - [( - key, - system_accounts::sequencer_stake_bootstrap_entry( - config.sequencer_stake_account_id, - ), - )], - )), - ), - ] - }); - base.with_public_accounts(holdings.chain(bootstrap_accounts)) + base.with_public_accounts(holdings) } /// Builds the initial genesis state from [`build_initial_state`] plus configured @@ -1691,7 +1660,7 @@ fn build_genesis_state( config: &SequencerConfig, bootstrap_sequencer_key: Option, ) -> (lee::V03State, Vec) { - let mut state = build_initial_state(config, bootstrap_sequencer_key); + let mut state = build_initial_state(config); // Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's. log::info!( @@ -1733,6 +1702,9 @@ fn build_genesis_state( // genesis tx. GenesisAction::SupplyBridgeLockHolding { .. } => None, }); + let bootstrap_stake_txs = bootstrap_sequencer_key.into_iter().flat_map(|key| { + build_bootstrap_stake_genesis_transactions(key, config.sequencer_stake_signing_key) + }); let genesis_txs = wrapped_token_config_tx .chain(ping_sender_config_tx) @@ -1740,6 +1712,7 @@ fn build_genesis_state( .chain(bridge_lock_config_tx) .chain(inbox_config_tx) .chain(supply_txs) + .chain(bootstrap_stake_txs) .chain(std::iter::once(clock_invocation(0))) .inspect(|tx| { state @@ -1752,6 +1725,71 @@ fn build_genesis_state( (state, genesis_txs) } +/// Fixed, public key behind a genesis-only funding account: the faucet can +/// only be called top-level, not as `Stake`'s mover, so this account is a +/// pass-through that receives faucet funds and then moves them into the real +/// stake account. Not a secret: every node derives the same account, and it +/// holds nothing once genesis has run. +// TODO: replace the faucet pass-through with a real deposit from Bedrock, +// once that path exists, instead of a fixed genesis-only key. +const GENESIS_STAKE_FUNDING_KEY: [u8; 32] = [9; 32]; + +/// The bootstrap sequencer's own `Stake`, funded via the faucet and signed +/// with `stake_signing_key` so a later top-up/unstake use the same account. +/// Real transactions, not raw state, so followers replay them instead of +/// missing them. +fn build_bootstrap_stake_genesis_transactions( + sequencer_key: sequencer_stake_core::SequencerKey, + stake_signing_key: [u8; 32], +) -> [PublicTransaction; 2] { + let stake_key = lee::PrivateKey::try_new(stake_signing_key).unwrap(); + let ownership_id = AccountId::from(&lee::PublicKey::new_from_private_key(&stake_key)); + let funding_key = lee::PrivateKey::try_new(GENESIS_STAKE_FUNDING_KEY).unwrap(); + let funding_id = AccountId::from(&lee::PublicKey::new_from_private_key(&funding_key)); + let amount = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; + + let fund_message = Message::try_new( + programs::faucet().id(), + vec![system_accounts::faucet_account_id(), funding_id], + vec![lee_core::account::Nonce(0)], + faucet_core::Instruction::GenesisTransferDirect { amount }, + ) + .expect("Failed to build genesis funding message"); + let fund_witness_set = + lee::public_transaction::WitnessSet::for_message(&fund_message, &[&funding_key]); + let fund_tx = PublicTransaction::new(fund_message, fund_witness_set); + + let mover_instruction_data = lee::program::Program::serialize_instruction( + authenticated_transfer_core::Instruction::Transfer { amount }, + ) + .expect("Failed to serialize genesis mover instruction"); + let stake_message = Message::try_new( + programs::sequencer_stake().id(), + vec![ + funding_id, + ownership_id, + system_accounts::sequencer_stake_config_account_id(), + ], + // funding_key already signed fund_tx above, so it's at nonce 1; stake_key + // signs for the first time here, still at nonce 0. + vec![lee_core::account::Nonce(1), lee_core::account::Nonce(0)], + sequencer_stake_core::Instruction::Stake { + sequencer_key, + amount, + mover_program_id: programs::authenticated_transfer().id(), + mover_instruction_data, + }, + ) + .expect("Failed to build genesis Stake message"); + let stake_witness_set = lee::public_transaction::WitnessSet::for_message( + &stake_message, + &[&funding_key, &stake_key], + ); + let stake_tx = PublicTransaction::new(stake_message, stake_witness_set); + + [fund_tx, stake_tx] +} + /// Bridge-lock holder balances configured for this zone's genesis. fn bridge_lock_holdings( genesis: &[GenesisAction], diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index fb7687757..b2981bf08 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -36,7 +36,7 @@ use crate::{ block_publisher::FollowUpdate, block_store::SequencerStore, build_bridge_deposit_tx_from_event, build_finalize_unstake_tx, build_genesis_state, - build_initial_state, classify_settled_deliveries, + classify_settled_deliveries, config::{ self, BedrockConfig, CrossZoneConfig, CrossZonePeer, CrossZoneRoute, GenesisAction, SequencerConfig, @@ -104,7 +104,7 @@ fn setup_sequencer_config() -> SequencerConfig { mempool_max_size: 10000, block_create_timeout: Duration::from_secs(1), signing_key: *sequencer_sign_key_for_testing().value(), - sequencer_stake_account_id: bootstrap_stake_account_id(), + sequencer_stake_signing_key: *bootstrap_stake_key().value(), bedrock_config: BedrockConfig { channel_id: ChannelId::from([0; 32]), node_url: "http://not-used-in-unit-tests".parse().unwrap(), @@ -2931,7 +2931,7 @@ fn diag_sequencer_stake_claims_ownership_account() { ), ( config_id, - system_accounts::sequencer_stake_config_account(std::collections::BTreeMap::new()), + system_accounts::sequencer_stake_config_account(), ), ]); @@ -3052,7 +3052,7 @@ fn stake_test_state(funding_id: AccountId, funding_balance: u128) -> V03State { ), ( system_accounts::sequencer_stake_config_account_id(), - system_accounts::sequencer_stake_config_account(std::collections::BTreeMap::new()), + system_accounts::sequencer_stake_config_account(), ), ]) } @@ -3281,7 +3281,7 @@ fn a_fully_exited_ownership_account_can_stake_again() { ), ( system_accounts::sequencer_stake_config_account_id(), - system_accounts::sequencer_stake_config_account(std::collections::BTreeMap::new()), + system_accounts::sequencer_stake_config_account(), ), ]); @@ -3366,9 +3366,9 @@ fn a_fully_exited_ownership_account_can_stake_again() { fn genesis_stakes_the_bootstrap_sequencer_at_the_configured_account() { let config = setup_sequencer_config(); let bootstrap_sequencer_key = test_bootstrap_sequencer_key(&config); - let state = build_initial_state(&config, Some(bootstrap_sequencer_key)); + let (state, _genesis_txs) = build_genesis_state(&config, Some(bootstrap_sequencer_key)); - let stake_account = state.get_account_by_id(config.sequencer_stake_account_id); + let stake_account = state.get_account_by_id(bootstrap_stake_account_id()); assert_eq!( stake_account.program_owner, programs::sequencer_stake().id() @@ -3387,7 +3387,7 @@ fn genesis_stakes_the_bootstrap_sequencer_at_the_configured_account() { .expect("genesis config account should decode"); assert_eq!( stake_config.entries[&bootstrap_sequencer_key].account_id, - config.sequencer_stake_account_id + bootstrap_stake_account_id() ); } @@ -3397,9 +3397,9 @@ fn genesis_stakes_the_bootstrap_sequencer_at_the_configured_account() { fn the_bootstrap_sequencer_can_request_an_unstake_of_its_genesis_stake() { let config = setup_sequencer_config(); let bootstrap_sequencer_key = test_bootstrap_sequencer_key(&config); - let mut state = build_initial_state(&config, Some(bootstrap_sequencer_key)); + let (mut state, _genesis_txs) = build_genesis_state(&config, Some(bootstrap_sequencer_key)); - let stake_id = config.sequencer_stake_account_id; + let stake_id = bootstrap_stake_account_id(); let destination = AccountId::from(&PublicKey::new_from_private_key( &PrivateKey::try_new([56; 32]).unwrap(), )); @@ -3410,7 +3410,8 @@ fn the_bootstrap_sequencer_can_request_an_unstake_of_its_genesis_stake() { stake_id, system_accounts::sequencer_stake_config_account_id(), ], - vec![Nonce(0)], + // The genesis Stake transaction already signed once with this account. + vec![Nonce(1)], sequencer_stake_core::Instruction::UnstakeRequest { amount: system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE, destination, diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs index abc007c94..596c1c26a 100644 --- a/lez/sequencer/core/src/tests/reconstruction.rs +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -24,10 +24,7 @@ fn fresh_store_and_chain(config: &SequencerConfig) -> (SequencerStore, Mutex::open_or_create_store(config, bootstrap_sequencer_key); let chain = Mutex::new(SequencerCore::::restore_chain_state( - config, - &store, - &state, - bootstrap_sequencer_key, + config, &store, &state, )); (store, chain) } diff --git a/lez/sequencer/service/configs/debug/sequencer_config.json b/lez/sequencer/service/configs/debug/sequencer_config.json index 0af473a11..68ede6866 100644 --- a/lez/sequencer/service/configs/debug/sequencer_config.json +++ b/lez/sequencer/service/configs/debug/sequencer_config.json @@ -83,5 +83,38 @@ 37, 37 ], - "sequencer_stake_account_id": "FHAMGz4rorhVkJzYc28DQ6HozxBPiYMtbStGfRTvqC6o" + "sequencer_stake_signing_key": [ + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55 + ] } diff --git a/lez/sequencer/service/configs/docker/sequencer_config.json b/lez/sequencer/service/configs/docker/sequencer_config.json index 5b1c2e768..8bd396417 100644 --- a/lez/sequencer/service/configs/docker/sequencer_config.json +++ b/lez/sequencer/service/configs/docker/sequencer_config.json @@ -79,5 +79,38 @@ 37, 37 ], - "sequencer_stake_account_id": "FHAMGz4rorhVkJzYc28DQ6HozxBPiYMtbStGfRTvqC6o" + "sequencer_stake_signing_key": [ + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55, + 55 + ] } diff --git a/lez/system_accounts/src/lib.rs b/lez/system_accounts/src/lib.rs index f48080512..df7e11dad 100644 --- a/lez/system_accounts/src/lib.rs +++ b/lez/system_accounts/src/lib.rs @@ -74,20 +74,15 @@ pub fn sequencer_stake_config_account_id() -> AccountId { sequencer_stake_core::sequencer_stake_config_account_id(programs::sequencer_stake().id()) } -/// `entries` is empty in the shared base state (no sequencer key is known there). -/// -/// A running sequencer's `build_initial_state` replaces this account with one -/// whose `entries` map also carries the bootstrap sequencer's own entry, -/// alongside seeding [`sequencer_stake_bootstrap_account`]. +/// Starts with no entries; every stake, including the bootstrap sequencer's +/// own, is added by replaying a `Stake` transaction, not seeded here. #[must_use] -pub fn sequencer_stake_config_account( - entries: BTreeMap, -) -> Account { +pub fn sequencer_stake_config_account() -> Account { Account { program_owner: programs::sequencer_stake().id(), data: sequencer_stake_core::SequencerStakeConfig { minimum_sequencer_stake: DEFAULT_MINIMUM_SEQUENCER_STAKE, - entries, + entries: BTreeMap::new(), } .to_bytes() .try_into() @@ -96,40 +91,6 @@ pub fn sequencer_stake_config_account( } } -/// Genesis stake for the sequencer that bootstraps the channel. -/// -/// Seeded at the LEZ account named in the sequencer's config, so the operator -/// can sign for it. -#[must_use] -pub fn sequencer_stake_bootstrap_account( - sequencer_key: sequencer_stake_core::SequencerKey, -) -> Account { - Account { - program_owner: programs::sequencer_stake().id(), - balance: DEFAULT_MINIMUM_SEQUENCER_STAKE, - data: sequencer_stake_core::StakeRecord { - sequencer_key, - pending_unstake: None, - } - .to_bytes() - .try_into() - .expect("stake record should fit"), - ..Account::default() - } -} - -/// The config-account entry backing [`sequencer_stake_bootstrap_account`]. -#[must_use] -pub const fn sequencer_stake_bootstrap_entry( - account_id: AccountId, -) -> sequencer_stake_core::SequencerEntry { - sequencer_stake_core::SequencerEntry { - account_id, - total_staked: DEFAULT_MINIMUM_SEQUENCER_STAKE, - total_pending_unstake: 0, - } -} - #[must_use] pub fn clock_account() -> Account { Account { diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index 323e7221d..3c63ae182 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -214,7 +214,7 @@ fn initial_public_accounts() -> HashMap { ) .chain([( system_accounts::sequencer_stake_config_account_id(), - system_accounts::sequencer_stake_config_account(std::collections::BTreeMap::new()), + system_accounts::sequencer_stake_config_account(), )]) .collect() } diff --git a/test_fixtures/src/config.rs b/test_fixtures/src/config.rs index c2f58eed9..5d43d5691 100644 --- a/test_fixtures/src/config.rs +++ b/test_fixtures/src/config.rs @@ -99,13 +99,6 @@ impl Default for MultiNodeTestContextConfig { } } -#[must_use] -pub fn sequencer_stake_account_id() -> AccountId { - let private_key = - PrivateKey::try_new(SEQUENCER_STAKE_KEY).expect("Fixed sequencer stake key must be valid"); - AccountId::from(&PublicKey::new_from_private_key(&private_key)) -} - #[expect( clippy::too_many_arguments, reason = "All fields are necessary and better to keep separate" @@ -137,7 +130,7 @@ pub fn sequencer_config( retry_pending_blocks_timeout: Duration::from_secs(5), genesis: genesis_transactions, signing_key: signing_key.unwrap_or(SEQUENCER_SIGNING_KEY), - sequencer_stake_account_id: sequencer_stake_account_id(), + sequencer_stake_signing_key: SEQUENCER_STAKE_KEY, bedrock_config: BedrockConfig { channel_id, node_url: addr_to_url(UrlProtocol::Http, bedrock_addr)