Emit and query LEZ program events
Emit typed events from an LEZ program and read them back from an LEZ Indexer by program, selector, transaction or block range.
This document is accurate for Testnet v0.3.
A Logos Execution Zone (LEZ) program can emit typed events while it runs: a deposit, a swap, a delivery, or anything else off-chain consumers should see without re-deriving it from state changes. An LEZ Indexer stores the events it is configured to keep and serves them over JSON-RPC with exact-match filtering. Events are the basis for explorers, bots, bridges and any service that reacts to on-chain activity.
This procedure uses the bridge program's Deposit event in the LEZ repository as its example. It walks through how a program defines and emits an event, how an indexer decides which events to keep, and how a client queries and streams them. It is for program developers who want to expose activity, and for developers building services that consume it.
Events are not stored in blocks and no hash commits to them. Re-executing a block produces them again, so any party holding the chain can re-derive the same event stream.
- A local clone of the LEZ repository at
v0.3.2, to read the example code. - A running LEZ Indexer. The indexer serves JSON-RPC on
http://localhost:8779by default. curlandjq. To stream events, a WebSocket client such aswebsocat.- An understanding of how LEZ programs update account state. It is also helpful to have gone through Write an LEZ program that updates an account via
planandapply.
What to expect
- You can define an event type with a stable selector and emit it from a program's
planfunction. - You can configure an LEZ Indexer to keep all events, or only the programs and selectors you care about.
- You can query stored events with
getEventsand stream new ones withsubscribeToEvents.
Step 1: Define an event type and its selector
An event is defined as a ProgramEvent { selector: [u8; 8], data: Vec<u8> }. The selector here tells consumers which event type the data holds. By convention, it is the first 8 bytes of sha256("<program>::<EventName>"), so consumers can compute it without a registry. Nothing enforces the convention at runtime.
-
Open
lez/programs/bridge/core/src/event.rs. It defines the event as a struct that derives theborshtraits, pins the selector as a constant next to the name it is derived from, and provides byte conversions:#[derive(Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]pub struct Deposit {pub l1_deposit_op_id: [u8; 32],pub recipient_id: AccountId,pub amount: u64,}impl Deposit {pub const SELECTOR: [u8; 8] = [0xcd, 0x49, 0x9a, 0xe5, 0x48, 0xcd, 0xf2, 0x3d];pub const SELECTOR_NAME: &str = "bridge::Deposit";pub fn to_bytes(&self) -> Vec<u8> {borsh::to_vec(self).expect("Deposit serializes")}pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result<Self> {borsh::from_slice(bytes)}}Put the event types in a
corecrate that both the program and its off-chain consumers depend on, so both sides decode the same layout.tipTo compute a selector from the command line, run
printf 'bridge::Deposit' | sha256sum | cut -c1-16. The output here iscd499ae548cdf23d, the same hex string you use in queries.
Step 2: Emit the event from plan
Events are declared in a program's plan function, the state-blind entrypoint that also declares the program's effects and chained calls.
-
Open
lez/programs/bridge/src/main.rsand find the end ofplan. After declaring its effect and its chained transfer, the bridge emits the deposit:plan.event(ProgramEvent {selector: bridge_core::event::Deposit::SELECTOR,data: bridge_core::event::Deposit {l1_deposit_op_id,recipient_id,amount,}.to_bytes(),});planplan.event(..)adds the event toPlanOutput.events. It changes no state. -
Note how the runtime records the event. When the transaction executes, the runtime stamps each event with the emitting program's account ID and stores it as a
TransactionEvent { account_id, event }. A program cannot emit an event that appears to come from another program. Within a transaction:- Events come out in emission order, and events from chained calls follow depth-first pre-order.
- An event from a chained call is attributed to the program that was called, not to its caller.
- Events are grouped per transaction as
TxEvents { tx_index, tx_hash, events }.
Events are a public-execution feature. A privacy-preserving transaction drops its events to protect function privacy, so a program run privately emits nothing an indexer can read.
Step 3: Configure which events the indexer keeps
Each indexer decides at ingest which events to store, through the event_filter key in its configuration file, such as lez/indexer/service/configs/debug/indexer_config.json. Events the filter drops are never written.
-
Check the
event_filtervalue in your indexer configuration. Choose one of the following:-
Keep every event. The shipped configurations use this:
"event_filter": "archival" -
Keep only declared programs. Each source names a program by its base58 account ID, with an optional list of hex selectors. A source without
selectorskeeps all of that program's events:"event_filter": {"sources": [{ "program_id": "<program-account-id>" },{ "program_id": "<other-program-account-id>", "selectors": ["cd499ae548cdf23d"] }]}Replace
<program-account-id>and<other-program-account-id>with the base58 account IDs of the programs to watch. -
If you omit
event_filter, the indexer keeps nothing, prints a warning at startup, and rejects every event query. A source with an emptyselectorslist, or a program listed twice, fails startup.
-
-
Restart the indexer after changing the filter. The indexer records each filter change as a history segment that starts at the next block, so it knows which block ranges each filter covered. A query that asks for events a past filter did not keep is rejected with
UncoveredEventQuery, rather than returning a silently incomplete result.
Step 4: Query events with getEvents
getEvents takes one filter object. With tx_hash set, it returns the events of that transaction. Otherwise from_block is required and to_block defaults to the indexer's tip.
The indexer only ingests finalised blocks, so an event becomes available to query once its block finalises.
-
Query the bridge's
Depositevents from block 1 onwards:curl -s -X POST http://localhost:8779 \-H "Content-Type: application/json" \-d '{"jsonrpc":"2.0","id":1,"method":"getEvents","params":[{"from_block":0,"to_block":999,"selector":"cd499ae548cdf23d"}]}' | jq .warningAlways set
to_block. Omitted, it defaults to the last block the indexer has ingested, which breaks the 1,000-block span cap as soon as the chain passes block 1,000. Read longer histories in windows of 1,000 blocks.Expected result:
resultis an array of event records, empty if no deposit has finalised in the range. Each record has these fields:Field Description block_idThe block that contains the transaction. tx_indexThe transaction's position in the block. tx_hashThe transaction hash. program_account_idThe program that emitted the event, as stamped by the runtime. selectorThe event selector, as hex. dataThe event data, base64-encoded. The filter fields are:
Field Description from_blockFirst block to search. Required unless tx_hashis set.to_blockLast block to search. Defaults to the indexer's tip. A range may span at most 1,000 blocks. tx_hashReturn only this transaction's events. Takes precedence over the block range. program_account_idReturn only events from this program. selectorReturn only events with this hex selector. - Filters are exact-match and conjunctive: a record must match every field you set.
- A response is capped at 8 MiB. Narrow the range or the filter if a query exceeds it.
- With a
sourcesfilter configured, includeprogram_account_id, andselectorwhere the source lists selectors, so the indexer can confirm it kept everything you ask for.
-
Decode an event's
datawith the event type from Step 1. ADepositis Borsh-encoded as 32 bytes ofl1_deposit_op_id, 32 bytes ofrecipient_id, thenamountas a little-endianu64:use base64::prelude::*;let bytes = BASE64_STANDARD.decode(&record.data)?;let deposit = bridge_core::event::Deposit::from_bytes(&bytes)?; -
Fetch the events of a single transaction by its hash. Replace
<tx-hash>with atx_hashfrom a previous result:curl -s -X POST http://localhost:8779 \-H "Content-Type: application/json" \-d '{"jsonrpc":"2.0","id":1,"method":"getEvents","params":[{"tx_hash":"<tx-hash>"}]}' | jq .- The full method schema, including every filter field, is available from the indexer's
getSchemamethod.
- The full method schema, including every filter field, is available from the indexer's
Step 5: Stream new events with subscribeToEvents
subscribeToEvents takes the same filter without the block range, and pushes each matching event record as it is ingested.
-
Open a WebSocket connection to the indexer and subscribe to
Depositevents:websocat ws://localhost:8779Then send:
{"jsonrpc":"2.0","id":1,"method":"subscribeToEvents","params":[{"selector":"cd499ae548cdf23d"}]}Expected result: the indexer replies with a subscription ID, then sends one notification per matching event record as new blocks finalise.
-
To read events from C or another language with C bindings, use the FFI instead of RPC. The indexer FFI exposes
query_events(indexer, from_block, to_block, tx_hash, program_account_id, selector)inlez/indexer/ffi/src/api/query.rs, and the sequencer FFI exposessequencer_ffi_query_eventswith the same filter fields and span cap.from_blockis required andto_blockis an optional value, not a pointer; pass a null pointer for any oftx_hash,program_account_idandselectoryou do not set, and free the returned vector when you are done.
Troubleshooting
Every query fails with UncoveredEventQuery
The indexer's filter did not keep the events you asked for, for some or all of the requested range. This includes an indexer with no event_filter set, which keeps nothing. Set "event_filter": "archival", or add the program and selector to sources, and restart. Blocks ingested before the change remain uncovered.
Queries return an empty array for recent transactions
The block holding the transaction has not finalised yet, or the transaction ran privately. Wait for finalisation and check that the program ran on the public execution path.
A query fails with from_block ... exceeds the indexed tip or block span ... exceeds the maximum
The requested range is not one the indexer can serve. from_block must not be greater than the last block ingested, and the range may span at most 1,000 blocks, counting both ends. An omitted to_block defaults to that last block, so it trips the span cap on any chain past block 1,000. Set both bounds and read longer histories in windows of 1,000 blocks.
A subscription returns an ID but no notifications arrive
No new blocks are finalising on the chain the indexer follows, so nothing new is ingested. Check the indexer's progress with getLastFinalizedBlockId, as in Run an LEZ Indexer.