From b1eb69322c2f73d91105c7e6f665a2dbaf8dfbc5 Mon Sep 17 00:00:00 2001 From: Sergio Chouhy Date: Tue, 11 Aug 2026 22:04:59 -0300 Subject: [PATCH] fix(integration_tests): multi sequencer now builds the state with two accredited keys from channel creation --- integration_tests/tests/multi_sequencer.rs | 262 +++++++++++---------- lee/state_machine/src/signature/mod.rs | 5 +- lez/sequencer/core/src/block_publisher.rs | 235 ++++++++++++++---- lez/sequencer/core/src/config.rs | 8 +- lez/sequencer/core/src/lib.rs | 237 +++++++++++++++---- lez/sequencer/core/src/mock.rs | 8 + lez/testnet_initial_state/src/lib.rs | 2 +- test_fixtures/src/lib.rs | 6 + 8 files changed, 538 insertions(+), 225 deletions(-) diff --git a/integration_tests/tests/multi_sequencer.rs b/integration_tests/tests/multi_sequencer.rs index ee2cde05e..f171dd80e 100644 --- a/integration_tests/tests/multi_sequencer.rs +++ b/integration_tests/tests/multi_sequencer.rs @@ -3,9 +3,9 @@ reason = "top-level test functions are conventional for integration tests" )] -//! Two sequencers share one channel: A starts solo as channel admin, live- -//! accredits `[A, B]` with round-robin rotation, B joins and syncs, both -//! produce on their turns, and A, B and an indexer converge on the same chain. +//! Two sequencers share one channel: both are staked at genesis, so A creates +//! the channel already accrediting `[A, B]`, B joins and syncs, both produce on +//! their turns, and A, B and an indexer converge on the same chain. use std::time::Duration; @@ -13,91 +13,110 @@ use anyhow::{Context as _, Result, ensure}; use indexer_service_rpc::RpcClient as _; use integration_tests::{ config::{self, SequencerPartialConfig}, - indexer_client::IndexerClient, + init_logger, + setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, +}; +use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; +use sequencer_core::{ + block_publisher::{Ed25519PublicKey, read_channel_state}, + config::{BedrockConfig, GenesisAction}, + sign_genesis_stake, }; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; -use test_fixtures::{ - MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, -}; use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; use tokio::test; const PHASE_TIMEOUT: Duration = Duration::from_secs(360); const POLL_INTERVAL: Duration = Duration::from_secs(2); const TRANSFER_AMOUNT: u128 = 10; -/// ≈4 turn windows past B's join (5 s blocks, ~20 s turns → ~4 blocks/window). +/// ≈4 turn windows past B's join, at `DEFAULT_SEQUENCER_POSTING_TIMEFRAME` +/// (20 slots ≈ 20 s) and 5 s blocks. const ROTATION_BLOCKS: u64 = 8; #[test] async fn multi_sequencer_committee_converges() -> Result<()> { - let bedrock_channel_id = config::bedrock_channel_id(); + init_logger(); + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up Bedrock node")?; + + // Fixed seeds so both keys can be staked in genesis before either starts. + let key_a = [0xA1_u8; ED25519_SECRET_KEY_SIZE]; + let key_b = [0xB2_u8; ED25519_SECRET_KEY_SIZE]; + let pub_a = Ed25519Key::from_bytes(&key_a).public_key(); + let pub_b = Ed25519Key::from_bytes(&key_b).public_key(); + + // Each operator signs its own stake offchain. + let genesis = vec![ + founding_stake(0, pub_a.to_bytes(), [0x51_u8; 32])?, + founding_stake(1, pub_b.to_bytes(), [0x52_u8; 32])?, + ]; + + let bedrock_config = BedrockConfig { + channel_id: config::bedrock_channel_id(), + node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?, + funding_key: config::bedrock_funding_key(), + auth: None, + priority_fee: sequencer_core::config::default_priority_fee(), + }; + let partial = SequencerPartialConfig { block_create_timeout: Duration::from_secs(5), ..SequencerPartialConfig::default() }; - let ctx = MultiZoneTestContextBuilder::default() - .with_zone( - ZoneTestContextBuilder::new(MultiNodeTestContextConfig { - num_nodes: 2, - bedrock_channel: bedrock_channel_id, - }) - .disable_wallet() - .with_sequencer_partial_config(partial) - .with_genesis(vec![]), - ) - .build() - .await?; + // Phase 1: A solo (it creates the channel), plus an indexer. + let (seq_a, _a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_genesis(genesis.clone()) + .with_bedrock_signing_key(key_a) + .setup() + .await + .context("Failed to set up sequencer A")?; + let a = sequencer_client(seq_a.addr())?; + let (idx, _idx_home) = setup_indexer(bedrock_addr, config::bedrock_channel_id(), None) + .await + .context("Failed to set up indexer")?; + let indexer = indexer_client(idx.addr()).await?; - let mut seq_iterator = ctx.sequencer_components_iter(bedrock_channel_id).unwrap(); + wait_for_height(&a, 2, "sequencer A to produce past genesis").await?; - let seq_client_a = &(seq_iterator.next().unwrap().sequencer_client); - let seq_client_b = &(seq_iterator.next().unwrap().sequencer_client); - - let indexer = ctx.indexer_client(); - - wait_for_height(seq_client_a, 2, "sequencer A to produce past genesis").await?; - - log::info!("Passed wait for height A to be at least 2"); - - let height_at_config = seq_client_a.get_last_block_id().await?; - wait_for_height( - seq_client_a, - height_at_config + 1, - "A to produce after the roster change", - ) + // Phase 2: the creating tx carried the committee, so both keys are accredited + // from block 1 and discovery has nothing to reconcile. + let mut want = vec![pub_a.to_bytes(), pub_b.to_bytes()]; + want.sort_unstable(); + wait_until("Bedrock to accredit both staked keys", || async { + Ok(committee(&bedrock_config).await?.0 == want) + }) .await?; - log::info!( - "Passed wait for height A to be at least {}", - height_at_config + 1 - ); + // Phase 3: B joins live and syncs the existing chain. + let (seq_b, _b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_genesis(genesis) + .with_bedrock_signing_key(key_b) + .setup() + .await + .context("Failed to set up sequencer B")?; + let b = sequencer_client(seq_b.addr())?; - let join_height = seq_client_a.get_last_block_id().await?; - wait_for_height(seq_client_b, join_height, "B to sync to A's height at join").await?; + let join_height = a.get_last_block_id().await?; + wait_for_height(&b, join_height, "B to sync to A's height at join").await?; - log::info!("Passed wait for height B to be at least {join_height}"); - - // Phase 4: rotation + convergence over ≈4 turn windows. + // Phase 4: rotation + convergence over ≈4 turn windows. Without the turn + // check, a chain A produces alone satisfies every assertion below. let rotation_target = join_height + ROTATION_BLOCKS; wait_for_height( - seq_client_a, + &a, rotation_target, "the chain to advance across turn windows", ) .await?; - - log::info!("Passed wait for height A to be at least {rotation_target}"); - - wait_for_height( - seq_client_b, - rotation_target, - "B to follow across turn windows", - ) + wait_for_height(&b, rotation_target, "B to follow across turn windows").await?; + wait_until("the round-robin turn to reach B", || async { + Ok(committee(&bedrock_config).await?.1 == Some(pub_b)) + }) .await?; - assert_same_chain(seq_client_a, seq_client_b).await?; - - log::info!("Passed wait for height B to be at least {rotation_target}"); + assert_same_chain(&a, &b).await?; // Phase 5: a tx submitted only to B is included by B and visible on A. let accounts = initial_public_user_accounts(); @@ -105,8 +124,8 @@ async fn multi_sequencer_committee_converges() -> Result<()> { let to = accounts[1].account_id; let sign_key = initial_pub_accounts_private_keys()[0].pub_sign_key.clone(); - let to_balance_before = seq_client_a.get_account_balance(to).await?; - let nonce = seq_client_b.get_accounts_nonces(vec![from]).await?[0]; + let to_balance_before = a.get_account_balance(to).await?; + let nonce = b.get_accounts_nonces(vec![from]).await?[0]; let tx = common::test_utils::create_transaction_native_token_transfer( from, nonce.0, @@ -114,30 +133,28 @@ async fn multi_sequencer_committee_converges() -> Result<()> { TRANSFER_AMOUNT, &sign_key, ); - seq_client_b - .send_transaction(tx) + b.send_transaction(tx) .await .context("Failed to submit the transfer to B")?; - wait_for_balance(seq_client_a, to, to_balance_before + TRANSFER_AMOUNT).await?; - - log::info!( - "Passed wait for height balance {to} to be {}", - to_balance_before + TRANSFER_AMOUNT - ); + let expected = to_balance_before + TRANSFER_AMOUNT; + wait_until("the cross-sequencer transfer to reach A", || async { + Ok(a.get_account_balance(to).await? == expected) + }) + .await?; // Phase 6: the indexer finalizes the same chain, with no stall. - wait_for_finalized(indexer, join_height).await?; - - log::info!("Passed indexer to see finalized {join_height}"); - + wait_until("the indexer to finalize", || async { + Ok(indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= join_height) + }) + .await?; let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0); for id in 1..=finalized { let block_i = indexer .get_block_by_id(id) .await? .with_context(|| format!("Indexer is missing finalized block {id}"))?; - let block_a = seq_client_a + let block_a = a .get_block(id) .await? .with_context(|| format!("A is missing block {id}"))?; @@ -156,59 +173,62 @@ async fn multi_sequencer_committee_converges() -> Result<()> { Ok(()) } +/// Builds a founding-sequencer genesis entry, signing its stake the way an +/// operator would before handing the entry over. +fn founding_stake( + index: usize, + sequencer_key: sequencer_stake_core::SequencerKey, + owner_seed: [u8; 32], +) -> Result { + let owner = lee::PrivateKey::try_new(owner_seed)?; + Ok(GenesisAction::StakeSequencer { + sequencer_key, + ownership_public_key: lee::PublicKey::new_from_private_key(&owner), + stake_signature: sign_genesis_stake(index, sequencer_key, &owner), + }) +} + +/// Polls `check` until it reports ready, failing with `what` on timeout. +async fn wait_until(what: &str, mut check: F) -> Result<()> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let wait = async { + while !check().await? { + tokio::time::sleep(POLL_INTERVAL).await; + } + Ok::<(), anyhow::Error>(()) + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .with_context(|| format!("Timed out waiting for {what}"))? +} + /// Polls the sequencer until its chain height reaches `target`. async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> { - log::info!("Waiting for {what:?}, target is {target}"); - - let wait = async { - loop { - if client.get_last_block_id().await? >= target { - return Ok::<(), anyhow::Error>(()); - } - tokio::time::sleep(POLL_INTERVAL).await; - } - }; - tokio::time::timeout(PHASE_TIMEOUT, wait) - .await - .with_context(|| format!("Timed out waiting for {what} (target height {target})"))? + wait_until(&format!("{what} (target height {target})"), || async { + Ok(client.get_last_block_id().await? >= target) + }) + .await } -/// Polls the sequencer until `account`'s balance reaches `expected`. -async fn wait_for_balance( - client: &SequencerClient, - account: lee::AccountId, - expected: u128, -) -> Result<()> { - log::info!("Waiting for {account} to have {expected} tokens"); - - let wait = async { - loop { - if client.get_account_balance(account).await? == expected { - return Ok::<(), anyhow::Error>(()); - } - tokio::time::sleep(POLL_INTERVAL).await; - } +/// The channel's accredited keys, sorted, plus whose turn the tip was written on. +async fn committee(config: &BedrockConfig) -> Result<(Vec<[u8; 32]>, Option)> { + let Some(state) = read_channel_state(config).await? else { + return Ok((Vec::new(), None)); }; - tokio::time::timeout(PHASE_TIMEOUT, wait) - .await - .context("Timed out waiting for the cross-sequencer transfer to reach A")? -} - -/// Polls the indexer until its finalized height reaches `target`. -async fn wait_for_finalized(indexer: &IndexerClient, target: u64) -> Result<()> { - log::info!("Waiting for indexer to see target finalized, target is {target}"); - - let wait = async { - loop { - if indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= target { - return Ok::<(), anyhow::Error>(()); - } - tokio::time::sleep(POLL_INTERVAL).await; - } - }; - tokio::time::timeout(PHASE_TIMEOUT, wait) - .await - .context("Timed out waiting for the indexer to finalize")? + let turn = state + .accredited_keys + .get(usize::from(state.tip_sequencer)) + .copied(); + let mut keys: Vec<_> = state + .accredited_keys + .iter() + .map(Ed25519PublicKey::to_bytes) + .collect(); + keys.sort_unstable(); + Ok((keys, turn)) } /// Asserts A and B hold byte-identical block hashes over their common prefix. diff --git a/lee/state_machine/src/signature/mod.rs b/lee/state_machine/src/signature/mod.rs index ba049419c..873a274c5 100644 --- a/lee/state_machine/src/signature/mod.rs +++ b/lee/state_machine/src/signature/mod.rs @@ -5,11 +5,14 @@ use k256::ecdsa::signature::hazmat::PrehashVerifier as _; pub use private_key::PrivateKey; pub use public_key::PublicKey; use rand::{RngCore as _, rngs::OsRng}; +use serde_with::{DeserializeFromStr, SerializeDisplay}; mod private_key; mod public_key; -#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[derive( + Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, SerializeDisplay, DeserializeFromStr, +)] pub struct Signature { pub value: [u8; 64], } diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index b1e5ba272..3462103b0 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -11,24 +11,24 @@ pub use logos_blockchain_core::mantle::{ use logos_blockchain_core::{ mantle::{ SignedMantleTx, - channel::{SlotTimeframe, SlotTimeout}, + channel::{ChannelState, SlotTimeframe, SlotTimeout}, gas::GasCost, ops::{ Op, OpProof, channel::{ ChannelId, config::{ChannelConfigOp, Keys}, - inscribe::Inscription, + inscribe::{Inscription, InscriptionOp}, }, }, traits::Hashable as _, - transactions::{MantleTxBuilder, OpsProofs}, + transactions::{MantleTxBuilder, OpsProofs, states::Unverified}, }, proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature}, }; use logos_blockchain_http_api_common::bodies::wallet::fund::WalletFundRequestBody; pub use logos_blockchain_key_management_system_service::keys::{ - ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey, + ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey, ZkPublicKey, }; pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; use logos_blockchain_zone_sdk::{ @@ -109,6 +109,13 @@ enum Command { new_keys: Keys, resp: oneshot::Sender>, }, + /// Hand zone-sdk a pre-built tx to track and post, keyed by the channel tip + /// it leaves behind. + SubmitSignedTx { + tx: Box>, + msg_id: MsgId, + resp: oneshot::Sender>, + }, } type CommandSender = mpsc::Sender; @@ -141,6 +148,15 @@ pub trait BlockPublisherTrait: Sized + Sync { withdrawals: Vec, ) -> impl Future> + Send + 'blk; + /// Create the channel and write `block` into it in one Mantle tx. Only valid + /// while the channel does not exist, and `keys[0]` must be this sequencer's + /// own key, since creation hands the first turn to index 0. + async fn publish_genesis_creating_channel( + &self, + block: &Block, + keys: Vec, + ) -> Result; + /// Live (adopted, possibly not yet finalized) accredited-key snapshot for /// this channel, read directly from the connected Bedrock node. fn accredited_keys(&self) -> impl Future>> + Send; @@ -199,19 +215,31 @@ pub struct ZoneSdkPublisher { // path wait until it has actually stopped. drive_task: TaskGroup, indexer: ZoneIndexer, + bedrock_signing_key: Ed25519Key, + funding_key: ZkPublicKey, + priority_fee: u64, +} + +impl ZoneSdkPublisher { + /// Runs one [`Command`] on the drive task and waits for its reply. + async fn dispatch( + &self, + command: impl FnOnce(oneshot::Sender>) -> Command, + ) -> Result { + let (resp_tx, resp_rx) = oneshot::channel(); + self.command_tx + .send(command(resp_tx)) + .await + .map_err(|_closed| anyhow!("Drive task is no longer running"))?; + resp_rx + .await + .map_err(|_closed| anyhow!("Drive task dropped the response"))? + } } impl BlockPublisherTrait for ZoneSdkPublisher { async fn channel_exists(config: &BedrockConfig) -> Result { - let node = NodeHttpClient::new( - CommonHttpClient::new(config.auth.clone().map(Into::into)), - config.node_url.clone(), - ); - Ok(node - .channel_state(config.channel_id) - .await - .context("Failed to read channel state")? - .is_some()) + Ok(read_channel_state(config).await?.is_some()) } async fn new( @@ -235,7 +263,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { let mut sequencer = ZoneSequencer::init_with_config( config.channel_id, - bedrock_signing_key, + bedrock_signing_key.clone(), node.clone(), zone_sdk_config, initial_checkpoint, @@ -334,6 +362,21 @@ impl BlockPublisherTrait for ZoneSdkPublisher { let _dontcare = resp_tx.send(result); } + Command::SubmitSignedTx { tx, msg_id, resp: resp_tx } => { + let submitted = sequencer + .handle() + .submit_signed_tx(*tx, msg_id) + .context("Failed to submit pre-built channel transaction"); + let msg_result = submitted.map(|(result, checkpoint)| PublishOutcome { + this_msg: result.tx.inscription().this_msg, + checkpoint, + released_notes: released_notes(&result.tx), + }); + if let Err(e) = &msg_result { + warn!("zone-sdk rejected the pre-built transaction: {e:?}"); + } + let _dontcare = resp_tx.send(msg_result); + } }, event = sequencer.next_event() => { match event { @@ -419,6 +462,9 @@ impl BlockPublisherTrait for ZoneSdkPublisher { turn_rx, driver_cancellation, drive_task: TaskGroup::new(vec![drive_task]), + bedrock_signing_key, + funding_key: config.funding_key, + priority_fee: config.priority_fee, }) } @@ -432,19 +478,88 @@ impl BlockPublisherTrait for ZoneSdkPublisher { .try_into() .context("Block data exceeds maximum allowed size")?; - let (resp_tx, resp_rx) = oneshot::channel(); - self.command_tx - .send(Command::Publish { - inscription: data_bounded, - withdrawals, - resp: resp_tx, - }) - .await - .map_err(|_closed| anyhow!("Drive task is no longer running"))?; + self.dispatch(|resp| Command::Publish { + inscription: data_bounded, + withdrawals, + resp, + }) + .await + } - resp_rx + async fn publish_genesis_creating_channel( + &self, + block: &Block, + keys: Vec, + ) -> Result { + let own_key = self.bedrock_signing_key.public_key(); + ensure!( + keys.first() == Some(&own_key), + "Creating the channel requires our own key first; creation gives the turn to index 0" + ); + let key_count = keys.len(); + let keys = + Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?; + + let config_op = ChannelConfigOp { + channel: self.channel_id, + keys, + posting_timeframe: SlotTimeframe::from( + system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEFRAME, + ), + posting_timeout: SlotTimeout::from(system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEOUT), + configuration_threshold: system_accounts::DEFAULT_SEQUENCER_CONFIGURATION_THRESHOLD, + transfer_threshold: system_accounts::DEFAULT_SEQUENCER_WITHDRAW_THRESHOLD, + }; + + let data = borsh::to_vec(block).context("Failed to serialize genesis block")?; + let inscription: Inscription = data + .try_into() + .context("Genesis block exceeds maximum allowed size")?; + // The config op runs first and becomes the tip, so it is the parent. + let inscribe_op = InscriptionOp { + channel_id: self.channel_id, + inscription, + parent: config_op.id(), + signer: own_key, + }; + let msg_id = inscribe_op.id(); + + let funded = fund_ops( + &self.node, + self.funding_key, + self.priority_fee, + [ + Op::ChannelConfig(config_op), + Op::ChannelInscribe(inscribe_op), + ], + ) + .await?; + let mantle_tx = funded.funded_tx; + + let signature = self + .bedrock_signing_key + .sign_payload(mantle_tx.hash().as_signing_bytes().as_ref()); + // Creation skips the channel-config signature check, but the proof must + // still be well formed; index 0 is our own key. + let config_proof = + ChannelMultiSigProof::try_new(IndexedSignature::new(0, signature).into()) + .map_err(|err| anyhow!("Failed to assemble channel multi-sig proof: {err:?}"))?; + + let mut ops_proofs: OpsProofs = OpProof::ChannelMultiSigProof(config_proof).into(); + ops_proofs + .try_push(OpProof::Ed25519Sig(signature)) + .map_err(|err| anyhow!("Too many operation proofs: {err:?}"))?; + if let Some(transfer_proof) = funded.transfer_proof { + ops_proofs + .try_push(transfer_proof) + .map_err(|err| anyhow!("Too many operation proofs: {err:?}"))?; + } + + info!("Creating the channel with {key_count} accredited key(s), genesis block bundled"); + + let tx = Box::new(SignedMantleTx::new(mantle_tx, ops_proofs)); + self.dispatch(|resp| Command::SubmitSignedTx { tx, msg_id, resp }) .await - .map_err(|_closed| anyhow!("Drive task dropped the publish response"))? } async fn accredited_keys(&self) -> Result> { @@ -465,18 +580,8 @@ impl BlockPublisherTrait for ZoneSdkPublisher { let new_keys = Keys::try_from(new_keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?; - let (resp_tx, resp_rx) = oneshot::channel(); - self.command_tx - .send(Command::SubmitChannelConfig { - new_keys, - resp: resp_tx, - }) + self.dispatch(|resp| Command::SubmitChannelConfig { new_keys, resp }) .await - .map_err(|_closed| anyhow!("Drive task is no longer running"))?; - - resp_rx - .await - .map_err(|_closed| anyhow!("Drive task dropped the submit response"))? } fn channel_id(&self) -> ChannelId { @@ -550,6 +655,41 @@ const fn channel_update_inscription(orphan: &ChannelUpdateTx) -> Option<&Inscrip } } +/// Funds `ops` from the node's wallet, which appends a fee transfer (paid from +/// `funding_key`, change back to it) and returns its proof. +async fn fund_ops( + node: &NodeHttpClient, + funding_key: ZkPublicKey, + priority_fee: u64, + ops: impl IntoIterator, +) -> Result { + let tx_builder = MantleTxBuilder::new() + .extend_ops(ops) + .map_err(|err| anyhow!("Too many ops in channel transaction: {err:?}"))?; + node.fund_tx(WalletFundRequestBody { + tip: None, + tx_builder, + change_public_key: funding_key, + funding_public_keys: vec![funding_key], + max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX), + priority_fee, + }) + .await + .context("Failed to fund channel transaction") +} + +/// Reads the channel's committee state from the bedrock node, without a running +/// sequencer. `None` means the channel does not exist yet. +pub async fn read_channel_state(config: &BedrockConfig) -> Result> { + let node = NodeHttpClient::new( + CommonHttpClient::new(config.auth.clone().map(Into::into)), + config.node_url.clone(), + ); + node.channel_state(config.channel_id) + .await + .context("Failed to read channel state") +} + /// Signs a `ChannelConfig` op (accredited keys + rotation params) with /// `signing_key`, funds it from `config.funding_key` via the node's wallet, /// and posts it straight to the bedrock node. @@ -599,22 +739,13 @@ pub async fn post_channel_config( config.node_url.clone(), ); - // Fund the op from the node's wallet: the node appends a fee transfer - // (paid from `funding_key`, change back to it) and returns its proof. - let tx_builder = MantleTxBuilder::new() - .extend_ops([Op::ChannelConfig(config_op)]) - .map_err(|err| anyhow!("Too many ops in channel config transaction: {err:?}"))?; - let funded = node - .fund_tx(WalletFundRequestBody { - tip: None, - tx_builder, - change_public_key: config.funding_key, - funding_public_keys: vec![config.funding_key], - max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX), - priority_fee: config.priority_fee, - }) - .await - .context("Failed to fund channel config transaction")?; + let funded = fund_ops( + &node, + config.funding_key, + config.priority_fee, + [Op::ChannelConfig(config_op)], + ) + .await?; let mantle_tx = funded.funded_tx; // Sign the funded tx: the appended fee transfer changes the hash. diff --git a/lez/sequencer/core/src/config.rs b/lez/sequencer/core/src/config.rs index 564e6b718..59002c673 100644 --- a/lez/sequencer/core/src/config.rs +++ b/lez/sequencer/core/src/config.rs @@ -11,7 +11,7 @@ use bytesize::ByteSize; use common::config::BasicAuth; pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use humantime_serde; -use lee::{AccountId, Balance}; +use lee::{AccountId, Balance, PublicKey, Signature}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_key_management_system_service::keys::ZkPublicKey; use serde::{Deserialize, Serialize}; @@ -33,6 +33,12 @@ pub enum GenesisAction { holder: AccountId, amount: Balance, }, + /// Stakes `sequencer_key` at genesis. + StakeSequencer { + sequencer_key: sequencer_stake_core::SequencerKey, + ownership_public_key: PublicKey, + stake_signature: Signature, + }, } /// Sequencer p2p gossip configuration. Absent (`None`) disables gossip diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 6792c1f5a..5bd413289 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -87,6 +87,13 @@ const MAX_DISPATCHES_PER_BLOCK: usize = 16; // once that path exists, instead of a fixed genesis-only key. const GENESIS_STAKE_FUNDING_KEY: [u8; 32] = [9; 32]; +/// A founding sequencer's key, plus the ownership account attesting to its stake. +type FoundingStake = ( + sequencer_stake_core::SequencerKey, + lee::PublicKey, + lee::Signature, +); + /// The origin of a transaction. #[derive(Clone, Copy)] pub enum TransactionOrigin { @@ -368,17 +375,27 @@ impl SequencerCore { "First pending block on fresh start should be the genesis block" ); + // The channel is born holding only its creator's key, so a configured + // founding set is applied by the same tx that writes genesis; the + // committee is never observable without it. + let founding_committee = founding_committee(&config, own_sequencer_key); + let mut last_checkpoint = None; for block in &pending_blocks { - let outcome = block_publisher - .publish_block(block, vec![]) - .await - .unwrap_or_else(|err| { - panic!( - "Failed to publish block {} on fresh start: {err:#}", - block.header.block_id - ) - }); + let publish = match &founding_committee { + Some(keys) if block.header.block_id == GENESIS_BLOCK_ID => { + block_publisher + .publish_genesis_creating_channel(block, keys.clone()) + .await + } + _ => block_publisher.publish_block(block, vec![]).await, + }; + let outcome = publish.unwrap_or_else(|err| { + panic!( + "Failed to publish block {} on fresh start: {err:#}", + block.header.block_id + ) + }); last_checkpoint = Some(outcome.checkpoint); store .raise_published_high_water(block.header.block_id) @@ -1720,13 +1737,24 @@ fn build_genesis_state( Some(build_supply_bridge_account_genesis_transaction(*balance)) } // Seeded directly in `build_initial_state` (holdings via `build_holding_account`), not a - // 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) + // genesis tx. Stakes are built separately below. + GenesisAction::SupplyBridgeLockHolding { .. } | GenesisAction::StakeSequencer { .. } => { + None + } }); + // The creator falls back to staking itself, signing with the key it owns. + let mut staked = founding_stakes(config); + if staked.is_empty() { + staked.extend(bootstrap_sequencer_key.map(|key| { + let owner = lee::PrivateKey::try_new(config.sequencer_stake_signing_key) + .expect("sequencer stake signing key is a valid private key"); + let signature = sign_genesis_stake(0, key, &owner); + (key, lee::PublicKey::new_from_private_key(&owner), signature) + })); + } + let bootstrap_stake_txs = build_stake_genesis_transactions(&staked); + let genesis_txs = wrapped_token_config_tx .chain(ping_sender_config_tx) .chain(ping_receiver_config_tx) @@ -1746,45 +1774,91 @@ fn build_genesis_state( (state, genesis_txs) } -/// 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; +fn founding_stakes(config: &SequencerConfig) -> Vec { + config + .genesis + .iter() + .filter_map(|action| match action { + GenesisAction::StakeSequencer { + sequencer_key, + ownership_public_key, + stake_signature, + } => Some(( + *sequencer_key, + ownership_public_key.clone(), + stake_signature.clone(), + )), + GenesisAction::SupplyAccount { .. } + | GenesisAction::SupplyBridgeAccount { .. } + | GenesisAction::SupplyBridgeLockHolding { .. } => None, + }) + .collect() +} - 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 }, +/// The accredited keys a newly created channel should carry, `own_key` first +/// because creation gives the turn to index 0. `None` leaves creation to the +/// plain inscription path. +fn founding_committee( + config: &SequencerConfig, + own_key: sequencer_stake_core::SequencerKey, +) -> Option> { + let mut keys: Vec<_> = founding_stakes(config) + .into_iter() + .map(|(key, ..)| key) + .collect(); + if keys.is_empty() { + return None; + } + keys.sort_unstable(); + keys.retain(|key| *key != own_key); + + Some( + std::iter::once(own_key) + .chain(keys) + .map(|key| { + block_publisher::Ed25519PublicKey::from_bytes(&key) + .expect("sequencer key was decoded from a valid Ed25519 public key") + }) + .collect(), ) - .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); +} +fn genesis_stake_funding_account() -> AccountId { + let key = lee::PrivateKey::try_new(GENESIS_STAKE_FUNDING_KEY) + .expect("GENESIS_STAKE_FUNDING_KEY is a valid private key"); + AccountId::from(&lee::PublicKey::new_from_private_key(&key)) +} + +/// The exact `Stake` message the founding sequencer at `index` must sign. Shared +/// offchain by the genesis sequencer. +fn genesis_stake_message( + index: usize, + sequencer_key: sequencer_stake_core::SequencerKey, + ownership_id: AccountId, +) -> Message { + let amount = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; 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( + // A nonce counts how many times an account has signed. The funding account + // signed the faucet tx already, so its count starts at 1 here. + let funding_nonce = u128::try_from(index) + .expect("founding sequencer count fits in u128") + .checked_add(1) + .expect("genesis funding nonce overflow"); + + Message::try_new( programs::sequencer_stake().id(), vec![ - funding_id, + genesis_stake_funding_account(), 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)], + vec![ + lee_core::account::Nonce(funding_nonce), + lee_core::account::Nonce(0), + ], sequencer_stake_core::Instruction::Stake { sequencer_key, amount, @@ -1792,14 +1866,77 @@ fn build_bootstrap_stake_genesis_transactions( 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); + .expect("Failed to build genesis Stake message") +} - [fund_tx, stake_tx] +/// Signs the founding sequencer at `index`'s genesis `Stake`, for an operator +/// producing their `GenesisAction::StakeSequencer` entry. +#[must_use] +pub fn sign_genesis_stake( + index: usize, + sequencer_key: sequencer_stake_core::SequencerKey, + ownership_key: &lee::PrivateKey, +) -> lee::Signature { + let ownership_id = AccountId::from(&lee::PublicKey::new_from_private_key(ownership_key)); + let message = genesis_stake_message(index, sequencer_key, ownership_id); + lee::Signature::new(ownership_key, &message.hash()) +} + +/// The founding sequencers' `Stake`s, funded via the faucet. Real transactions, +/// not raw state, so followers replay them instead of missing them. +fn build_stake_genesis_transactions(staked: &[FoundingStake]) -> Vec { + if staked.is_empty() { + return Vec::new(); + } + + let funding_key = lee::PrivateKey::try_new(GENESIS_STAKE_FUNDING_KEY).unwrap(); + let funding_public_key = lee::PublicKey::new_from_private_key(&funding_key); + let amount = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; + let total = u128::try_from(staked.len()) + .ok() + .and_then(|count| amount.checked_mul(count)) + .expect("genesis stake total overflow"); + + let fund_message = Message::try_new( + programs::faucet().id(), + vec![ + system_accounts::faucet_account_id(), + genesis_stake_funding_account(), + ], + vec![lee_core::account::Nonce(0)], + faucet_core::Instruction::GenesisTransferDirect { amount: total }, + ) + .expect("Failed to build genesis funding message"); + // The funding account signs even though it is only receiving. It is a brand + // new account, so the transfer claims it, and a claim needs that account's + // own signature. + let fund_witness_set = + lee::public_transaction::WitnessSet::for_message(&fund_message, &[&funding_key]); + + let mut txs = vec![PublicTransaction::new(fund_message, fund_witness_set)]; + + for (index, (sequencer_key, ownership_public_key, signature)) in staked.iter().enumerate() { + let ownership_id = AccountId::from(ownership_public_key); + let stake_message = genesis_stake_message(index, *sequencer_key, ownership_id); + let stake_witness_set = lee::public_transaction::WitnessSet::from_raw_parts(vec![ + ( + lee::Signature::new(&funding_key, &stake_message.hash()), + funding_public_key.clone(), + ), + (signature.clone(), ownership_public_key.clone()), + ]); + + // Redundant with the signature check every tx gets, but names the entry. + assert!( + stake_witness_set.is_valid_for(&stake_message), + "genesis stake signature does not match founding sequencer {index} ({})", + hex::encode(sequencer_key) + ); + + txs.push(PublicTransaction::new(stake_message, stake_witness_set)); + } + + txs } /// Bridge-lock holder balances configured for this zone's genesis. @@ -1808,7 +1945,9 @@ fn bridge_lock_holdings( ) -> impl Iterator + '_ { genesis.iter().filter_map(|action| match action { GenesisAction::SupplyBridgeLockHolding { holder, amount } => Some((*holder, *amount)), - GenesisAction::SupplyAccount { .. } | GenesisAction::SupplyBridgeAccount { .. } => None, + GenesisAction::SupplyAccount { .. } + | GenesisAction::SupplyBridgeAccount { .. } + | GenesisAction::StakeSequencer { .. } => None, }) } diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index 0af664a61..8a70bd7b1 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -90,6 +90,14 @@ impl BlockPublisherTrait for MockBlockPublisher { }) } + async fn publish_genesis_creating_channel( + &self, + block: &Block, + _keys: Vec, + ) -> Result { + self.publish_block(block, Vec::new()).await + } + async fn accredited_keys(&self) -> Result> { Ok(Vec::new()) } diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index 3c63ae182..1c9871de7 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -229,6 +229,7 @@ fn initial_programs() -> Vec { programs::vault(), programs::faucet(), programs::bridge(), + programs::sequencer_stake(), // Cross-zone programs are builtins: their bytecode is baked into every node, // so registering them in the base state (rather than shipping ELFs through // the genesis block, which exceeds the inscription size limit) keeps the two @@ -239,7 +240,6 @@ fn initial_programs() -> Vec { programs::ping_receiver(), programs::bridge_lock(), programs::wrapped_token(), - programs::sequencer_stake(), ] } diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index 7f9f04f59..bc67fbc80 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -1043,6 +1043,12 @@ pub async fn verify_commitment_is_in_state( .is_some() } +/// Initializes the global logger once, for tests that build their fixtures +/// without going through [`TestContextBuilder`]. +pub fn init_logger() { + *LOGGER; +} + fn dir_size_bytes(path: &Path) -> u64 { let mut total = 0_u64; let Ok(entries) = std::fs::read_dir(path) else {