Inscribe data on-chain using the Zone SDK
Learn how to write plain text as on-chain inscriptions using a simple Logos Zone.
Applications built on Logos are implemented in execution environments known as Zones, which post data inscriptions on-chain via Logos channels. A Zone could host a versatile rollup with thousands of applications, such as the Logos Execution Zone, or it could be a simple, standalone Zone tracking the state of just one application.
The Zone SDK is a ready-to-use toolbox that handles basic interactions with a Logos Zone. This tutorial shows how to create a simple Logos Zone that writes plain text as on-chain inscriptions, based on the TUI Zone demo.
You can try out a working version of the TUI Zone used in this tutorial directly from your Logos Blockchain node. Just run ./logos-blockchain-node inscribe.
Before you begin, you will need:
- A running Logos node
What to expect
- You will initialise a
ZoneSequencerthat connects to your Logos node and creates a new channel for posting inscriptions. - You will implement event handling so the sequencer tracks which of its inscriptions have finalised and keeps a checkpoint to resume from.
- You will publish plain-text messages as on-chain inscriptions and verify they are finalised in the channel.
Step 1: Clone the Logos Blockchain repository
Before you begin, clone the logos-blockchain repository. The tui-zone-tutorial branch contains a skeleton implementation of the TUI Zone with space to fill in the relevant code. A more comprehensive implementation, which also supports decentralised sequencing, can be found in the master branch.
-
Run the following in your desired path to clone the repository and switch to the tutorial branch:
git clone https://github.com/logos-blockchain/logos-blockchain.gitcd logos-blockchain/deployment/tui-zone# To follow along with the tutorialgit checkout tui-zone-tutorial
Step 2: Initialise the ZoneSequencer struct
Sequencer functionality is provided via the ZoneSequencer struct from the Zone SDK, found in logos-blockchain/zone-sdk/src/sequencer/zone_sequencer.rs. When initialising this struct, provide the following 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— a Node struct referring to your Logos Blockchain node, together with the credentials to access it. 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 first generate an Ed25519 public/private key pair. This initial public key defines the channel ID, while the private key becomes the first authorised signing key. The channel is created when the sequencer posts a message with this channel ID, unless it already exists. Initially, only the sequencer with the signing key can post messages to the channel. Additional keys can be authorised via the CHANNEL_CONFIG Mantle Operation.
After the first channel message, further messages include a hash reference to the previous message in the channel. If a message is posted with an incorrect parent hash, it is rejected. Providing a checkpoint lets the sequencer track the last message posted to its channel, allowing it to resume posting new updates after restarting. A checkpoint is not necessary for the session during which a sequencer creates a new channel.
-
Add a function to create a new private signing key in
src/lib.rs:// 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::thread_rng(), &mut key_bytes);fs::write(path, key_bytes).expect("failed to write key file");Ed25519Key::from_bytes(&key_bytes)}} -
Fill in the
run()function insrc/lib.rswith the following code that creates a channel with an associated sequencer:// Processing loop//// args: Setup infopub async fn run(args: InscribeArgs) {// Get node URLlet node_url: Url = args.node_url.parse().expect("invalid node URL");// Create new signing key or load existing one from pathlet signing_key = load_or_create_signing_key(Path::new(&args.key_path));// Derive channel IDlet channel_id = ChannelId::from(signing_key.public_key().to_bytes());println!("TUI Zone Sequencer");println!(" Node: {node_url}");println!(" Key: {}", args.key_path);println!(" Channel ID: {}", hex::encode(channel_id.as_ref()));println!();// Create initial Zone state & load checkpoint (to be implemented later in the tutorial)let mut state = InMemoryZoneState::default();let checkpoint = state.load_checkpoint().cloned();// Connect to nodelet node = NodeHttpClient::new(CommonHttpClient::new(None), node_url);// Initialise ZoneSequencerlet mut sequencer = ZoneSequencer::init(channel_id, signing_key, node, checkpoint);// Wait to start reading from stdin until readylet (ready_tx, ready_rx) = tokio::sync::oneshot::channel();let mut stdin_rx = spawn_stdin_reader(ready_rx);let mut ready_tx = Some(ready_tx);...}
Step 3: Handle channel events
For a centralized Zone — a single sequencer that owns its channel — event handling is minimal. There are no competing sequencers, and a single sequencer never loses a slot, so the sequencer only needs to track which of its inscriptions have finalised and keep a checkpoint so it can resume after a restart. It learns about both through the Zone SDK's Event stream.
The status of the sequencer's backfill process, transactions sent by the sequencer, and any updates to the Zone state are communicated via the Zone SDK's Event. These events are:
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
src/state.rs, add the following struct to track Zone state in memory and maintain the checkpoint:// Keep track of Zone state in memory//// published: Inscriptions published by your sequencer, not yet finalised// finalized: All finalised inscriptions// checkpoint: Last message in Zone state — lets the sequencer resume after a restart#[derive(Default)]pub struct InMemoryZoneState {published: Vec<Msg>,finalized: Vec<Msg>,checkpoint: Option<SequencerCheckpoint>,}impl InMemoryZoneState {// Record a tx we just published locally, so the local view stays in// sync with what the SDK accepted. Called at the publish-call site.pub 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));}}}pub fn published(&self) -> &[Msg] {&self.published}pub fn finalized(&self) -> &[Msg] {&self.finalized}pub fn save_checkpoint(&mut self, checkpoint: SequencerCheckpoint) {self.checkpoint = Some(checkpoint);}pub fn load_checkpoint(&self) -> Option<&SequencerCheckpoint> {self.checkpoint.as_ref()}} -
Back in
src/lib.rs, add a function to handle when the sequencer has finished bootstrapping:// Defines initial post-bootstrapping behaviour//// state: Zone state// ready_tx: The transmitter of the ready event to ready_rxfn handle_ready(state: &InMemoryZoneState,ready_tx: &mut Option<tokio::sync::oneshot::Sender<()>>,) {info!("Sequencer ready");if let Some(tx) = ready_tx.take() {let _ = tx.send(());}println!("Ready.");println!();println!("Type a message and press Enter to publish.");println!("Press Ctrl-D or type an empty line to exit.");println!();ui::render_state(state);ui::prompt();} -
Add a function to apply finalised messages to the Zone state:
// Apply finalised messages from chain to Zone state//// items: Finalised transactions// state: Zone statefn apply_finalized(items: &[FinalizedTx], state: &mut InMemoryZoneState) {// TUI only cares about inscriptions for rendering; deposit / withdraw// ops have no inscription payload.let inscriptions: Vec<InscriptionInfo> = items.iter().flat_map(|t| t.ops.iter()).filter_map(|op| match op {FinalizedOp::Inscription(i) => Some(i.clone()),FinalizedOp::Deposit(_) | FinalizedOp::Withdraw(_) => None,}).collect();state.on_finalized(&inscriptions);ui::render_state(state);ui::prompt();} -
Add a function to handle sequencer events and execute whichever of the above handlers is necessary for the current event:
// Handle sequencer events//// event: current sequencer eventfn handle_event(event: Event,state: &mut InMemoryZoneState,ready_tx: &mut Option<tokio::sync::oneshot::Sender<()>>,) {match event {Event::Ready => handle_ready(state, ready_tx),// Centralized Zone: ignore `channel_update` — a single sequencer// never loses a slot, so there is nothing to adopt or roll back.// Just apply finalised inscriptions and persist the checkpoint.Event::BlocksProcessed {checkpoint, finalized, ..} => {if !finalized.is_empty() {apply_finalized(&finalized, state);}state.save_checkpoint(checkpoint);}Event::MempoolPending(_) | Event::TurnNotification { .. } => {}}} -
Add the processing loop to
run()to watch for events:pub async fn run(args: InscribeArgs) {...loop {tokio::select! {// Watch for eventsevent = sequencer.next_event() => {handle_event(event, &mut state, &mut ready_tx);}...// Ctrl+C exits the loop_ = tokio::signal::ctrl_c() => {println!();break;}}}println!("Goodbye!");}
Step 4: Publish data
Once the ZoneSequencer is set up, posting data to the channel is as easy as passing a vector of bytes to the sequencer handler's publish function. Once the transaction is submitted to the Zone SDK, this function returns a struct consisting of the inscription (message) ID and the current checkpoint.
-
Add the following code to process user input text in the
runfunction insrc/lib.rs:pub async fn run(args: InscribeArgs) {...loop {tokio::select! {...// Get input text from stdininput = stdin_rx.recv() => {// Handle unexpected inputlet Some(text) = input else {println!();break;};// Turn input into AppMessage wrapper. Includes unique ID for each transaction to avoid removing 'duplicate' messages// from mempoollet msg = AppMessage::new(text);debug!(tx_uuid = %msg.tx_uuid, text = %msg.text, "Publishing message");// publish() takes bytes anyway, so must convertlet Ok(inscription) = Inscription::try_from(msg.to_bytes()) else {error!("Message is too large to fit in an inscription");continue;};// Publish datamatch sequencer.handle().publish(inscription) {// Get result and checkpoint, update Zone stateOk((result, checkpoint)) => {let info = result.tx.inscription();debug!(msg_id = %hex::encode(info.this_msg.as_ref()), "Published");state.on_published(info);state.save_checkpoint(checkpoint);ui::render_state(&state);eprintln!(" \x1b[90mpending...\x1b[0m");ui::prompt();}Err(lb_zone_sdk::sequencer::Error::Unavailable { reason }) => {warn!("publish rejected: {reason}");eprintln!(" \x1b[33msequencer is still starting up, try again in a moment\x1b[0m");ui::prompt();}Err(e) => {error!("failed to publish: {e}");break;}}}...}}...}
Step 5: Build and run the sequencer
-
With the code filled in, build and run the sequencer from the
deployment/tui-zonedirectory. The binary is namedtui-sequencer:cargo run --bin tui-sequencer -- --node-url <Your Node URL>The sequencer connects to your node, derives the channel ID from your signing key, and prints
Ready.once it has bootstrapped. Type a message and press Enter to publish it as an on-chain inscription.Each message first appears under Published (pending). Once your node finalises the block containing it, the message moves to Finalized — confirming the inscription is on-chain. Press Ctrl-D or enter an empty line to exit.