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 document is accurate for Testnet v0.3.
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 decentralised 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.
- An existing application using the Zone SDK, such as an inscription program.
- 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 behaviour.
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 authorised 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 authorised sequencer goes silent.
- Values smaller than
-
Confirm the worked example matches your intended behaviour 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...infoThe 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_writeis the boolean a sequencer reads to decide whether it is currently authorised. - The full
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).await?;// `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.orphaned()for orphaned transactions. Republish these transactions with the latest parent.infoTwo 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::{ChannelUpdateTx, Event};if let Event::BlocksProcessed { channel_update, .. } = event {for entry in channel_update.orphaned() {if let ChannelUpdateTx::Inscription(info) = entry {let (result, checkpoint) = sequencer.handle().publish(info.payload.clone()).await?;// Persist `result` + `checkpoint` exactly as on the original publish.}}}channel_updateis aChannelUpdateobject. It is aChannelUpdate::Extension { adopted }when nothing you hold became invalid, and aChannelUpdate::Conflict { common_prefix, adopted, orphaned }when entries left the channel view.orphaned()is empty on an extension.- To keep your own state in step with a conflict, either revert every orphaned entry and apply every adopted one, or reset to finalised state and replay
channel_update.canonical_chain(), the whole non-finalised view at the new tip. Your own publishes are applied at publish time and appear inadoptedonly after they were first reported orphaned. tests/src/cucumber/steps/zone/operations/policies.rsin thelogos-blockchainrepository contains reference policies for this republish loop:OrphanRepublishPolicy(simple republish),RepublishLineagePolicy(for repeating payloads, so each intent lands once),SortedConflictPolicy(republish only when it preserves the channel's sorted order), andBalanceAwarePolicy(republish only when the account balance still allows it).- 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.
noteChannel transactions built outside the publish API are classified by shape, not by how they were submitted. One inscription plus at most one funding transfer surfaces as
ChannelUpdateTx::Inscription; with withdrawals added, asChannelUpdateTx::AtomicWithdraw; with one channel transfer and no withdrawals, asChannelUpdateTx::PinDeposit. A lone channel config surfaces asChannelUpdateTx::Config. Anything else surfaces asChannelUpdateTx::Custom(SignedOps).For
Customentries the SDK hands back the whole transaction, and it is up to the consumer to parse it (thechannel_inscriptionshelper extracts its inscriptions) and decide how to recover it. The main API ispublish,publish_atomic_withdraw, andpublish_pin_deposit.Stuck transactionsA pending transaction that stays out of a block for longer than
SequencerConfig::stale_refund_slots(default 30 slots of the last irreversible block,0disables it) is recovered automatically when the SDK can sign it alone. Transactions frompublish,publish_atomic_withdraw, andpublish_pin_depositare re-funded, re-signed, and posted again with the same message ID. Multi-signature and custom transactions are shed instead and reported inorphaned(), together with everything chained on them, so you prepare them, collect signatures, and submit them again.
Step 3: Update the committee with a channel config op
Submit a channel config to add or remove sequencers from accredited_keys. Because a channel config 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},ops::channel::VerifiedChannelKeys,};let new_keys = VerifiedChannelKeys::from([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, // transfer_threshold).await?;channel_configfunds, signs with the local key only, and submits the transaction. On finalisation, the SDK picks up the new config and the nextBlocksProcessedevent recomputesour_turn_to_writefor every running sequencer. -
Use the
prepare_channel_config→sign_with→submit_channel_configflow whenconfiguration_threshold > 1. In this case, collecting signatures from other sequencers is the responsibility of the application rather than the Zone SDK.- The proposer calls
prepare_channel_configwith the new configuration. The SDK builds and funds the config transaction and returns aPreparedChannelConfig. - Each key holder inspects
prepared.tx()and signs withprepared.sign_with(&key), which returns anIndexedSignatureindexed against the channel's current accredited keys. A key holder without a running sequencer can callsign_prepared(&key, &prepared.accredited_keys, &prepared.sign_payload)instead. - The proposer gathers
prepared.signing_thresholdsignatures, the channel's currentconfiguration_threshold, intoIndexedSignaturesand submits them withsubmit_channel_config.
use lb_core::mantle::{channel::{SlotTimeframe, SlotTimeout},ops::channel::VerifiedChannelKeys,};use lb_zone_sdk::sequencer::{IndexedSignature, IndexedSignatures};// 1. Propose the new configuration. The SDK builds and funds the tx.let prepared = sequencer.handle().prepare_channel_config(VerifiedChannelKeys::from([admin_pk, sequencer_b_pk, sequencer_c_pk]),SlotTimeframe::from(60),SlotTimeout::from(180),2, // configuration_threshold1, // transfer_threshold).await?;// 2. Distribute `prepared` to the rest of the committee; each key holder// signs with `prepared.sign_with(&key)`. Transport is application-defined.let collected: Vec<IndexedSignature> = collect_signatures_from_committee(&prepared).await?;// 3. Gather the signatures, ordered by key index with at most one per key,// and submit.let signatures = IndexedSignatures::try_from_iter(collected.into_iter().map(Into::into))?;let (result, checkpoint) = sequencer.handle().submit_channel_config(prepared, signatures)?;- A signature authorises every channel operation in the prepared transaction, not only the config, so signers should inspect the whole of
prepared.tx()before signing. - A config position holds one pending continuation, so
prepare_channel_configrefuses a second config on a parent that already has one pending until it lands or expires. - An orphaned multi-signature config surfaces as
ChannelUpdateTx::Config. Recover it by running the prepare → collect signatures → submit flow again.
- The proposer calls