fix(lez)!: first sequencer stake ownership account on genesis block

This commit is contained in:
Sergio Chouhy
2026-08-13 18:47:50 -03:00
parent 39d19151ba
commit 1fd81a7889
12 changed files with 205 additions and 128 deletions
@@ -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(
@@ -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
]
}
+1 -1
View File
@@ -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
+5 -3
View File
@@ -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.
+78 -40
View File
@@ -201,7 +201,6 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
config: &SequencerConfig,
store: &SequencerStore,
stored_head_state: &lee::V03State,
bootstrap_sequencer_key: Option<sequencer_stake_core::SequencerKey>,
) -> ChainState {
let final_snapshot = store
.dbio()
@@ -210,7 +209,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
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<BP: BlockPublisherTrait> SequencerCore<BP> {
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<sequencer_stake_core::SequencerKey>,
) -> 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<sequencer_stake_core::SequencerKey>,
) -> (lee::V03State, Vec<LeeTransaction>) {
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],
+12 -11
View File
@@ -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,
@@ -24,10 +24,7 @@ fn fresh_store_and_chain(config: &SequencerConfig) -> (SequencerStore, Mutex<Cha
let (store, state) =
SequencerCore::<MockBlockPublisher>::open_or_create_store(config, bootstrap_sequencer_key);
let chain = Mutex::new(SequencerCore::<MockBlockPublisher>::restore_chain_state(
config,
&store,
&state,
bootstrap_sequencer_key,
config, &store, &state,
));
(store, chain)
}
@@ -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
]
}
@@ -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
]
}
+4 -43
View File
@@ -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<sequencer_stake_core::SequencerKey, sequencer_stake_core::SequencerEntry>,
) -> 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 {
+1 -1
View File
@@ -214,7 +214,7 @@ fn initial_public_accounts() -> HashMap<AccountId, Account> {
)
.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()
}
+1 -8
View File
@@ -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)