Operate a decentralised Zone with round-robin sequencer rotation
Get started with multi-sequencer channels using the Zone SDK and round-robin slot windows.
This tutorial covers how to configure and operate a Logos Blockchain channel with a committee of accredited sequencers that take turns publishing inscriptions to a Logos Zone. It is intended for zone developers using the Zone SDK who need to move from a single-sequencer setup to a decentralized one.
The Zone SDK currently supports round-robin rotation only. Each sequencer publishes inscriptions for posting_timeframe slots before the rotation advances to the next sequencer in the accredited_keys list. Other scheduling schemes (such as First-Write-Wins) are not yet available.
Before you start, make sure you have the following:
- An existing application using the Zone SDK
- At least one
Ed25519PublicKeygenerated per committee member
What to expect
- You can publish inscriptions from any sequencer at any time; the SDK holds pending transactions and drains them automatically when the sequencer's turn arrives.
- You can monitor each sequencer's turn status in real time using
subscribe_turn_to_writeorsubscribe_channel_view. - You can update the accredited key list to add or remove sequencers by submitting a
ChannelConfigOp.
Step 1: Size the rotation parameters
Choose values for posting_timeframe and posting_timeout before creating or reconfiguring the channel. These fields on ChannelState control the rotation cadence and inactive-sequencer skip behavior.
The Mantle specification is the source of truth for the exact rotation algorithm. The guidance below is a practical summary.
-
Set
posting_timeframe- the length of each sequencer's turn under normal circumstances - to at least the average slots-per-block of the Logos Blockchain.- A turn shorter than one block lets multiple sequencers become authorized within the same block, defeating the purpose of rotation.
- As a rule of thumb, use a multiple: with a ~20 slots/block average,
posting_timeframe = 60gives each sequencer roughly three blocks of ownership per turn.
-
Set
posting_timeoutto a value greater than or equal toposting_timeframe.posting_timeoutis the time we wait for an inactive sequencer to publish before continuing to the next sequencer - all subsequent inactive sequencers have a turn ofposting_timeoutslots until one of them publishes. At that point, turns go back to beingposting_timeframeslots long.- Values smaller than
posting_timeframemake the timeout branch dominate cadence as soon as the authorized sequencer goes silent.
- Values smaller than
-
Confirm the worked example matches your intended behavior before proceeding.
A channel with three accredited keys,
posting_timeframe = 60, andposting_timeout = 180:slots 0–59 → index 0slots 60–119 → index 1slots 120–179 → index 2slots 180–239 → index 0...info
The Zone SDK exposes the current rotation state to your sequencer in two forms:
- The full
SequencerChannelView(subscribe_channel_view) — atokio::sync::watchreceiver carryingauthorized_key_index,own_key_index,our_turn_to_write, the current slot, and the turn window'sturn_to_write_slots. - A focused
TurnNotification(subscribe_turn_to_write) plusEvent::TurnNotification— emitted only when the turn boundary actually changes.
our_turn_to_write is the boolean a sequencer reads to decide whether it is currently authorised.
:::
Step 2: Publish inscriptions and monitor turn state
The Zone SDK queues inscriptions locally when it is not your sequencer's turn and posts them automatically when the turn arrives. You do not need to gate publish calls on turn state yourself.
-
Call
publishfrom any task at any time.// From the drive task, once `Event::Ready` has fired.let (result, checkpoint) = sequencer.handle().publish(zone_block)?;// `result.tx` is a `PendingTx::Inscription(...)`.// `checkpoint` is up to date with the new pending entry.// The post may not have hit the node yet if it is not our turn.- The SDK enqueues the transaction immediately if it is your turn and posts to the network.
- The SDK enqueues the transaction with
posted: falseif it is not your turn; it drains automatically when the turn arrives. - A periodic
resubmit_interval(default 30 s) re-drains pending publishes if your turn is current, covering transient network failures.
-
Subscribe to turn notifications to drive your UI or application logic. Choose one of the two surfaces based on your use case:
-
Use
TurnNotification(edge-triggered) to wake the UI only when the turn boundary changes:use lb_zone_sdk::sequencer::Event;if let Event::TurnNotification { notification } = event {if notification.our_turn_to_write {println!("Our turn until slot {:?}",notification.ends_at_slot,);} else {println!("Waiting for our turn; current authorized window ends at {:?}",notification.ends_at_slot,);}} -
Use
subscribe_channel_view(snapshot) to read the full rotation state on every block, for example to render a status panel:let view_rx = sequencer.subscribe_channel_view();let view = view_rx.borrow().clone();if view.our_turn_to_write {// Authorized right now.} else if let (Some(own), Some(auth)) = (view.own_key_index, view.authorized_key_index) {println!("Sequencer {} is publishing; we are sequencer {}.",auth, own,);}The view also carries
pending_publish_txsandqueued_messagesso the UI can show how many inscriptions are waiting for the next turn.
-
-
Handle competing writes by listening for
Event::BlocksProcessedevents and checkingchannel_update.orphanedfor orphaned transactions. Republish these transactions with the latest parent.
Two sequencers can race on the same parent slot during rotation transitions or after a resync. The on-chain rule is: the first valid inscription wins; the loser's transaction is surfaced in channel_update.orphaned.
use lb_zone_sdk::sequencer::{Event, OrphanedTx};
if let Event::BlocksProcessed { channel_update, .. } = event {
for entry in channel_update.orphaned {
if let OrphanedTx::Inscription(info) = entry {
let (result, checkpoint) = sequencer.handle().publish(info.payload)?;
// Persist `result` + `checkpoint` exactly as on the original publish.
}
}
}
- See
OrphanRepublishPolicyintests/src/cucumber/steps/manual_zone/support.rsfor the reference policy used in integration tests. - If the orphan was caused by an application-level conflict rather than a race, you can choose to drop the payload or deduplicate it against a higher-level transaction stream, or apply any other custom rule instead of republishing.
Step 3: Update the committee with a channel config op
Submit a ChannelConfigOp to add or remove sequencers from accredited_keys. Because ChannelConfigOp overwrites the entire key list, you should re-include any keys that should remain accredited after the update.
-
Use
channel_configdirectly whenconfiguration_threshold == 1- that is, when you only need one sequencer's signature to change the configuration.use lb_core::mantle::channel::{SlotTimeframe, SlotTimeout};use lb_core::mantle::ops::channel::config::Keys;let new_keys = Keys::from(vec![admin_pk,new_sequencer_b_pk,new_sequencer_c_pk,]);let (result, checkpoint, signed_tx) = sequencer.handle().channel_config(new_keys,SlotTimeframe::from(60), // ~3 blocks per turn at 20 slots/blockSlotTimeout::from(180), // skip after 3 turn windows of inactivity1, // configuration_threshold (still single-admin)1, // withdraw_threshold)?;On finalization,
refresh_channel_statepicks up the new config and the nextBlocksProcessedevent recomputesour_turn_to_writefor every running sequencer. -
Use the
prepare_tx+submit_signed_txflow whenconfiguration_threshold > 1. In this case, collecting signatures from other sequencers is the responsibility of the application rather than the Zone SDK.
Multi-admin config changes are not yet exercised by integration tests; treat the flow below as the reference path until full SDK support lands.
- The proposer calls
prepare_txwith aChannelConfigOppayload and distributes the unsigned transaction to the rest of the committee. - Each co-signer calls
sign_txand returns anIndexedSignature. - The proposer assembles a
ChannelMultiSigProoffrom the collected signatures and submits viasubmit_signed_tx.
use lb_core::mantle::{
Op, SignedMantleTx,
ops::{OpProof, channel::config::ChannelConfigOp},
};
use lb_core::proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature};
// 1. Read this sequencer's index from the channel view.
let view = sequencer.subscribe_channel_view().borrow().clone();
let own_key_index = view.own_key_index.ok_or("not an accredited key")?;
// 2. Build the unsigned config tx and get this sequencer's own signature.
let config = ChannelConfigOp {
channel,
keys: new_keys,
posting_timeframe,
posting_timeout,
configuration_threshold,
withdraw_threshold,
};
let (tx, msg_id, own_sig) = sequencer.handle().prepare_tx(
[Op::ChannelConfig(config)].into(),
inscription_payload,
)?;
// 3. Distribute `tx` to the rest of the committee, collect their
// `IndexedSignature`s. Transport is application-defined.
let signatures: Vec<IndexedSignature> = collect_signatures_from_committee(
&tx,
IndexedSignature::new(own_key_index, own_sig.clone()),
).await?;
// 4. Assemble the threshold proof and submit.
let config_proof = ChannelMultiSigProof::new(signatures)?;
let signed_tx = SignedMantleTx::new(
tx,
vec![
OpProof::ChannelMultiSigProof(config_proof),
OpProof::Ed25519Sig(own_sig),
],
)?;
let (result, checkpoint) = sequencer
.handle()
.submit_signed_tx(signed_tx, msg_id)?;