fix(integration_tests): multi sequencer now builds the state with two accredited keys from channel creation

This commit is contained in:
Sergio Chouhy
2026-08-13 18:47:50 -03:00
parent 9c9e18b80d
commit b1eb69322c
8 changed files with 538 additions and 225 deletions
+141 -121
View File
@@ -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<GenesisAction> {
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<F, Fut>(what: &str, mut check: F) -> Result<()>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<bool>>,
{
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<Ed25519PublicKey>)> {
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.
+4 -1
View File
@@ -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],
}
+183 -52
View File
@@ -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<Result<()>>,
},
/// Hand zone-sdk a pre-built tx to track and post, keyed by the channel tip
/// it leaves behind.
SubmitSignedTx {
tx: Box<SignedMantleTx<Unverified>>,
msg_id: MsgId,
resp: oneshot::Sender<Result<PublishOutcome>>,
},
}
type CommandSender = mpsc::Sender<Command>;
@@ -141,6 +148,15 @@ pub trait BlockPublisherTrait: Sized + Sync {
withdrawals: Vec<WithdrawArg>,
) -> impl Future<Output = Result<PublishOutcome>> + 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<Ed25519PublicKey>,
) -> Result<PublishOutcome>;
/// 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<Output = Result<Vec<Ed25519PublicKey>>> + Send;
@@ -199,19 +215,31 @@ pub struct ZoneSdkPublisher {
// path wait until it has actually stopped.
drive_task: TaskGroup,
indexer: ZoneIndexer<NodeHttpClient>,
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<T>(
&self,
command: impl FnOnce(oneshot::Sender<Result<T>>) -> Command,
) -> Result<T> {
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<bool> {
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<Ed25519PublicKey>,
) -> Result<PublishOutcome> {
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<Vec<Ed25519PublicKey>> {
@@ -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<Item = Op>,
) -> Result<logos_blockchain_http_api_common::bodies::wallet::fund::WalletFundResponseBody> {
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<Option<ChannelState>> {
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.
+7 -1
View File
@@ -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
+188 -49
View File
@@ -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<BP: BlockPublisherTrait> SequencerCore<BP> {
"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<FoundingStake> {
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<Vec<block_publisher::Ed25519PublicKey>> {
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<PublicTransaction> {
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<Item = (lee::AccountId, lee::Balance)> + '_ {
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,
})
}
+8
View File
@@ -90,6 +90,14 @@ impl BlockPublisherTrait for MockBlockPublisher {
})
}
async fn publish_genesis_creating_channel(
&self,
block: &Block,
_keys: Vec<Ed25519PublicKey>,
) -> Result<PublishOutcome> {
self.publish_block(block, Vec::new()).await
}
async fn accredited_keys(&self) -> Result<Vec<Ed25519PublicKey>> {
Ok(Vec::new())
}
+1 -1
View File
@@ -229,6 +229,7 @@ fn initial_programs() -> Vec<Program> {
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<Program> {
programs::ping_receiver(),
programs::bridge_lock(),
programs::wrapped_token(),
programs::sequencer_stake(),
]
}
+6
View File
@@ -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 {