|
| 1 | +# Following the Cardano Leios testnet |
| 2 | + |
| 3 | +This example connects a node to the public Cardano **Leios** ("Musashi Dojo") |
| 4 | +testnet and follows its endorsement layer live: it negotiates a Leios-capable |
| 5 | +handshake, runs ordinary Praos chain-sync, and reacts to **Endorser Block (EB)** |
| 6 | +notifications by fetching their bodies and transactions over Pallas's |
| 7 | +[`pallas-network2`](../../pallas-network2) stack. |
| 8 | + |
| 9 | +It's a small, single-file program (`src/main.rs`, ~290 lines) meant to be read |
| 10 | +top-to-bottom. By the end of this tutorial you'll understand: |
| 11 | + |
| 12 | +- what the Leios overlay is and how it rides on top of Praos; |
| 13 | +- how a single handshake version "turns Leios on"; |
| 14 | +- the `leios-notify` (server-push) vs `leios-fetch` (client-pull) split; |
| 15 | +- how to drive `pallas-network2`'s `Manager` / `InitiatorBehavior` event loop. |
| 16 | + |
| 17 | +## What is Leios? |
| 18 | + |
| 19 | +[Leios (CIP-0164)](https://cips.cardano.org/cip/CIP-0164) is an overlay on top |
| 20 | +of Ouroboros Praos. Alongside the normal Praos chain, block producers diffuse |
| 21 | +**Endorser Blocks** — compact lists of *transaction references* — which are then |
| 22 | +voted on and certified back into a ranking block. The goal is higher throughput: |
| 23 | +transactions get endorsed in parallel with normal block production. |
| 24 | + |
| 25 | +Pallas speaks this overlay through two node-to-node mini-protocols that ride the |
| 26 | +**same** TCP connection as Praos, once a Leios-capable handshake version is |
| 27 | +negotiated: |
| 28 | + |
| 29 | +| Mini-protocol | Direction | What it carries | Surfaced as | |
| 30 | +| -------------- | -------------- | ------------------------------------------------------- | ------------------------ | |
| 31 | +| `leios-notify` | server → push | announces/offers new EBs and their txs, diffuses votes | `InitiatorEvent::EbNotification` | |
| 32 | +| `leios-fetch` | client → pull | request an EB body, or a subset of its transactions | `InitiatorEvent::EbFetched` | |
| 33 | + |
| 34 | +The interaction is a notify-then-fetch loop: the relay *tells you* what it has, |
| 35 | +and you *pull* the pieces you want. |
| 36 | + |
| 37 | +``` |
| 38 | +relay ──leios-notify──▶ BlockOffer(eb) "I have EB e" |
| 39 | + you ──leios-fetch───▶ FetchEb(e) "send me its body" |
| 40 | +relay ──leios-fetch───▶ Block(body) body = { tx_hash => size } map |
| 41 | + you (remember tx count) |
| 42 | +relay ──leios-notify──▶ BlockTxsOffer(eb) "I can serve e's transactions" |
| 43 | + you ──leios-fetch───▶ FetchEbTxs(e, bitmap) "send me these txs" |
| 44 | +relay ──leios-fetch───▶ BlockTxs { txs } the actual transactions |
| 45 | +``` |
| 46 | + |
| 47 | +## Prerequisites |
| 48 | + |
| 49 | +- A Rust toolchain matching the workspace `rust-version`. |
| 50 | +- Network access to the public Leios relay. |
| 51 | + |
| 52 | +> **Heads up — this is a throwaway devnet.** The Musashi Dojo testnet is |
| 53 | +> continuously reset. If the connection is refused or sync stalls, the relay |
| 54 | +> address, network magic, or intersection point in `src/main.rs` may simply be |
| 55 | +> stale. Check the |
| 56 | +> [Leios testnet getting-started guide](https://leios.cardano-scaling.org/docs/testnet/getting-started/) |
| 57 | +> for current values. |
| 58 | +
|
| 59 | +## Running it |
| 60 | + |
| 61 | +From the repository root: |
| 62 | + |
| 63 | +```sh |
| 64 | +RUST_LOG=info cargo run -p leios-testnet |
| 65 | +``` |
| 66 | + |
| 67 | +There are no command-line arguments — everything is configured by the constants |
| 68 | +at the top of `src/main.rs` (see [Configuration](#configuration)). For more |
| 69 | +detail, including per-transaction fetch logging and otherwise-unhandled events, |
| 70 | +bump the log level: |
| 71 | + |
| 72 | +```sh |
| 73 | +RUST_LOG=debug cargo run -p leios-testnet |
| 74 | +``` |
| 75 | + |
| 76 | +## Reading the output |
| 77 | + |
| 78 | +A healthy run prints something like this (abridged). Each line corresponds |
| 79 | +directly to an event handled in `src/main.rs`: |
| 80 | + |
| 81 | +``` |
| 82 | +INFO connecting to Leios testnet relay="leios-node.play.dev.cardano.org:3001" magic=164 |
| 83 | +INFO peer initialized pid=... version=15 leios=true |
| 84 | +INFO intersection found pid=... point=Specific(2812236, ...) tip=... |
| 85 | +INFO header received pid=... variant=... tip_block=... |
| 86 | +INFO EB offered → fetching body pid=... eb=2812240@9d8a... size=1234 |
| 87 | +INFO EB body fetched pid=... eb=2812240@9d8a... bytes=1234 txs=42 |
| 88 | +INFO txs offered → fetching pid=... eb=2812240@9d8a... want=42 total=42 |
| 89 | +INFO EB transactions fetched pid=... eb=2812240@9d8a... count=42 bytes=98765 |
| 90 | +INFO votes received pid=... count=7 |
| 91 | +``` |
| 92 | + |
| 93 | +What to look for: |
| 94 | + |
| 95 | +- **`peer initialized ... leios=true`** — the handshake negotiated a |
| 96 | + Leios-capable version. If you see `leios=false` (plus a warning), the peer |
| 97 | + spoke a pre-Leios version and **no EBs will be diffused**. |
| 98 | +- **`intersection found` / `header received`** — ordinary Praos chain-sync, |
| 99 | + running underneath the overlay. |
| 100 | +- **`EB offered` → `EB body fetched` → `txs offered` → `EB transactions |
| 101 | + fetched`** — the notify-then-fetch loop in action. |
| 102 | +- **`votes received`** — Leios votes diffused inline over `leios-notify`. |
| 103 | + |
| 104 | +## How it works |
| 105 | + |
| 106 | +Everything lives in the `LeiosNode` struct in `src/main.rs`. |
| 107 | + |
| 108 | +### 1. Turning Leios on (the handshake) |
| 109 | + |
| 110 | +This is the only thing that "enables" Leios. The default `InitiatorBehavior` |
| 111 | +proposes only a mainnet v13 handshake, which does **not** carry the overlay. The |
| 112 | +example swaps in a version table that proposes v11–v15 with the testnet's |
| 113 | +network magic, so the peer can negotiate v15 (`LEIOS_MIN_VERSION`, the Dijkstra |
| 114 | +era) and bring up `leios-notify` / `leios-fetch`: |
| 115 | + |
| 116 | +```rust |
| 117 | +let behavior = InitiatorBehavior { |
| 118 | + handshake: HandshakeBehavior::new(HandshakeConfig { |
| 119 | + supported_version: VersionTable::v11_and_above_with_query( |
| 120 | + LEIOS_TESTNET_MAGIC, // 164 |
| 121 | + false, |
| 122 | + ), |
| 123 | + }), |
| 124 | + ..Default::default() // chain-sync, block-fetch, keepalive stay at defaults |
| 125 | +}; |
| 126 | +``` |
| 127 | + |
| 128 | +### 2. The event loop |
| 129 | + |
| 130 | +`tick()` uses `tokio::select!` to multiplex two sources: a 3-second housekeeping |
| 131 | +timer (which drives `InitiatorCommand::Housekeeping`, keeping the protocols |
| 132 | +pumping) and the network's event stream (`Manager::poll_next`). Every event is |
| 133 | +dispatched to `handle_event`: |
| 134 | + |
| 135 | +```rust |
| 136 | +select! { |
| 137 | + _ = self.housekeeping_interval.tick() => { |
| 138 | + self.network.execute(InitiatorCommand::Housekeeping); |
| 139 | + } |
| 140 | + evt = self.network.poll_next() => { |
| 141 | + if let Some(evt) = evt { self.handle_event(evt); } |
| 142 | + } |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +### 3. Praos chain-sync, underneath |
| 147 | + |
| 148 | +`IntersectionFound`, `BlockHeaderReceived`, and `RollbackReceived` are ordinary |
| 149 | +Praos events. The example just logs them and asks for more with |
| 150 | +`InitiatorCommand::ContinueSync`. Note that chain-sync is started near the tip |
| 151 | +(via an intersection point) rather than from origin — the Leios overlay diffuses |
| 152 | +EBs over the same connection regardless of where you are in chain-sync. |
| 153 | + |
| 154 | +### 4. Reacting to notifications (`handle_notification`) |
| 155 | + |
| 156 | +This is the heart of the example. Each `leios-notify` notification triggers the |
| 157 | +appropriate `leios-fetch` pull: |
| 158 | + |
| 159 | +- **`BlockOffer(eb_id, size)`** → `InitiatorCommand::FetchEb(pid, eb_id)`. We |
| 160 | + pull the body first, because the body tells us how many transactions the EB |
| 161 | + has — which we need to request them correctly later. |
| 162 | +- **`BlockTxsOffer(eb_id)`** → `InitiatorCommand::FetchEbTxs(pid, eb_id, bitmap)`, |
| 163 | + but **only** if we already fetched that EB's body (so we know its tx count). |
| 164 | + The transactions are selected with a bitmap, capped at `MAX_TXS_PER_FETCH` |
| 165 | + (64) per request: |
| 166 | + |
| 167 | + ```rust |
| 168 | + let want = n.min(MAX_TXS_PER_FETCH); |
| 169 | + self.network.execute(InitiatorCommand::FetchEbTxs( |
| 170 | + pid, eb_id, leiosfetch::Bitmaps::all(want), |
| 171 | + )); |
| 172 | + ``` |
| 173 | + |
| 174 | + Two subtleties worth understanding: |
| 175 | + - We only request transactions a peer has **offered**. Requesting txs a peer |
| 176 | + hasn't offered makes the prototype relay reset the connection. |
| 177 | + - Each request is bounded to one 64-tx bitmap window. Asking for a whole large |
| 178 | + EB at once can exceed the relay's per-response limits; a real client would |
| 179 | + page across windows. |
| 180 | + |
| 181 | +- **`BlockAnnouncement(raw)`** and **`Votes(votes)`** are simply logged. |
| 182 | + |
| 183 | +### 5. Sizing the transaction request |
| 184 | + |
| 185 | +How do we know how many transactions an EB has? The EB body is a CBOR |
| 186 | +`{ tx_hash => size }` map, so the number of entries *is* the transaction count. |
| 187 | +`eb_tx_count` decodes the map header (handling both definite- and |
| 188 | +indefinite-length encodings) and the result is stashed in the `eb_tx_counts` |
| 189 | +map, keyed by `EbId`, so it's ready when the peer later offers the transactions. |
| 190 | + |
| 191 | +## Configuration |
| 192 | + |
| 193 | +All knobs are constants at the top of `src/main.rs`. Because the testnet resets |
| 194 | +periodically, expect to update these from time to time: |
| 195 | + |
| 196 | +| Constant | Default | When to change | |
| 197 | +| ---------------------- | -------------------------------------- | ----------------------------------------------------------- | |
| 198 | +| `LEIOS_RELAY` | `leios-node.play.dev.cardano.org:3001` | Connection refused / relay moved — check the docs. | |
| 199 | +| `LEIOS_TESTNET_MAGIC` | `164` | If the testnet's network magic changes. | |
| 200 | +| `INTERSECT_SLOT` / `INTERSECT_HASH` | slot `2812236`, hash `9d8a43aa…` | Sync stalls / intersection not found — use a current point. | |
| 201 | +| `MAX_TXS_PER_FETCH` | `64` | Tune how many txs to pull per `leios-fetch` request. | |
| 202 | + |
| 203 | +## Troubleshooting |
| 204 | + |
| 205 | +- **Connection refused** — the relay address is likely stale (the devnet was |
| 206 | + reset). Get the current address from the |
| 207 | + [getting-started guide](https://leios.cardano-scaling.org/docs/testnet/getting-started/) |
| 208 | + and update `LEIOS_RELAY`. |
| 209 | +- **Sync stalls / "intersection not found"** — the chain was reset past your |
| 210 | + `INTERSECT_SLOT`/`INTERSECT_HASH`. Replace them with a current point. |
| 211 | +- **`peer negotiated a pre-Leios version`** — you connected, but the peer only |
| 212 | + speaks pre-v15; no EBs will be diffused. Confirm you're hitting a Leios relay. |
| 213 | + |
| 214 | +## Going further |
| 215 | + |
| 216 | +- The mini-protocol types live in |
| 217 | + [`pallas_network2::protocol::{leiosnotify, leiosfetch}`](../../pallas-network2/src/protocol); |
| 218 | + the initiator event loop is in |
| 219 | + [`pallas_network2::behavior::initiator`](../../pallas-network2/src/behavior). |
| 220 | +- Ideas to extend this example: persist fetched EBs and transactions, page |
| 221 | + across **all** transaction windows of a large EB (not just the first 64), |
| 222 | + decode and follow the diffused votes, or connect to multiple relays at once. |
| 223 | +- Background reading: |
| 224 | + [CIP-0164](https://cips.cardano.org/cip/CIP-0164) and the |
| 225 | + [Leios testnet docs](https://leios.cardano-scaling.org/docs/testnet/getting-started/). |
0 commit comments