Bridge assets between Logos Blockchain and a Zone
Move tokens between Logos Blockchain and a Zone using channels and the Zone SDK.
Logos Zones are customizable, high-performance blockchains for applications built on Logos. Logos Blockchain notes can be bridged from Bedrock to Zones, with the Zone's associated channel maintaining a token balance that keeps track of the total token value stored in the Zone.
This procedure covers creating a channel, depositing notes from the Blockchain into a Zone, and withdrawing notes from a Zone back to the Blockchain, using the Zone SDK's ZoneSequencer. It applies to Zone developers building sequencers and indexers; the Zone itself defines how the channel balance maps to its internal accounts, while the SDK only surfaces the on-chain events.
Bridging has two directions: deposit (Blockchain to Zone), where a user funds a channel and the Zone sequencer credits the user internally (ChannelDeposit), and withdraw (Zone to Blockchain), where the sequencer submits a signed ChannelWithdraw to debit the channel and mint fresh notes on-chain.
The Zone SDK currently supports the bundled withdrawal API only for single-sequencer Zones (ChannelState.withdraw_threshold == 1). Multi-sequencer Zones require building the threshold proof manually, as covered in a later step.
Before you start, make sure you have:
- A running Logos Blockchain node reachable over HTTP
- The Zone SDK (
lb_zone_sdk) andlb_corecrates added to your project - Completed the Zone SDK inscription tutorial
What to expect
- You can create a channel and have it recognized on-chain without a separate deployment transaction.
- You can observe finalized deposits on your channel as they are credited inside the zone.
- You can submit single- or multi-signature withdrawals and confirm them once finalized, including after a reorg.
Step 1: Create a channel
A channel is created automatically the first time an operation references a previously unseen ChannelId.
-
Generate a
Ed25519Keyfor your sequencer. The private key is used to sign inscriptions and channel operations, while the public key defines theChannelId. An example implementation is shown below:// Generate Ed25519 key pairlet mut key_bytes = [0u8; ED25519_SECRET_KEY_SIZE];rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut key_bytes);std::fs::write(&your_key_file_path, key_bytes).expect("failed to write key file");let signing_key = Ed25519Key::from_bytes(&key_bytes);// Derive channel IDlet channel_id = ChannelId::from(signing_key.public_key().to_bytes());The bridging-relevant fields on
ChannelStatein the Mantle specification:Field Purpose balanceOn-chain TokenValue held by the channel. Deposits add, withdrawals subtract. Defaults to 0. withdrawal_nonceIncrements by 1 on every successful withdraw. Provides replay protection. withdraw_thresholdMinimum number of accredited-key signatures needed to authorize a withdrawal. Defaults to 0. accredited_keysThe committee that may sign withdrawals. -
Initialize a
ZoneSequencerand publish the first inscription inside your event loop onceEvent::Readyhas fired. The channel is created on-chain automatically, naming this sequencer's key as the sole accredited key.use lb_zone_sdk::{CommonHttpClient, adapter::NodeHttpClient, sequencer::ZoneSequencer,};// Connect to the Logos Blockchain node.let node = NodeHttpClient::new(CommonHttpClient::new(None),"http://localhost:8080".parse()?,);// Initialize the sequencer for this channellet mut sequencer = ZoneSequencer::init(channel_id, signing_key, node, None);// Inside the event loop, once `Event::Ready` has fired:// Publishing the first inscription creates the channel just-in-time.let (result, checkpoint) = sequencer.handle().publish(genesis_zone_block)?;publishreturns synchronously after enqueueing the transaction into the sequencer's pending set; the post reaches the node the next time the event loop pollsnext_event. Persist the returnedPublishResultandSequencerCheckpointinto your outbox. -
(Optional) Reconfigure the channel by calling
sequencer.handle().channel_config(..)with aChannelConfigoperation.
Step 2: Observe deposits from Bedrock
A deposit happens when a Bedrock user submits a transaction with a ChannelDeposit operation naming the target channel, the consumed inputs, and opaque metadata that the Zone interprets, such as a recipient address.
-
Watch for finalized deposits inside your events loop. The Zone SDK surfaces every finalized deposit on your channel as a
FinalizedOp::Deposit(DepositInfo)inside thefinalizedfield ofEvent::BlocksProcessed.use lb_zone_sdk::sequencer::{Event, FinalizedOp};if let Event::BlocksProcessed { finalized, .. } = event {// Iterate over every finalized transaction in this batch of blocks.for tx in finalized {for op in tx.ops {if let FinalizedOp::Deposit(deposit) = op {// Credit the user inside the Zone according to the Zone's internal rules.println!("Deposit of {} with metadata {:?}",deposit.amount, deposit.metadata,);}}}}Event::BlocksProcessedfires once per ingested block, whether live or backfilled, and only carries finalized items at or below LIB, so deposits surfaced here cannot be re-orged off the chain. -
Credit the user inside the Zone according to the Zone's internal state transition function rules.
Step 3: Submit a withdrawal
The process of withdrawing funds is different for single-sequencer Zones and multiple-sequencer Zones with a withdraw threshold greater than one.
Option 1: Submit a single-sequencer withdrawal
A withdrawal is initiated inside the zone and lands on-chain as a signed ChannelWithdraw operation. This step applies only when ChannelState.withdraw_threshold == 1.
-
Describe the withdrawal by building a
WithdrawArgwith the recipientOutputs. The SDK fills in thechannel_idand reads the currentwithdraw_nonceand accredited sequencer key.use lb_core::mantle::{Note, ledger::Outputs};use lb_zone_sdk::sequencer::WithdrawArg;// Describe what to withdraw: a single note to the recipient.let withdraw = WithdrawArg {outputs: Outputs::new([Note::new(50, recipient_pk)]),}; -
Submit the inscription bundled with the withdraw by calling
sequencer.handle().publish_atomic_withdraw(..).// Inside the drive task: submit the inscription bundled with the withdraw.let (result, checkpoint) = sequencer.handle().publish_atomic_withdraw(inscription_payload, // the Zone block this withdraw goes withvec![withdraw],)?;Because the inscription and the withdrawal share one transaction, they become adopted, orphaned, or finalized as a unit, so the Zone block recording the withdrawal and the on-chain debit cannot drift apart.
infopublish_atomic_withdrawreturns thePublishResultsynchronously. For a single-signature bundle,PublishResult.txis aPendingTx::AtomicWithdraw(AtomicWithdrawInfo)carrying the inscription and the bundled withdraw operations. -
Check the finalized transactions in
Event::BlocksProcessed.finalizedagainst the pending transaction'sAtomicWithdrawInfo.tx_hash. Because it is a bundle, both the inscription and the withdrawal will appear in the sametx.opsonce the withdrawal is finalized.use lb_zone_sdk::sequencer::{Event, FinalizedOp};if let Event::BlocksProcessed { finalized, .. } = event {for tx in finalized {for op in tx.ops {match op {FinalizedOp::Inscription(info) => {// The Zone block carried with the withdrawal.println!("Inscribed {:?} in tx {:?}", info.this_msg, info.tx_hash);// Check if info.tx_hash matches the pending tx_hash}FinalizedOp::Withdraw(withdrawal) => {// The on-chain debit.println!("Withdrawn {:?} in tx {:?}", withdrawal.op, withdrawal.tx_hash);}FinalizedOp::Deposit(_) => {}}}}}
Option 2: Submit a multi-sequencer withdrawal
When withdraw_threshold > 1, no single sequencer can authorize a withdrawal alone. The proposing sequencer builds the ChannelWithdrawOp directly, because it must commit to a specific withdraw_nonce before sharing the unsigned transaction with the rest of the committee.
-
Read the current
withdraw_nonceand this sequencer's accredited-key index from the channel view.use lb_core::mantle::{Op, SignedMantleTx,ops::{OpProof, channel::withdraw::ChannelWithdrawOp},};use lb_core::proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature};// 1. Read current nonce + this sequencer's accredited-key index from// the channel view.let view = sequencer.subscribe_channel_view().borrow().clone();let withdraw_nonce = view.channel.as_ref().ok_or("channel state not yet available")?.withdrawal_nonce;let own_key_index = view.own_key_index.ok_or("not an accredited key")?; -
Build the unsigned transaction and obtain this sequencer's own signature by calling
handle.prepare_tx(ops, inscription).// 2. Build the unsigned tx and get this sequencer's own signature back.let withdraw = ChannelWithdrawOp {channel_id,outputs,withdraw_nonce,};let (tx, msg_id, own_sig) = sequencer.handle().prepare_tx([Op::ChannelWithdraw(withdraw)].into(),inscription_payload,)?; -
Share the unsigned transaction with the other accredited signers and collect their
IndexedSignatures. Each signer callshandle.sign_tx(&tx)on the transaction the first sequencer proposes.infoDefining the committee transport - how proposals and signatures are exchanged - is outside the Zone SDK's scope.
// 3. Hand `tx` to the other accredited signers and 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?; -
Assemble the threshold proof and submit it once you have gathered
ChannelState.withdraw_thresholdsignatures, by callinghandle.submit_signed_tx(signed_tx, msg_id).// 4. Assemble the threshold proof and submit.let withdraw_proof = ChannelMultiSigProof::new(signatures)?;let signed_tx = SignedMantleTx::new(tx,vec![OpProof::ChannelMultiSigProof(withdraw_proof),OpProof::Ed25519Sig(own_sig),],)?;let (result, checkpoint) = sequencer.handle().submit_signed_tx(signed_tx, msg_id)?;Keep the result and use the returned
tx_hashto identify the bundle. Unlike the single-sig flow, the SDK treats the caller-built transaction as opaque, soPublishResult.txisPendingTx::Inscription(InscriptionInfo)regardless of the underlying ops. -
Match the finalized transaction by
tx_hashonce it appears inEvent::BlocksProcessed.finalized, the same way as in the single-signature flow in Option 1.
Step 4: Recover from a reorg
A reorg can orphan the parent inscription of a withdrawal submitted via publish_atomic_withdraw, which invalidates the original signed transaction.
-
Watch for orphaned transactions in the
channel_updatefield ofEvent::BlocksProcessed. The abandoned transaction appears inchannel_update.orphaned. -
Reconstruct the original
WithdrawArgs from the orphaned bundle and re-callpublish_atomic_withdrawwith the same inscription payload; the SDK refills the inscription parent and thewithdraw_noncefrom the current on-chain state.use lb_zone_sdk::sequencer::{Event, OrphanedTx, WithdrawArg};if let Event::BlocksProcessed { channel_update, .. } = event {for tx in channel_update.orphaned {if let OrphanedTx::AtomicWithdraw(info) = tx {// Rebuild the withdraw args from the orphaned bundle.let withdraws = info.withdraws.into_iter().map(|w| WithdrawArg { outputs: w.op.outputs }).collect();// Republish with the same inscription payload; the SDK// refreshes the parent and withdraw_nonce automatically.let (result, checkpoint) = sequencer.handle().publish_atomic_withdraw(info.inscription.payload,withdraws,)?;// Keep `result` + `checkpoint` exactly as on the original publish.}}} -
Keep the new
resultandcheckpointexactly as you did for the original publish to compare it to
Frequently asked questions
Does the reorg recovery path work for multi-sequencer withdrawals?
No. The reorg-aware recovery path described in Step 5 is not supported for multi-sig withdrawals at the moment and is planned for a future release.