From 0143a7edf1f3a05d1a64ac60148a437a95e218dd Mon Sep 17 00:00:00 2001 From: erhant Date: Mon, 13 Jul 2026 23:25:38 +0300 Subject: [PATCH 1/8] feat(sequencer): channel roster configuration through the block publisher --- lez/sequencer/core/src/block_publisher.rs | 169 ++++++++++++++++------ lez/sequencer/core/src/lib.rs | 23 ++- lez/sequencer/core/src/mock.rs | 52 ++++++- lez/sequencer/core/src/tests.rs | 21 +++ 4 files changed, 219 insertions(+), 46 deletions(-) diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index 9cd21b45..ecd1ffc8 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -3,8 +3,11 @@ 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_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::{Ed25519Key, ZkKey}; pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; use logos_blockchain_zone_sdk::{ @@ -58,13 +61,26 @@ pub struct FollowUpdate { pub type OnFollowSink = Box Pin + 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, - oneshot::Sender>, -)>; +/// 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, + resp: oneshot::Sender>, + }, + /// 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>, + }, +} + +type CommandSender = mpsc::Sender; #[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")] pub trait BlockPublisherTrait: Clone { @@ -88,6 +104,19 @@ pub trait BlockPublisherTrait: Clone { /// Zone-sdk drives the actual submission and retries internally. async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result; + /// 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. + async fn configure_channel( + &self, + keys: Vec, + posting_timeframe: u32, + posting_timeout: u32, + configuration_threshold: u16, + withdraw_threshold: u16, + ) -> Result<()>; + fn channel_id(&self) -> ChannelId; /// Whether this sequencer is currently authorized to write to the channel. @@ -98,7 +127,7 @@ pub trait BlockPublisherTrait: Clone { #[derive(Clone)] pub struct ZoneSdkPublisher { channel_id: ChannelId, - publish_tx: PublishSender, + command_tx: CommandSender, turn_rx: watch::Receiver, // Aborts the drive task when the last clone is dropped. _drive_task: Arc, @@ -146,7 +175,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 +186,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 +310,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 +323,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 +337,33 @@ impl BlockPublisherTrait for ZoneSdkPublisher { .map_err(|_closed| anyhow!("Drive task dropped the publish response"))? } + async fn configure_channel( + &self, + keys: Vec, + posting_timeframe: u32, + posting_timeout: u32, + configuration_threshold: u16, + withdraw_threshold: u16, + ) -> Result<()> { + let keys = Keys::try_from(keys) + .map_err(|_err| anyhow!("Channel key list must be non-empty and within bounds"))?; + 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 } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 1cc1ed4b..c0f09135 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -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 SequencerCore { 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, + 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> { diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index 37c43254..5cc46900 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -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; +/// One recorded `configure_channel` invocation. +#[derive(Clone)] +pub struct ConfigureChannelCall { + pub keys: Vec, + 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>>, +} + +impl MockBlockPublisher { + #[must_use] + pub fn configure_channel_calls(&self) -> Vec { + self.configure_channel_calls + .lock() + .expect("mock mutex poisoned") + .clone() + } } impl BlockPublisherTrait for MockBlockPublisher { @@ -35,6 +59,7 @@ impl BlockPublisherTrait for MockBlockPublisher { ) -> Result { 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, + 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 } diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index f78e8ef4..177b2783 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -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,23 @@ 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].posting_timeframe, 20); + assert_eq!(calls[0].posting_timeout, 30); +} From 726312025d4dba08924793d3d6a9091f784dd0e3 Mon Sep 17 00:00:00 2001 From: erhant Date: Tue, 14 Jul 2026 13:47:52 +0300 Subject: [PATCH 2/8] feat(sequencer): adminConfigureChannel RPC for channel roster changes --- Cargo.lock | 2 + lez/sequencer/core/src/block_publisher.rs | 7 ++- lez/sequencer/service/Cargo.toml | 1 + lez/sequencer/service/protocol/Cargo.toml | 1 + lez/sequencer/service/protocol/src/lib.rs | 14 +++++ lez/sequencer/service/rpc/src/lib.rs | 12 +++- lez/sequencer/service/src/service.rs | 67 +++++++++++++++++++++-- 7 files changed, 96 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fcdcec14..93d5f16b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9085,6 +9085,7 @@ dependencies = [ "common", "env_logger", "futures", + "hex", "jsonrpsee", "lee", "log", @@ -9105,6 +9106,7 @@ dependencies = [ "hex", "lee", "lee_core", + "serde", "serde_with", ] diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index ecd1ffc8..6230d32c 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -108,14 +108,17 @@ pub trait BlockPublisherTrait: Clone { /// `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. - async fn configure_channel( + /// + /// 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, posting_timeframe: u32, posting_timeout: u32, configuration_threshold: u16, withdraw_threshold: u16, - ) -> Result<()>; + ) -> impl Future> + Send; fn channel_id(&self) -> ChannelId; diff --git a/lez/sequencer/service/Cargo.toml b/lez/sequencer/service/Cargo.toml index 3427dc22..94617bd9 100644 --- a/lez/sequencer/service/Cargo.toml +++ b/lez/sequencer/service/Cargo.toml @@ -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 diff --git a/lez/sequencer/service/protocol/Cargo.toml b/lez/sequencer/service/protocol/Cargo.toml index ced19e75..1eb413d0 100644 --- a/lez/sequencer/service/protocol/Cargo.toml +++ b/lez/sequencer/service/protocol/Cargo.toml @@ -13,4 +13,5 @@ lee.workspace = true lee_core.workspace = true hex.workspace = true +serde.workspace = true serde_with.workspace = true diff --git a/lez/sequencer/service/protocol/src/lib.rs b/lez/sequencer/service/protocol/src/lib.rs index 58e300f6..475772aa 100644 --- a/lez/sequencer/service/protocol/src/lib.rs +++ b/lez/sequencer/service/protocol/src/lib.rs @@ -26,3 +26,17 @@ 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, + pub posting_timeframe: u32, + pub posting_timeout: u32, + pub configuration_threshold: u16, + pub withdraw_threshold: u16, +} diff --git a/lez/sequencer/service/rpc/src/lib.rs b/lez/sequencer/service/rpc/src/lib.rs index f7aa8b56..40b9d2bd 100644 --- a/lez/sequencer/service/rpc/src/lib.rs +++ b/lez/sequencer/service/rpc/src/lib.rs @@ -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,12 @@ pub trait Rpc { async fn get_channel_id(&self) -> Result; // ============================================================================================= + + /// 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). + #[method(name = "adminConfigureChannel")] + async fn admin_configure_channel( + &self, + request: ConfigureChannelRequest, + ) -> Result<(), ErrorObjectOwned>; } diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index 317af868..b8fce3df 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -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 SequencerService { } #[async_trait] -impl sequencer_service_rpc::RpcServer +impl sequencer_service_rpc::RpcServer for SequencerService { async fn send_transaction(&self, tx: LeeTransaction) -> Result { @@ -198,8 +199,66 @@ impl 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> { + let keys = request + .keys + .iter() + .map(|hex_key| parse_channel_key(hex_key)) + .collect::, _>>()?; + + 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::<()>) } + +/// Parses one hex-encoded 32-byte Ed25519 public key from an RPC request. +fn parse_channel_key(hex_key: &str) -> Result { + let invalid = |detail: String| { + ErrorObjectOwned::owned(ErrorCode::InvalidParams.code(), detail, None::<()>) + }; + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(hex_key, &mut bytes) + .map_err(|err| invalid(format!("Invalid hex-encoded key: {err}")))?; + Ed25519PublicKey::from_bytes(&bytes) + .map_err(|err| invalid(format!("Invalid Ed25519 public key: {err}"))) +} + +#[cfg(test)] +mod tests { + use sequencer_core::block_publisher::Ed25519Key; + + use super::*; + + #[test] + fn parse_channel_key_roundtrips_and_rejects_garbage() { + let key = Ed25519Key::from_bytes(&[7; 32]).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()); + } +} From 8bbc2697c91b5626708dcac5b03180cd34fd0c33 Mon Sep 17 00:00:00 2001 From: erhant Date: Tue, 14 Jul 2026 13:48:00 +0300 Subject: [PATCH 3/8] test(fixtures): allow injecting a pre-generated bedrock signing key --- test_fixtures/src/setup.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test_fixtures/src/setup.rs b/test_fixtures/src/setup.rs index 325e1628..cdf87c9c 100644 --- a/test_fixtures/src/setup.rs +++ b/test_fixtures/src/setup.rs @@ -152,6 +152,7 @@ pub async fn setup_sequencer( SequencerInit::Genesis(genesis_transactions), channel_id, cross_zone, + None, ) .await } @@ -169,6 +170,30 @@ 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 written into the home so tests know the sequencer's +/// public key before it boots — 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, + channel_id: ChannelId, + cross_zone: Option, + bedrock_signing_key: [u8; 32], +) -> Result<(SequencerHandle, TempDir)> { + setup_sequencer_inner( + partial, + bedrock_addr, + SequencerInit::Genesis(genesis_transactions), + channel_id, + cross_zone, + Some(bedrock_signing_key), ) .await } @@ -179,6 +204,7 @@ async fn setup_sequencer_inner( init: SequencerInit<'_>, channel_id: ChannelId, cross_zone: Option, + bedrock_signing_key: Option<[u8; 32]>, ) -> Result<(SequencerHandle, TempDir)> { let temp_sequencer_dir = tempfile::tempdir().context("Failed to create temp dir for sequencer home")?; @@ -188,6 +214,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) => { From 969ba979eb5c82b43d4b475073d28d6c0c72b3c0 Mon Sep 17 00:00:00 2001 From: erhant Date: Tue, 14 Jul 2026 14:53:34 +0300 Subject: [PATCH 4/8] test(integration): multi-sequencer committee convergence --- Cargo.lock | 2 + integration_tests/Cargo.toml | 2 + integration_tests/tests/multi_sequencer.rs | 251 +++++++++++++++++++++ test_fixtures/src/setup.rs | 7 +- 4 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 integration_tests/tests/multi_sequencer.rs diff --git a/Cargo.lock b/Cargo.lock index 93d5f16b..648f7925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index a225d657..a2ab08b7 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -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 diff --git a/integration_tests/tests/multi_sequencer.rs b/integration_tests/tests/multi_sequencer.rs new file mode 100644 index 00000000..0e4055ea --- /dev/null +++ b/integration_tests/tests/multi_sequencer.rs @@ -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::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; 32]; + let key_b = [0xB2_u8; 32]; + 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 { + 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 { + 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(()) +} diff --git a/test_fixtures/src/setup.rs b/test_fixtures/src/setup.rs index cdf87c9c..fe41ee99 100644 --- a/test_fixtures/src/setup.rs +++ b/test_fixtures/src/setup.rs @@ -176,9 +176,10 @@ pub async fn setup_sequencer_from_prebuilt( } /// Like [`setup_sequencer`], but with a pre-generated bedrock (Ed25519, 32-byte -/// seed) signing key written into the home so tests know the sequencer's -/// public key before it boots — required to accredit a committee member that -/// has not started yet. +/// 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, From dd0c0daaa7f6c3e4adc4964ebd9d1b0a437c3c95 Mon Sep 17 00:00:00 2001 From: erhant Date: Tue, 14 Jul 2026 16:21:23 +0300 Subject: [PATCH 5/8] docs(sequencer): clarify queued semantics of adminConfigureChannel --- lez/sequencer/core/src/block_publisher.rs | 3 +++ lez/sequencer/core/src/tests.rs | 3 +++ lez/sequencer/service/rpc/src/lib.rs | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index 6230d32c..f429e780 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -109,6 +109,9 @@ pub trait BlockPublisherTrait: Clone { /// 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( diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 177b2783..a5dd971b 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -1561,6 +1561,9 @@ async fn configure_channel_delegates_to_publisher() { 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); } diff --git a/lez/sequencer/service/rpc/src/lib.rs b/lez/sequencer/service/rpc/src/lib.rs index 40b9d2bd..ccf015bc 100644 --- a/lez/sequencer/service/rpc/src/lib.rs +++ b/lez/sequencer/service/rpc/src/lib.rs @@ -95,6 +95,11 @@ pub trait Rpc { /// 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, From fd58bf459d691ff45fc5a0aaa5d4c4ebe6bbf0ed Mon Sep 17 00:00:00 2001 From: erhant Date: Tue, 14 Jul 2026 17:25:17 +0300 Subject: [PATCH 6/8] fix(sequencer): use `ED25519_SECRET_KEY_SIZE` and retain `BoundedError` message --- integration_tests/tests/multi_sequencer.rs | 6 +++--- lez/sequencer/core/src/block_publisher.rs | 8 +++++--- lez/sequencer/service/src/service.rs | 4 ++-- test_fixtures/src/setup.rs | 9 ++++++--- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/integration_tests/tests/multi_sequencer.rs b/integration_tests/tests/multi_sequencer.rs index 0e4055ea..c0c4a265 100644 --- a/integration_tests/tests/multi_sequencer.rs +++ b/integration_tests/tests/multi_sequencer.rs @@ -16,7 +16,7 @@ use integration_tests::{ indexer_client::IndexerClient, setup::{setup_bedrock_node, setup_indexer, setup_sequencer_with_bedrock_key}, }; -use logos_blockchain_key_management_system_service::keys::Ed25519Key; +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}; @@ -39,8 +39,8 @@ async fn multi_sequencer_committee_converges() -> Result<()> { .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; 32]; - let key_b = [0xB2_u8; 32]; + 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(); diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index f429e780..105e0c77 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -8,7 +8,9 @@ use logos_blockchain_core::mantle::{ channel::{SlotTimeframe, SlotTimeout}, ops::channel::{ChannelId, config::Keys, inscribe::Inscription}, }; -pub use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkKey}; +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, @@ -351,8 +353,8 @@ impl BlockPublisherTrait for ZoneSdkPublisher { configuration_threshold: u16, withdraw_threshold: u16, ) -> Result<()> { - let keys = Keys::try_from(keys) - .map_err(|_err| anyhow!("Channel key list must be non-empty and within bounds"))?; + 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 { diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index b8fce3df..53fc3897 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -248,13 +248,13 @@ fn parse_channel_key(hex_key: &str) -> Result, channel_id: ChannelId, cross_zone: Option, - bedrock_signing_key: [u8; 32], + bedrock_signing_key: [u8; ED25519_SECRET_KEY_SIZE], ) -> Result<(SequencerHandle, TempDir)> { setup_sequencer_inner( partial, @@ -205,7 +208,7 @@ async fn setup_sequencer_inner( init: SequencerInit<'_>, channel_id: ChannelId, cross_zone: Option, - bedrock_signing_key: Option<[u8; 32]>, + 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")?; From a621a65f405877f97cd86e66712abb6ac10748b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:58:43 +0000 Subject: [PATCH 7/8] fix: update spin v0.9.8 -> v0.9.9 (yanked crate fix for cargo-deny) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 648f7925..06a3e08e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9540,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", ] From a5c5e06700ffd27586d722049e0b2f75f6bfacf6 Mon Sep 17 00:00:00 2001 From: erhant Date: Tue, 14 Jul 2026 18:10:34 +0300 Subject: [PATCH 8/8] fix(sequencer): validate `adminConfigureChannel` requests, per Copilot review --- lez/sequencer/service/protocol/src/lib.rs | 82 ++++++++++++++++++++++- lez/sequencer/service/src/service.rs | 12 ++-- 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/lez/sequencer/service/protocol/src/lib.rs b/lez/sequencer/service/protocol/src/lib.rs index 475772aa..afe61ab6 100644 --- a/lez/sequencer/service/protocol/src/lib.rs +++ b/lez/sequencer/service/protocol/src/lib.rs @@ -30,8 +30,8 @@ impl FromStr for ChannelId { /// 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. +/// - `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, @@ -40,3 +40,81 @@ pub struct ConfigureChannelRequest { 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() + ); + } +} diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index 53fc3897..549144f0 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -204,6 +204,7 @@ impl sequencer_service_rpc::Rpc &self, request: ConfigureChannelRequest, ) -> Result<(), ErrorObjectOwned> { + request.validate().map_err(invalid_params)?; let keys = request .keys .iter() @@ -234,16 +235,17 @@ 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 { - let invalid = |detail: String| { - ErrorObjectOwned::owned(ErrorCode::InvalidParams.code(), detail, None::<()>) - }; let mut bytes = [0_u8; 32]; hex::decode_to_slice(hex_key, &mut bytes) - .map_err(|err| invalid(format!("Invalid hex-encoded key: {err}")))?; + .map_err(|err| invalid_params(format!("Invalid hex-encoded key: {err}")))?; Ed25519PublicKey::from_bytes(&bytes) - .map_err(|err| invalid(format!("Invalid Ed25519 public key: {err}"))) + .map_err(|err| invalid_params(format!("Invalid Ed25519 public key: {err}"))) } #[cfg(test)]