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:
erhant 2026-07-14 18:15:58 +03:00 committed by GitHub
commit e5c0e2c91d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 703 additions and 56 deletions

8
Cargo.lock generated
View File

@ -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",
]

View File

@ -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

View 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(())
}

View File

@ -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
}

View File

@ -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>> {

View File

@ -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
}

View File

@ -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);
}

View File

@ -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

View File

@ -13,4 +13,5 @@ lee.workspace = true
lee_core.workspace = true
hex.workspace = true
serde.workspace = true
serde_with.workspace = true

View File

@ -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()
);
}
}

View File

@ -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>;
}

View File

@ -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());
}
}

View File

@ -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) => {