Build a dApp with the Logos Zone SDK
Learn how to use the Zone SDK to implement a decentralised password manager application.
This tutorial covers building a Logos Zone from scratch using the Zone SDK. Zones are execution environments built on the Logos Blockchain that post data as on-chain inscriptions via Logos channels. A Zone can host a versatile rollup with thousands of applications, such as the Logos Execution Zone, or a simple standalone Zone tracking the state of a single application.
The Zone SDK provides a ready-to-use toolbox for basic interactions with a Logos Zone. Every Zone is operated by one or more sequencers, which collect transactions, batch them, and publish them as inscriptions on the Logos Blockchain. Indexers are nodes that follow a Zone's updates on-chain, re-executing them locally to maintain an up-to-date copy of the Zone state. The Zone SDK supports building both.
The tutorial uses a password manager Zone as the running example: a single sequencer posts SQL transactions on-chain, and one or more indexers replay those transactions to sync their local state. The password manager maintains state in a SQLite database, with updates taking the form of SQL transactions applied to that database. The Zone design maps onto those concepts as follows:
- The user interacts with the password manager on the main device (in the
sequencerfolder) to add or update passwords. - The main device operates as a sequencer, posting SQL transactions as inscriptions to its channel.
- Secondary devices operate as indexers, following the channel and obtaining SQL transactions from the chain.
- Secondary devices run a read-only version of the password manager (in the
indexerfolder), applying SQL transactions from the channel to update their local state.
Read Decentralise the Log, Not the Server for the motivation behind this design.
The implementation skeleton already has most of the password manager code written. This tutorial will focus on using the Zone SDK to write the sequencer and indexer functionality, contained primarily in the sequencer/src/sequencer.rs and indexer/src/indexer.rs files.
Before you start:
- Git
- Rust toolchain (stable)
- Access to a running Logos Blockchain node (see documentation)
What to expect
- You will configure a
ZoneSequencerthat signs and publishes SQL transactions as on-chain inscriptions. - You will configure a
ZoneIndexerthat follows a channel and applies incoming inscriptions to a local database. - You will have a compilable password manager Zone demo that illustrates core Zone SDK patterns.
Set up the repository
Clone the Logos SQL Zone repository, which contains all dependencies and password manager code unrelated to Zone functionality.
-
Clone the repository:
git clone https://github.com/logos-blockchain/logos-sql-zone.git -
Switch to the
tutorialbranch and initialise submodules:cd logos-sql-zonegit checkout tutorialgit submodule update --init --recursiveThe complete demo is available in the
masterbranch.
Sequencer
The sequencer is the node that accepts user transactions, batches them, and posts them as inscriptions to the Logos Blockchain. The steps in this section implement the sequencer using the Zone SDK, using the password manager Zone as the example.
Step 1: Initialise the ZoneSequencer struct
Your sequencer wraps the ZoneSequencer struct from the Zone SDK, found in logos-blockchain/zone-sdk/src/sequencer/zone_sequencer.rs. Initialising it requires these arguments:
channel_id: ChannelId— the ID of the channel associated with the Zone.signing_key: Ed25519Key— a key authorised to post updates to the channel.node: Node— aNodestruct referring to your Logos Blockchain node, created byNodeHttpClient::new().checkpoint: Option<SequencerCheckpoint>— (optional) the checkpoint representing the most recently pushed channel update.
Before posting to a new channel, the sequencer must generate an Ed25519 public/private key pair. The initial public key defines the channel ID; the private key becomes the first authorised signing key. The channel is created when the sequencer posts a message with this channel ID, unless the channel already exists. Additional keys can be authorised via the CHANNEL_CONFIG Mantle Operation.
After the first channel message, each subsequent message includes a hash reference to the previous one. A message with an incorrect parent hash is rejected. Providing a checkpoint lets the sequencer resume posting after a restart. A checkpoint is not required during the session that creates a new channel.
-
In
sequencer/src/sequencer.rs, add theSequencerstruct definition:// The sequencer that handles transactions using the Zone SDK//// sequencer: The ZoneSequencer instance used by our wrapper// client: Async handle for submitting requests to Zone SDK sequencer// state: A helper struct for keeping track of transaction state,// see more on this below.// queue_file: The path to a file that holds SQL transactions not yet posted to the channel// checkpoint_path: The path to the channel checkpoint filepub struct Sequencer {sequencer: ZoneSequencer<NodeHttpClient>,client: SequencerClient,state: InMemoryZoneState,queue_file: String,checkpoint_path: String,} -
Add a function to load or generate a private signing key:
// Load signing key from file or generate a new one if it doesn't exist//// path: The path to the signing key filefn load_or_create_signing_key(path: &Path) -> Ed25519Key {if path.exists() {let key_bytes = fs::read(path).expect("failed to read key file");assert!(key_bytes.len() == ED25519_SECRET_KEY_SIZE,"invalid key file: expected {} bytes, got {}",ED25519_SECRET_KEY_SIZE,key_bytes.len());let key_array: [u8; ED25519_SECRET_KEY_SIZE] =key_bytes.try_into().expect("length already checked");Ed25519Key::from_bytes(&key_array)} else {let mut key_bytes = [0u8; ED25519_SECRET_KEY_SIZE];rand::RngCore::fill_bytes(&mut rand::rng(), &mut key_bytes);fs::write(path, key_bytes).expect("failed to write key file");Ed25519Key::from_bytes(&key_bytes)}} -
Add a function to restore from a saved checkpoint:
// Restore from saved checkpoint//// path: Path to checkpoint filefn load_checkpoint(path: &Path) -> Option<SequencerCheckpoint> {if !path.exists() {return None;}let data = fs::read(path).expect("failed to read checkpoint file");Some(serde_json::from_slice(&data).expect("failed to deserialize checkpoint"))} -
Add the
newfunction to theSequencerstruct implementation:impl Sequencer {// Create a new Sequencer//// node_endpoint: Address of Logos Blockchain node// signing_key_path: Path to file containing signing key// node_auth_username: Username to access node// node_auth_password: Password to access node// queue_file: Path to file storing queued SQL statements// checkpoint_path: Path to file containing latest channel checkpoint// channel_path: Path to file with channel IDpub async fn new(node_endpoint: &str,signing_key_path: &str,node_auth_username: Option<String>,node_auth_password: Option<String>,queue_file: &str,checkpoint_path: &str,channel_path: &str,) -> Result<Self> {let node_url = Url::parse(node_endpoint).map_err(|e| SequencerError::Url(e.to_string()))?;let basic_auth = node_auth_username.map(|username| BasicAuthCredentials::new(username, node_auth_password));// Create files from paths if they don't existfor path in [signing_key_path, checkpoint_path, channel_path] {if let Some(parent) = Path::new(path).parent() {fs::create_dir_all(parent)?;}}let checkpoint = load_checkpoint(Path::new(checkpoint_path));if checkpoint.is_some() {println!(" Restored checkpoint from {checkpoint_path}");}// Produce channel ID from signing keylet signing_key = load_or_create_signing_key(Path::new(signing_key_path));let channel_id = ChannelId::from(signing_key.public_key().to_bytes());fs::write(channel_path, hex::encode(channel_id.as_ref())).expect("failed to write channel id");// Initialise the ZoneSequencerlet node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), node_url);let sequencer = ZoneSequencer::init(channel_id, signing_key, node, checkpoint);let client = sequencer.client();Ok(Self {sequencer,client,state: InMemoryZoneState::default(),queue_file: queue_file.to_owned(),checkpoint_path: checkpoint_path.to_owned(),})}...}
Step 2: Publish data to the channel
Once the ZoneSequencer is set up, posting data to the channel is as simple as passing a byte vector to the sequencer's publish function, which returns an inscription ID and the current checkpoint.
Whenever the password manager's database is updated, the SQL transactions are also written to a queue file. This functionality is already implemented in the sequencer/src/db.rs file. A processing loop continuously checks for new SQL transactions from the password database and submits them as plain text to the Zone.
-
In
sequencer/src/sequencer.rs, add a function to save checkpoints after each published update:// Write latest checkpoint to file//// path: Path to checkpoint file// checkpoint: Checkpoint messagefn save_checkpoint(path: &Path, checkpoint: &SequencerCheckpoint) {let data = serde_json::to_vec(checkpoint).expect("failed to serialize checkpoint");fs::write(path, data).expect("failed to write checkpoint file");} -
Add a function to read pending SQL transactions from the queue file and clear it:
// Drain the queue file and return all pending queries//// queue_file: File for queued SQL statementsfn queue_drain(queue_file: &str) -> Result<Vec<String>> {// Check if queue_file is emptylet file = match OpenOptions::new().read(true).write(true).open(queue_file) {Ok(f) => f,Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),Err(e) => return Err(SequencerError::Io(e)),};file.lock_exclusive()?;let reader = BufReader::new(&file);let mut queue_vec = Vec::new();for query in reader.lines() {queue_vec.push(query?);}// Clear queue filefile.set_len(0)?;Ok(queue_vec)} -
Add a function to publish the pending SQL transactions as a single inscription:
// Process all pending queries as a single inscription//// queue_file: File for queued SQL statementsasync fn process_pending_batch(queue_file: &str, client: &SequencerClient) -> Result<()> {// Read list of pending SQL statementslet pending = queue_drain(queue_file)?;if pending.is_empty() {return Ok(());}let count = pending.len();debug!("Processing batch of {} queries", count);// Publish batch of statementslet sql_bytes = pending.join("\n").into_bytes();let inscription = Inscription::try_from(sql_bytes).map_err(|e| SequencerError::InscriptionTooLarge(e.to_string()))?;if let Err(e) = client.publish(inscription).await {error!("failed to publish batch: {e}");} else {info!("Submitted batch of {} statement(s)", count);}Ok(())} -
Add the
runfunction to theSequencerstruct implementation to poll the queue and publish its contents:impl Sequencer {...// Processing looppub async fn run(self) {let Self { mut sequencer, client, mut state, queue_file, checkpoint_path } = self;// Loop to check queue and publishlet batch_client = client;tokio::spawn(async move {// Wait until the sequencer completes cold-start backfill before publishing.let mut ready_rx = batch_client.subscribe_ready();drop(ready_rx.wait_for(|r| *r).await);let mut interval = tokio::time::interval(Duration::from_millis(100));loop {interval.tick().await;if let Err(e) = process_pending_batch(&queue_file, &batch_client).await {error!("Batch processing failed: {e}");}}});...}}
Step 3: Handle channel events
The sequencer must track the Zone state on the blockchain so it can detect reorgs, finalized blocks, and updates from other sequencers. In our example, there is only one sequencer, and therefore the state only has to track which inscriptions have finalized. The Zone SDK communicates these via Event:
Ready— the sequencer is caught up and ready to accept updates.BlocksProcessed— a new block was processed. Includes the latestcheckpointand the list offinalizedtransactions. It also carries achannel_update(adopted/orphanedinscriptions), but that is only relevant when several sequencers share a channel — a centralized Zone ignores it.MempoolPending— transaction was accepted by node API and is waiting in mempool.TurnNotification— the sequencer's turn to write in a turn-based decentralised sequencing scenario (not applicable to our example).
-
In
common/src/state.rs, add theInMemoryZoneStatestruct to track inscription IDs:// Keep track of Zone state in memory//// published: Inscriptions published by your sequencer but not yet finalised or orphaned// finalized: All finalised inscriptions#[derive(Default)]pub struct InMemoryZoneState {published: Vec<Msg>,finalized: Vec<Msg>,}impl ZoneState for InMemoryZoneState {fn on_published(&mut self, info: &InscriptionInfo) {self.published.push(Msg::from_payload(info.this_msg, &info.payload));}// Move our finalised publishes out of `published` and into `finalized`.fn on_finalized(&mut self, inscriptions: &[InscriptionInfo]) {for info in inscriptions {if let Some(i) = self.published.iter().position(|m| m.msg_id == info.this_msg){self.published.remove(i);}if !self.finalized.iter().any(|m| m.msg_id == info.this_msg) {self.finalized.push(Msg::from_payload(info.this_msg, &info.payload));}}}fn published(&self) -> &[Msg] {&self.published}fn finalized(&self) -> &[Msg] {&self.finalized}} -
In
sequencer/src/sequencer.rs, add a function to handle events:// Handle channel events//// event: Channel event enum// state: Zone state struct from common/src/state.rs// checkpoint_path: Path to checkpoint filefn handle_event(event: Event,state: &mut InMemoryZoneState,checkpoint_path: &str,) {match event {Event::Ready => {info!("Sequencer ready");}Event::BlocksProcessed { checkpoint, channel_update, finalized } => {// Add newly-finalized inscriptions to the finalized listlet inscriptions: Vec<_> = finalized.into_iter().flat_map(|tx| tx.ops.into_iter()).filter_map(|op| match op {FinalizedOp::Inscription(info) => Some(info),_ => None,}).collect();state.on_finalized(&inscriptions);save_checkpoint(Path::new(checkpoint_path), &checkpoint);}Event::MempoolPending(_) | Event::TurnNotification { .. } => {}}} -
Add an event loop to the
runfunction in theSequencerstruct:impl Sequencer {...// Processing looppub async fn run(self) {...loop {let event = sequencer.next_event().await;handle_event(event, &mut state, &checkpoint_path);}}}
Indexer
The indexer is the node that follows a Zone's channel on the Logos Blockchain and re-executes updates locally to maintain a current copy of the Zone state. The steps in this section implement the indexer using the Zone SDK, continuing the password manager example. The indexer must obtain the sequencer's channel ID via an out-of-band mechanism (in our case, the channel_path file) before it can start.
Step 1: Initialise the ZoneIndexer struct
Your indexer wraps the ZoneIndexer struct from the Zone SDK, found in logos-blockchain/zone-sdk/src/indexer.rs. Initialising it requires:
channel_id: ChannelId— the ID of the channel associated with the Zone.node: Node— aNodestruct referring to your Logos Blockchain node, created byNodeHttpClient::new().
-
In
indexer/src/indexer.rs, add theIndexerstruct definition:// Indexer struct//// zone_indexer: SDK indexer// db_path: Path to password database filepub struct Indexer {zone_indexer: ZoneIndexer<NodeHttpClient>,db_path: String,} -
Add a helper function to parse a channel ID from a hex string:
// Parse channel ID from string//// channel_id_str: channel IDfn parse_channel_id(channel_id_str: &str) -> Result<ChannelId> {// string to byteslet decoded = hex::decode(channel_id_str).map_err(|_| {Error::InvalidChannelId(format!("INDEXER_CHANNEL_ID must be a valid hex string, got: '{channel_id_str}'"))})?;// to 32 byteslet channel_bytes: [u8; 32] = decoded.try_into().map_err(|v: Vec<u8>| {Error::InvalidChannelId(format!("INDEXER_CHANNEL_ID must be exactly 64 hex characters (32 bytes), got {} characters ({} bytes)",v.len() * 2,v.len()))})?;Ok(ChannelId::from(channel_bytes))} -
Add the
newfunction to theIndexerstruct implementation:impl Indexer {// Create new indexer//// db_path: Path to local password db// node_endpoint: Address of Logos Blockchain node// channel_path: Path to file with channel ID// node_auth_username: Username to access node// node_auth_password: Password to access nodepub fn new(db_path: &str,node_endpoint: &str,channel_path: &str,node_auth_username: Option<String>,node_auth_password: Option<String>,) -> Result<Self> {let node_url = Url::parse(node_endpoint).map_err(|e| Error::Url(e.to_string()))?;let basic_auth = node_auth_username.map(|username| BasicAuthCredentials::new(username, node_auth_password));// Parse channel IDlet channel_id_str = fs::read_to_string(channel_path).map_err(|e| {Error::InvalidChannelId(format!("Failed to read channel path '{channel_path}': {e}"))})?;let channel_id = parse_channel_id(channel_id_str.trim())?;info!("Channel ID: {}", hex::encode(channel_id.as_ref()));// New ZoneIndexerlet node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), node_url);let zone_indexer = ZoneIndexer::new(channel_id, node);Ok(Self { zone_indexer, db_path: db_path.to_owned() })}...}
Step 2: Follow the channel
An indexer uses the follow function to check new blocks for channel updates. It returns a stream that can be iterated to obtain the latest messages. Once a block with a new channel message is finalised, the indexer applies the message to update its local state.
-
Add the
runfunction to theIndexerstruct implementation:impl Indexer {...// Follow the Zone channel & apply updatespub async fn run(self) {// Open database at db_pathlet db = match DatabaseReadOnly::open(&self.db_path) {Ok(db) => db,Err(e) => {error!("Failed to open database: {e}");return;}};loop {// Follow channel & create streaminfo!("Connecting to zone block stream...");let stream = match self.zone_indexer.follow().await {Ok(s) => s,Err(e) => {error!("Failed to connect to block stream: {e}");tokio::time::sleep(std::time::Duration::from_secs(5)).await;continue;}};info!("Connected to zone block stream");// Get next message from streamfutures::pin_mut!(stream);while let Some(zone_msg) = stream.next().await {// Ensure zone_block includes the inscriptions but not the deposit operations in the blocklet logos_blockchain_zone_sdk::ZoneMessage::Block(zone_block) = zone_msg else {continue;};let sql_text = match String::from_utf8(Vec::from(zone_block.data)) {Ok(s) => s,Err(e) => {error!("Zone block data is not valid UTF-8: {e}");continue;}};// Extract SQL statements from messagelet statements: Vec<&str> = sql_text.lines().map(|l: &str| l.trim().trim_end_matches(';').trim()).filter(|s: &&str| !s.is_empty()).collect();if statements.is_empty() {continue;}info!("Applying {} SQL statement(s)", statements.len());// Apply statements to dbfor stmt in &statements {if let Err(e) = db.execute_batch(stmt) {error!("Failed to execute SQL '{}': {e}", stmt);}}info!("Applied {} statement(s)", statements.len());}error!("Zone block stream ended, reconnecting...");tokio::time::sleep(std::time::Duration::from_secs(5)).await;}}}
Frequently asked questions
What other Zones can I build with the SDK?
This tutorial covered a simple single-sequencer appchain, but the Zone SDK supports a much wider range. You could build traditional ZK or optimistic rollups, high-throughput appchains, or applications compartmentalised across several Zones. Logos Zones also support interoperability features such as bridging Layer 1 tokens into Zones, on-chain message passing between Zones, and complex cross-Zone transaction coordination.
Where do I find instructions for running the demo?
Instructions for building and interacting with the sequencer and indexer applications are in the README.md file in the repository root.