mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-07-15 02:10:19 +00:00
Merge pull request #616 from logos-blockchain/erhant/multi-sequencer-demo
feat(sequencer): multi-sequencer committee support with adminConfigureChannel + convergence demo
This commit is contained in:
commit
e5c0e2c91d
8
Cargo.lock
generated
8
Cargo.lock
generated
@ -4148,12 +4148,14 @@ dependencies = [
|
||||
"reqwest",
|
||||
"risc0-zkvm",
|
||||
"sequencer_core",
|
||||
"sequencer_service_protocol",
|
||||
"sequencer_service_rpc",
|
||||
"serde_json",
|
||||
"system_accounts",
|
||||
"tempfile",
|
||||
"test_fixtures",
|
||||
"test_programs",
|
||||
"testnet_initial_state",
|
||||
"token_core",
|
||||
"tokio",
|
||||
"vault_core",
|
||||
@ -9085,6 +9087,7 @@ dependencies = [
|
||||
"common",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"hex",
|
||||
"jsonrpsee",
|
||||
"lee",
|
||||
"log",
|
||||
@ -9105,6 +9108,7 @@ dependencies = [
|
||||
"hex",
|
||||
"lee",
|
||||
"lee_core",
|
||||
"serde",
|
||||
"serde_with",
|
||||
]
|
||||
|
||||
@ -9536,9 +9540,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
@ -29,6 +29,7 @@ bridge_lock_core.workspace = true
|
||||
wrapped_token_core.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
indexer_service_rpc = { workspace = true, features = ["client"] }
|
||||
sequencer_service_protocol.workspace = true
|
||||
sequencer_service_rpc = { workspace = true, features = ["client"] }
|
||||
wallet-ffi.workspace = true
|
||||
indexer_ffi.workspace = true
|
||||
@ -36,6 +37,7 @@ indexer_service_protocol.workspace = true
|
||||
system_accounts.workspace = true
|
||||
programs.workspace = true
|
||||
test_programs.workspace = true
|
||||
testnet_initial_state.workspace = true
|
||||
|
||||
logos-blockchain-http-api-common.workspace = true
|
||||
logos-blockchain-core.workspace = true
|
||||
|
||||
251
integration_tests/tests/multi_sequencer.rs
Normal file
251
integration_tests/tests/multi_sequencer.rs
Normal file
@ -0,0 +1,251 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
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.
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result, ensure};
|
||||
use indexer_service_rpc::RpcClient as _;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{setup_bedrock_node, setup_indexer, setup_sequencer_with_bedrock_key},
|
||||
};
|
||||
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
|
||||
use sequencer_service_protocol::ConfigureChannelRequest;
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts};
|
||||
use tokio::test;
|
||||
|
||||
/// 1 s bedrock slots: rotate the turn every ~20 s of tenure; steal a stalled
|
||||
/// turn after ~30 s (bounds the stall while B is accredited but not started).
|
||||
const POSTING_TIMEFRAME_SLOTS: u32 = 20;
|
||||
const POSTING_TIMEOUT_SLOTS: u32 = 30;
|
||||
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).
|
||||
const ROTATION_BLOCKS: u64 = 8;
|
||||
|
||||
#[test]
|
||||
async fn multi_sequencer_committee_converges() -> Result<()> {
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to set up Bedrock node")?;
|
||||
|
||||
// Fixed seeds so A can accredit B's public key before B exists.
|
||||
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();
|
||||
|
||||
let partial = SequencerPartialConfig {
|
||||
block_create_timeout: Duration::from_secs(5),
|
||||
..SequencerPartialConfig::default()
|
||||
};
|
||||
|
||||
// Phase 1: A solo (its first inscription creates the channel), plus an indexer.
|
||||
let (seq_a, _a_home) = setup_sequencer_with_bedrock_key(
|
||||
partial,
|
||||
bedrock_addr,
|
||||
vec![],
|
||||
config::bedrock_channel_id(),
|
||||
None,
|
||||
key_a,
|
||||
)
|
||||
.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?;
|
||||
|
||||
wait_for_height(&a, 2, "sequencer A to produce past genesis").await?;
|
||||
|
||||
// Phase 2: live roster change to [A, B] with rotation enabled.
|
||||
a.admin_configure_channel(ConfigureChannelRequest {
|
||||
keys: vec![hex::encode(pub_a.to_bytes()), hex::encode(pub_b.to_bytes())],
|
||||
posting_timeframe: POSTING_TIMEFRAME_SLOTS,
|
||||
posting_timeout: POSTING_TIMEOUT_SLOTS,
|
||||
configuration_threshold: 1,
|
||||
withdraw_threshold: 1,
|
||||
})
|
||||
.await
|
||||
.context("Failed to configure the channel committee")?;
|
||||
|
||||
let height_at_config = a.get_last_block_id().await?;
|
||||
wait_for_height(
|
||||
&a,
|
||||
height_at_config + 1,
|
||||
"A to produce after the roster change",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase 3: B joins live and syncs the existing chain.
|
||||
let (seq_b, _b_home) = setup_sequencer_with_bedrock_key(
|
||||
partial,
|
||||
bedrock_addr,
|
||||
vec![],
|
||||
config::bedrock_channel_id(),
|
||||
None,
|
||||
key_b,
|
||||
)
|
||||
.await
|
||||
.context("Failed to set up sequencer B")?;
|
||||
let b = sequencer_client(seq_b.addr())?;
|
||||
|
||||
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?;
|
||||
|
||||
// Phase 4: rotation + convergence over ≈4 turn windows.
|
||||
let rotation_target = join_height + ROTATION_BLOCKS;
|
||||
wait_for_height(
|
||||
&a,
|
||||
rotation_target,
|
||||
"the chain to advance across turn windows",
|
||||
)
|
||||
.await?;
|
||||
wait_for_height(&b, rotation_target, "B to follow across turn windows").await?;
|
||||
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();
|
||||
let from = accounts[0].account_id;
|
||||
let to = accounts[1].account_id;
|
||||
let sign_key = initial_pub_accounts_private_keys()[0].pub_sign_key.clone();
|
||||
|
||||
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,
|
||||
to,
|
||||
TRANSFER_AMOUNT,
|
||||
&sign_key,
|
||||
);
|
||||
b.send_transaction(tx)
|
||||
.await
|
||||
.context("Failed to submit the transfer to B")?;
|
||||
|
||||
wait_for_balance(&a, to, to_balance_before + TRANSFER_AMOUNT).await?;
|
||||
|
||||
// Phase 6: the indexer finalizes the same chain, with no stall.
|
||||
wait_for_finalized(&indexer, 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 = a
|
||||
.get_block(id)
|
||||
.await?
|
||||
.with_context(|| format!("A is missing block {id}"))?;
|
||||
ensure!(
|
||||
block_i.header.hash == indexer_service_protocol::HashType::from(block_a.header.hash),
|
||||
"Indexer diverges from A at block {id}"
|
||||
);
|
||||
}
|
||||
let status = indexer.get_status().await?;
|
||||
ensure!(
|
||||
status.stall_reason.is_none(),
|
||||
"Indexer is stalled: {:?}",
|
||||
status.stall_reason
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
async fn indexer_client(addr: SocketAddr) -> Result<IndexerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Ws, addr)
|
||||
.context("Failed to build indexer URL")?;
|
||||
IndexerClient::new(&url).await
|
||||
}
|
||||
|
||||
/// Polls the sequencer until its chain height reaches `target`.
|
||||
async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> {
|
||||
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})"))?
|
||||
}
|
||||
|
||||
/// Polls the sequencer until `account`'s balance reaches `expected`.
|
||||
async fn wait_for_balance(
|
||||
client: &SequencerClient,
|
||||
account: lee::AccountId,
|
||||
expected: u128,
|
||||
) -> Result<()> {
|
||||
let wait = async {
|
||||
loop {
|
||||
if client.get_account_balance(account).await? == expected {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
};
|
||||
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<()> {
|
||||
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")?
|
||||
}
|
||||
|
||||
/// Asserts A and B hold byte-identical block hashes over their common prefix.
|
||||
async fn assert_same_chain(a: &SequencerClient, b: &SequencerClient) -> Result<()> {
|
||||
let common = a
|
||||
.get_last_block_id()
|
||||
.await?
|
||||
.min(b.get_last_block_id().await?);
|
||||
for id in 1..=common {
|
||||
let block_a = a
|
||||
.get_block(id)
|
||||
.await?
|
||||
.with_context(|| format!("A is missing block {id}"))?;
|
||||
let block_b = b
|
||||
.get_block(id)
|
||||
.await?
|
||||
.with_context(|| format!("B is missing block {id}"))?;
|
||||
ensure!(
|
||||
block_a.header.hash == block_b.header.hash,
|
||||
"Chain divergence at block {id}: A {:?} vs B {:?}",
|
||||
block_a.header.hash,
|
||||
block_b.header.hash
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -3,9 +3,14 @@ use std::{pin::Pin, sync::Arc, time::Duration};
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use common::block::Block;
|
||||
use log::{info, warn};
|
||||
pub use logos_blockchain_core::mantle::ops::channel::MsgId;
|
||||
use logos_blockchain_core::mantle::ops::channel::{ChannelId, inscribe::Inscription};
|
||||
pub use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkKey};
|
||||
pub use logos_blockchain_core::mantle::ops::channel::{Ed25519PublicKey, MsgId};
|
||||
use logos_blockchain_core::mantle::{
|
||||
channel::{SlotTimeframe, SlotTimeout},
|
||||
ops::channel::{ChannelId, config::Keys, inscribe::Inscription},
|
||||
};
|
||||
pub use logos_blockchain_key_management_system_service::keys::{
|
||||
ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey,
|
||||
};
|
||||
pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
|
||||
use logos_blockchain_zone_sdk::{
|
||||
CommonHttpClient,
|
||||
@ -58,13 +63,26 @@ pub struct FollowUpdate {
|
||||
pub type OnFollowSink =
|
||||
Box<dyn Fn(FollowUpdate) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + 'static>;
|
||||
|
||||
/// Publish request channel: the inscription, its bridge withdrawals, and a
|
||||
/// oneshot for the `MsgId` zone-sdk assigns.
|
||||
type PublishSender = mpsc::Sender<(
|
||||
Inscription,
|
||||
Vec<WithdrawArg>,
|
||||
oneshot::Sender<Result<MsgId>>,
|
||||
)>;
|
||||
/// Commands the drive task executes with `&mut sequencer`.
|
||||
enum Command {
|
||||
/// Publish an inscription (+ atomic withdrawals); responds with the assigned `MsgId`.
|
||||
Publish {
|
||||
inscription: Inscription,
|
||||
withdrawals: Vec<WithdrawArg>,
|
||||
resp: oneshot::Sender<Result<MsgId>>,
|
||||
},
|
||||
/// Post a `ChannelConfig` op replacing the accredited keys / rotation params.
|
||||
ConfigureChannel {
|
||||
keys: Keys,
|
||||
posting_timeframe: SlotTimeframe,
|
||||
posting_timeout: SlotTimeout,
|
||||
configuration_threshold: u16,
|
||||
withdraw_threshold: u16,
|
||||
resp: oneshot::Sender<Result<()>>,
|
||||
},
|
||||
}
|
||||
|
||||
type CommandSender = mpsc::Sender<Command>;
|
||||
|
||||
#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")]
|
||||
pub trait BlockPublisherTrait: Clone {
|
||||
@ -88,6 +106,25 @@ pub trait BlockPublisherTrait: Clone {
|
||||
/// Zone-sdk drives the actual submission and retries internally.
|
||||
async fn publish_block(&self, block: &Block, withdrawals: Vec<WithdrawArg>) -> Result<MsgId>;
|
||||
|
||||
/// Update the channel's accredited key set and rotation parameters via a
|
||||
/// `ChannelConfig` op. The sequencer's bedrock key must be the channel
|
||||
/// admin (`keys[0]`); the L1 rejects non-admin signers, so this is not
|
||||
/// re-validated here.
|
||||
///
|
||||
/// `Ok(())` only means the signed op was queued locally, not that the
|
||||
/// L1 accepted it — acceptance is asynchronous.
|
||||
///
|
||||
/// Desugared (not `async fn`) so the returned future is provably `Send` —
|
||||
/// generic callers awaiting it inside jsonrpsee handlers require that.
|
||||
fn configure_channel(
|
||||
&self,
|
||||
keys: Vec<Ed25519PublicKey>,
|
||||
posting_timeframe: u32,
|
||||
posting_timeout: u32,
|
||||
configuration_threshold: u16,
|
||||
withdraw_threshold: u16,
|
||||
) -> impl Future<Output = Result<()>> + Send;
|
||||
|
||||
fn channel_id(&self) -> ChannelId;
|
||||
|
||||
/// Whether this sequencer is currently authorized to write to the channel.
|
||||
@ -98,7 +135,7 @@ pub trait BlockPublisherTrait: Clone {
|
||||
#[derive(Clone)]
|
||||
pub struct ZoneSdkPublisher {
|
||||
channel_id: ChannelId,
|
||||
publish_tx: PublishSender,
|
||||
command_tx: CommandSender,
|
||||
turn_rx: watch::Receiver<TurnNotification>,
|
||||
// Aborts the drive task when the last clone is dropped.
|
||||
_drive_task: Arc<DriveTaskGuard>,
|
||||
@ -146,7 +183,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
// Grab the turn watch before the move; the sdk actor keeps it current.
|
||||
let turn_rx = sequencer.subscribe_turn_to_write();
|
||||
|
||||
let (publish_tx, mut publish_rx): (PublishSender, _) =
|
||||
let (command_tx, mut command_rx): (CommandSender, _) =
|
||||
mpsc::channel(PUBLISH_INBOX_CAPACITY);
|
||||
|
||||
let drive_task = tokio::spawn(async move {
|
||||
@ -157,37 +194,62 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
)]
|
||||
{
|
||||
tokio::select! {
|
||||
// Drain external publish requests by calling the
|
||||
// borrowing handle — `&mut sequencer` is only
|
||||
// available here.
|
||||
Some((data_bounded, withdrawals, resp_tx)) = publish_rx.recv() => {
|
||||
let data_byte_size = data_bounded.len();
|
||||
let withdraw_count = withdrawals.len();
|
||||
let published = if withdrawals.is_empty() {
|
||||
sequencer.handle()
|
||||
.publish(data_bounded)
|
||||
.context("Failed to publish block")
|
||||
} else {
|
||||
sequencer.handle()
|
||||
.publish_atomic_withdraw(data_bounded, withdrawals)
|
||||
.context("Failed to publish block with withdrawals")
|
||||
};
|
||||
// Drain external commands by calling the borrowing
|
||||
// handle — `&mut sequencer` is only available here.
|
||||
Some(command) = command_rx.recv() => match command {
|
||||
Command::Publish { inscription: data_bounded, withdrawals, resp: resp_tx } => {
|
||||
let data_byte_size = data_bounded.len();
|
||||
let withdraw_count = withdrawals.len();
|
||||
let published = if withdrawals.is_empty() {
|
||||
sequencer.handle()
|
||||
.publish(data_bounded)
|
||||
.context("Failed to publish block")
|
||||
} else {
|
||||
sequencer.handle()
|
||||
.publish_atomic_withdraw(data_bounded, withdrawals)
|
||||
.context("Failed to publish block with withdrawals")
|
||||
};
|
||||
|
||||
let msg_result = published
|
||||
.map(|(result, _checkpoint)| result.tx.inscription().this_msg);
|
||||
match &msg_result {
|
||||
Ok(_) if withdraw_count == 0 => {
|
||||
info!("Published block with the size of {data_byte_size} bytes");
|
||||
let msg_result = published
|
||||
.map(|(result, _checkpoint)| result.tx.inscription().this_msg);
|
||||
match &msg_result {
|
||||
Ok(_) if withdraw_count == 0 => {
|
||||
info!("Published block with the size of {data_byte_size} bytes");
|
||||
}
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals",
|
||||
);
|
||||
}
|
||||
Err(e) => warn!("zone-sdk publish failed: {e:?}"),
|
||||
}
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals",
|
||||
);
|
||||
}
|
||||
Err(e) => warn!("zone-sdk publish failed: {e:?}"),
|
||||
let _dontcare = resp_tx.send(msg_result);
|
||||
}
|
||||
let _dontcare = resp_tx.send(msg_result);
|
||||
}
|
||||
Command::ConfigureChannel {
|
||||
keys,
|
||||
posting_timeframe,
|
||||
posting_timeout,
|
||||
configuration_threshold,
|
||||
withdraw_threshold,
|
||||
resp,
|
||||
} => {
|
||||
let result = sequencer
|
||||
.handle()
|
||||
.channel_config(
|
||||
keys,
|
||||
posting_timeframe,
|
||||
posting_timeout,
|
||||
configuration_threshold,
|
||||
withdraw_threshold,
|
||||
)
|
||||
.map(|_queued| ())
|
||||
.context("Failed to post channel config");
|
||||
if let Err(err) = &result {
|
||||
warn!("zone-sdk channel config failed: {err:?}");
|
||||
}
|
||||
let _dontcare = resp.send(result);
|
||||
}
|
||||
},
|
||||
event = sequencer.next_event() => {
|
||||
let Some(event) = event else {
|
||||
continue;
|
||||
@ -256,7 +318,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
|
||||
Ok(Self {
|
||||
channel_id: config.channel_id,
|
||||
publish_tx,
|
||||
command_tx,
|
||||
turn_rx,
|
||||
_drive_task: Arc::new(DriveTaskGuard(drive_task)),
|
||||
})
|
||||
@ -269,8 +331,12 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
.context("Block data exceeds maximum allowed size")?;
|
||||
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
self.publish_tx
|
||||
.send((data_bounded, withdrawals, resp_tx))
|
||||
self.command_tx
|
||||
.send(Command::Publish {
|
||||
inscription: data_bounded,
|
||||
withdrawals,
|
||||
resp: resp_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_closed| anyhow!("Drive task is no longer running"))?;
|
||||
|
||||
@ -279,6 +345,33 @@ impl BlockPublisherTrait for ZoneSdkPublisher {
|
||||
.map_err(|_closed| anyhow!("Drive task dropped the publish response"))?
|
||||
}
|
||||
|
||||
async fn configure_channel(
|
||||
&self,
|
||||
keys: Vec<Ed25519PublicKey>,
|
||||
posting_timeframe: u32,
|
||||
posting_timeout: u32,
|
||||
configuration_threshold: u16,
|
||||
withdraw_threshold: u16,
|
||||
) -> Result<()> {
|
||||
let keys =
|
||||
Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?;
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
self.command_tx
|
||||
.send(Command::ConfigureChannel {
|
||||
keys,
|
||||
posting_timeframe: posting_timeframe.into(),
|
||||
posting_timeout: posting_timeout.into(),
|
||||
configuration_threshold,
|
||||
withdraw_threshold,
|
||||
resp: resp_tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_closed| anyhow!("Drive task is no longer running"))?;
|
||||
resp_rx
|
||||
.await
|
||||
.map_err(|_closed| anyhow!("Drive task dropped the config response"))?
|
||||
}
|
||||
|
||||
fn channel_id(&self) -> ChannelId {
|
||||
self.channel_id
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ use storage::sequencer::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
block_publisher::{BlockPublisherTrait, ZoneSdkPublisher},
|
||||
block_publisher::{BlockPublisherTrait, Ed25519PublicKey, ZoneSdkPublisher},
|
||||
block_store::SequencerStore,
|
||||
};
|
||||
|
||||
@ -625,6 +625,27 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
|
||||
self.block_publisher.is_our_turn()
|
||||
}
|
||||
|
||||
/// Update the channel's accredited key set and rotation parameters.
|
||||
/// This sequencer's bedrock key must be the channel admin (`keys[0]`).
|
||||
pub async fn configure_channel(
|
||||
&self,
|
||||
keys: Vec<Ed25519PublicKey>,
|
||||
posting_timeframe: u32,
|
||||
posting_timeout: u32,
|
||||
configuration_threshold: u16,
|
||||
withdraw_threshold: u16,
|
||||
) -> Result<()> {
|
||||
self.block_publisher
|
||||
.configure_channel(
|
||||
keys,
|
||||
posting_timeframe,
|
||||
posting_timeout,
|
||||
configuration_threshold,
|
||||
withdraw_threshold,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Shared handle to the two-tier follow state.
|
||||
#[must_use]
|
||||
pub fn chain(&self) -> Arc<Mutex<ChainState>> {
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use common::block::Block;
|
||||
@ -8,17 +11,38 @@ use logos_blockchain_zone_sdk::sequencer::WithdrawArg;
|
||||
|
||||
use crate::{
|
||||
block_publisher::{
|
||||
BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, OnFollowSink,
|
||||
OnWithdrawEventSink, SequencerCheckpoint,
|
||||
BlockPublisherTrait, CheckpointSink, Ed25519PublicKey, FinalizedBlockSink,
|
||||
OnDepositEventSink, OnFollowSink, OnWithdrawEventSink, SequencerCheckpoint,
|
||||
},
|
||||
config::BedrockConfig,
|
||||
};
|
||||
|
||||
pub type SequencerCoreWithMockClients = crate::SequencerCore<MockBlockPublisher>;
|
||||
|
||||
/// One recorded `configure_channel` invocation.
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigureChannelCall {
|
||||
pub keys: Vec<Ed25519PublicKey>,
|
||||
pub posting_timeframe: u32,
|
||||
pub posting_timeout: u32,
|
||||
pub configuration_threshold: u16,
|
||||
pub withdraw_threshold: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MockBlockPublisher {
|
||||
channel_id: ChannelId,
|
||||
configure_channel_calls: Arc<Mutex<Vec<ConfigureChannelCall>>>,
|
||||
}
|
||||
|
||||
impl MockBlockPublisher {
|
||||
#[must_use]
|
||||
pub fn configure_channel_calls(&self) -> Vec<ConfigureChannelCall> {
|
||||
self.configure_channel_calls
|
||||
.lock()
|
||||
.expect("mock mutex poisoned")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl BlockPublisherTrait for MockBlockPublisher {
|
||||
@ -35,6 +59,7 @@ impl BlockPublisherTrait for MockBlockPublisher {
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
channel_id: config.channel_id,
|
||||
configure_channel_calls: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -49,6 +74,27 @@ impl BlockPublisherTrait for MockBlockPublisher {
|
||||
Ok(MsgId::from(block.header.hash.0))
|
||||
}
|
||||
|
||||
async fn configure_channel(
|
||||
&self,
|
||||
keys: Vec<Ed25519PublicKey>,
|
||||
posting_timeframe: u32,
|
||||
posting_timeout: u32,
|
||||
configuration_threshold: u16,
|
||||
withdraw_threshold: u16,
|
||||
) -> Result<()> {
|
||||
self.configure_channel_calls
|
||||
.lock()
|
||||
.expect("mock mutex poisoned")
|
||||
.push(ConfigureChannelCall {
|
||||
keys,
|
||||
posting_timeframe,
|
||||
posting_timeout,
|
||||
configuration_threshold,
|
||||
withdraw_threshold,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn channel_id(&self) -> ChannelId {
|
||||
self.channel_id
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ use lee_core::{
|
||||
program::PdaSeed,
|
||||
};
|
||||
use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId};
|
||||
use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
|
||||
use mempool::MemPoolHandle;
|
||||
use storage::sequencer::sequencer_cells::PendingDepositEventRecord;
|
||||
use tempfile::tempdir;
|
||||
@ -1543,3 +1544,26 @@ async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() {
|
||||
assert_eq!(stored.header.hash, peer_block.header.hash);
|
||||
assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configure_channel_delegates_to_publisher() {
|
||||
let config = setup_sequencer_config();
|
||||
let (sequencer, _mempool_handle) =
|
||||
SequencerCoreWithMockClients::start_from_config(config).await;
|
||||
|
||||
let admin = Ed25519Key::from_bytes(&[0xA1; ED25519_SECRET_KEY_SIZE]).public_key();
|
||||
let peer = Ed25519Key::from_bytes(&[0xB2; ED25519_SECRET_KEY_SIZE]).public_key();
|
||||
sequencer
|
||||
.configure_channel(vec![admin, peer], 20, 30, 1, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let calls = sequencer.block_publisher().configure_channel_calls();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].keys.len(), 2);
|
||||
assert_eq!(calls[0].keys[0], admin);
|
||||
assert_eq!(calls[0].posting_timeframe, 20);
|
||||
assert_eq!(calls[0].posting_timeout, 30);
|
||||
assert_eq!(calls[0].configuration_threshold, 1);
|
||||
assert_eq!(calls[0].withdraw_threshold, 1);
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ programs.workspace = true
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
anyhow.workspace = true
|
||||
env_logger.workspace = true
|
||||
hex.workspace = true
|
||||
log.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
|
||||
@ -13,4 +13,5 @@ lee.workspace = true
|
||||
lee_core.workspace = true
|
||||
|
||||
hex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_with.workspace = true
|
||||
|
||||
@ -26,3 +26,95 @@ impl FromStr for ChannelId {
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Request for `adminConfigureChannel`: replaces the channel's accredited key
|
||||
/// set and rotation parameters.
|
||||
///
|
||||
/// - `keys` are hex-encoded 32-byte Ed25519 public keys
|
||||
/// - `keys[0]` must be this sequencer's (admin) key.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ConfigureChannelRequest {
|
||||
pub keys: Vec<String>,
|
||||
pub posting_timeframe: u32,
|
||||
pub posting_timeout: u32,
|
||||
pub configuration_threshold: u16,
|
||||
pub withdraw_threshold: u16,
|
||||
}
|
||||
|
||||
impl ConfigureChannelRequest {
|
||||
/// Structural sanity checks for the request.
|
||||
///
|
||||
/// The L1 validates this too, but its async so we don't immediately
|
||||
/// know about them when we submit. Checking this here instead gives
|
||||
/// immediate feedback to the caller.
|
||||
///
|
||||
/// We don't need a particular error type here, it's going to be logged only.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let key_count = self.keys.len();
|
||||
if key_count == 0 {
|
||||
return Err("Channel key list must not be empty".to_owned());
|
||||
}
|
||||
for (name, threshold) in [
|
||||
("configuration_threshold", self.configuration_threshold),
|
||||
("withdraw_threshold", self.withdraw_threshold),
|
||||
] {
|
||||
if threshold == 0 || usize::from(threshold) > key_count {
|
||||
return Err(format!(
|
||||
"{name} must be between 1 and the key count ({key_count}), got {threshold}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn configure_channel_request_validate_rejects_static_garbage() {
|
||||
// `validate` is structural: key contents are not parsed here.
|
||||
let base = ConfigureChannelRequest {
|
||||
keys: vec!["unparsed".to_owned(), "unparsed".to_owned()],
|
||||
posting_timeframe: 20,
|
||||
posting_timeout: 30,
|
||||
configuration_threshold: 1,
|
||||
withdraw_threshold: 2,
|
||||
};
|
||||
|
||||
assert!(base.validate().is_ok());
|
||||
assert!(
|
||||
ConfigureChannelRequest {
|
||||
keys: vec![],
|
||||
..base
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ConfigureChannelRequest {
|
||||
configuration_threshold: 0,
|
||||
..base.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ConfigureChannelRequest {
|
||||
configuration_threshold: 3,
|
||||
..base.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ConfigureChannelRequest {
|
||||
withdraw_threshold: 3,
|
||||
..base
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,8 @@ use jsonrpsee::types::ErrorObjectOwned;
|
||||
#[cfg(feature = "client")]
|
||||
pub use jsonrpsee::{core::ClientError, http_client::HttpClientBuilder as SequencerClientBuilder};
|
||||
use sequencer_service_protocol::{
|
||||
Account, AccountId, Block, BlockId, ChannelId, Commitment, HashType, LeeTransaction,
|
||||
MembershipProof, Nonce, ProgramId,
|
||||
Account, AccountId, Block, BlockId, ChannelId, Commitment, ConfigureChannelRequest, HashType,
|
||||
LeeTransaction, MembershipProof, Nonce, ProgramId,
|
||||
};
|
||||
|
||||
#[cfg(all(not(feature = "server"), not(feature = "client")))]
|
||||
@ -92,4 +92,17 @@ pub trait Rpc {
|
||||
async fn get_channel_id(&self) -> Result<ChannelId, ErrorObjectOwned>;
|
||||
|
||||
// =============================================================================================
|
||||
|
||||
/// Admin-only in effect: the L1 rejects the config op unless this
|
||||
/// sequencer's key is the channel admin (`keys[0]` of the current roster).
|
||||
///
|
||||
/// `Ok(())` only means the signed config op was queued locally (like
|
||||
/// block publishing), not that L1 accepted it: acceptance is asynchronous,
|
||||
/// and a rejection (e.g. non-admin signer) is not reported here — it only
|
||||
/// shows up in node logs and on-chain behavior.
|
||||
#[method(name = "adminConfigureChannel")]
|
||||
async fn admin_configure_channel(
|
||||
&self,
|
||||
request: ConfigureChannelRequest,
|
||||
) -> Result<(), ErrorObjectOwned>;
|
||||
}
|
||||
|
||||
@ -9,11 +9,12 @@ use lee;
|
||||
use log::warn;
|
||||
use mempool::MemPoolHandle;
|
||||
use sequencer_core::{
|
||||
DbError, SequencerCore, TransactionOrigin, block_publisher::BlockPublisherTrait,
|
||||
DbError, SequencerCore, TransactionOrigin,
|
||||
block_publisher::{BlockPublisherTrait, Ed25519PublicKey},
|
||||
};
|
||||
use sequencer_service_protocol::{
|
||||
Account, AccountId, Block, BlockId, ChannelId, Commitment, HashType, MembershipProof, Nonce,
|
||||
ProgramId,
|
||||
Account, AccountId, Block, BlockId, ChannelId, Commitment, ConfigureChannelRequest, HashType,
|
||||
MembershipProof, Nonce, ProgramId,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@ -40,7 +41,7 @@ impl<BC: BlockPublisherTrait> SequencerService<BC> {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
|
||||
impl<BC: BlockPublisherTrait + Send + Sync + 'static> sequencer_service_rpc::RpcServer
|
||||
for SequencerService<BC>
|
||||
{
|
||||
async fn send_transaction(&self, tx: LeeTransaction) -> Result<HashType, ErrorObjectOwned> {
|
||||
@ -198,8 +199,68 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
|
||||
let channel_id = self.sequencer.lock().await.block_publisher().channel_id();
|
||||
Ok(ChannelId(*channel_id.as_ref()))
|
||||
}
|
||||
|
||||
async fn admin_configure_channel(
|
||||
&self,
|
||||
request: ConfigureChannelRequest,
|
||||
) -> Result<(), ErrorObjectOwned> {
|
||||
request.validate().map_err(invalid_params)?;
|
||||
let keys = request
|
||||
.keys
|
||||
.iter()
|
||||
.map(|hex_key| parse_channel_key(hex_key))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let sequencer = self.sequencer.lock().await;
|
||||
sequencer
|
||||
.configure_channel(
|
||||
keys,
|
||||
request.posting_timeframe,
|
||||
request.posting_timeout,
|
||||
request.configuration_threshold,
|
||||
request.withdraw_threshold,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
ErrorObjectOwned::owned(
|
||||
ErrorCode::InternalError.code(),
|
||||
format!("{err:#}"),
|
||||
None::<()>,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn internal_error(err: &DbError) -> ErrorObjectOwned {
|
||||
ErrorObjectOwned::owned(ErrorCode::InternalError.code(), err.to_string(), None::<()>)
|
||||
}
|
||||
|
||||
fn invalid_params(detail: String) -> ErrorObjectOwned {
|
||||
ErrorObjectOwned::owned(ErrorCode::InvalidParams.code(), detail, None::<()>)
|
||||
}
|
||||
|
||||
/// Parses one hex-encoded 32-byte Ed25519 public key from an RPC request.
|
||||
fn parse_channel_key(hex_key: &str) -> Result<Ed25519PublicKey, ErrorObjectOwned> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
hex::decode_to_slice(hex_key, &mut bytes)
|
||||
.map_err(|err| invalid_params(format!("Invalid hex-encoded key: {err}")))?;
|
||||
Ed25519PublicKey::from_bytes(&bytes)
|
||||
.map_err(|err| invalid_params(format!("Invalid Ed25519 public key: {err}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use sequencer_core::block_publisher::{ED25519_SECRET_KEY_SIZE, Ed25519Key};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_channel_key_roundtrips_and_rejects_garbage() {
|
||||
let key = Ed25519Key::from_bytes(&[7; ED25519_SECRET_KEY_SIZE]).public_key();
|
||||
let parsed = parse_channel_key(&hex::encode(key.to_bytes())).unwrap();
|
||||
assert_eq!(parsed.to_bytes(), key.to_bytes());
|
||||
|
||||
assert!(parse_channel_key("not-hex").is_err());
|
||||
assert!(parse_channel_key("abcd").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,10 @@ use anyhow::{Context as _, Result, bail};
|
||||
use indexer_service::{ChannelId, IndexerHandle};
|
||||
use lee::{AccountId, PrivateKey, PublicKey};
|
||||
use log::{debug, warn};
|
||||
use sequencer_core::block_store::{DbDump, SequencerStore};
|
||||
use sequencer_core::{
|
||||
block_publisher::ED25519_SECRET_KEY_SIZE,
|
||||
block_store::{DbDump, SequencerStore},
|
||||
};
|
||||
use sequencer_service::{GenesisAction, SequencerHandle};
|
||||
use tempfile::TempDir;
|
||||
use testcontainers::compose::DockerCompose;
|
||||
@ -152,6 +155,7 @@ pub async fn setup_sequencer(
|
||||
SequencerInit::Genesis(genesis_transactions),
|
||||
channel_id,
|
||||
cross_zone,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@ -169,6 +173,31 @@ pub async fn setup_sequencer_from_prebuilt(
|
||||
SequencerInit::Prebuilt(&dump),
|
||||
config::bedrock_channel_id(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Like [`setup_sequencer`], but with a pre-generated bedrock (Ed25519, 32-byte
|
||||
/// seed) signing key.
|
||||
///
|
||||
/// This allows the tests to know the sequencer's public key before it boots which
|
||||
/// is required to accredit a committee member that has not started yet.
|
||||
pub async fn setup_sequencer_with_bedrock_key(
|
||||
partial: config::SequencerPartialConfig,
|
||||
bedrock_addr: SocketAddr,
|
||||
genesis_transactions: Vec<GenesisAction>,
|
||||
channel_id: ChannelId,
|
||||
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
|
||||
bedrock_signing_key: [u8; ED25519_SECRET_KEY_SIZE],
|
||||
) -> Result<(SequencerHandle, TempDir)> {
|
||||
setup_sequencer_inner(
|
||||
partial,
|
||||
bedrock_addr,
|
||||
SequencerInit::Genesis(genesis_transactions),
|
||||
channel_id,
|
||||
cross_zone,
|
||||
Some(bedrock_signing_key),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@ -179,6 +208,7 @@ async fn setup_sequencer_inner(
|
||||
init: SequencerInit<'_>,
|
||||
channel_id: ChannelId,
|
||||
cross_zone: Option<sequencer_core::config::CrossZoneConfig>,
|
||||
bedrock_signing_key: Option<[u8; ED25519_SECRET_KEY_SIZE]>,
|
||||
) -> Result<(SequencerHandle, TempDir)> {
|
||||
let temp_sequencer_dir =
|
||||
tempfile::tempdir().context("Failed to create temp dir for sequencer home")?;
|
||||
@ -188,6 +218,14 @@ async fn setup_sequencer_inner(
|
||||
temp_sequencer_dir.path().display()
|
||||
);
|
||||
|
||||
if let Some(key_bytes) = bedrock_signing_key {
|
||||
std::fs::write(
|
||||
temp_sequencer_dir.path().join("bedrock_signing_key"),
|
||||
key_bytes,
|
||||
)
|
||||
.context("Failed to write pre-generated bedrock signing key")?;
|
||||
}
|
||||
|
||||
let genesis_transactions = match init {
|
||||
SequencerInit::Genesis(genesis) => genesis,
|
||||
SequencerInit::Prebuilt(dump) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user