|
| 1 | +//! Connect a node to the Cardano **Leios** ("Musashi Dojo") testnet and follow |
| 2 | +//! its endorsement layer. |
| 3 | +//! |
| 4 | +//! Leios (CIP-0164) is an overlay on top of Ouroboros Praos: alongside the |
| 5 | +//! normal Praos chain, producers diffuse **Endorser Blocks (EBs)** — compact |
| 6 | +//! lists of transaction references — which are then voted on and certified back |
| 7 | +//! into a ranking block. Pallas speaks this overlay through two node-to-node |
| 8 | +//! mini-protocols that ride the *same* connection as Praos once a Leios-capable |
| 9 | +//! handshake version is negotiated: |
| 10 | +//! |
| 11 | +//! - **leios-notify** (server-push): the relay announces / offers new EBs and |
| 12 | +//! their transactions, and diffuses full votes inline. Surfaced as |
| 13 | +//! [`InitiatorEvent::EbNotification`]. |
| 14 | +//! - **leios-fetch** (client-pull): we request the EB body or a subset of its |
| 15 | +//! transactions. Surfaced as [`InitiatorEvent::EbFetched`]. |
| 16 | +//! |
| 17 | +//! The only thing that "turns Leios on" is the handshake: we must propose an |
| 18 | +//! N2N version `>= LEIOS_MIN_VERSION` (v15, the Dijkstra era) with the testnet's |
| 19 | +//! network magic. The default [`InitiatorBehavior`] only proposes a mainnet v13 |
| 20 | +//! handshake, so this example swaps in a v15-capable version table. |
| 21 | +//! |
| 22 | +//! Run with: |
| 23 | +//! |
| 24 | +//! ```sh |
| 25 | +//! RUST_LOG=info cargo run -p leios-testnet |
| 26 | +//! ``` |
| 27 | +
|
| 28 | +use std::collections::HashMap; |
| 29 | +use std::time::Duration; |
| 30 | + |
| 31 | +use pallas_codec::minicbor::{Decoder, data::Type}; |
| 32 | +use pallas_network2::{ |
| 33 | + Manager, PeerId, |
| 34 | + behavior::{ |
| 35 | + AnyMessage, |
| 36 | + initiator::{ |
| 37 | + Config as HandshakeConfig, HandshakeBehavior, InitiatorBehavior, InitiatorCommand, |
| 38 | + InitiatorEvent, |
| 39 | + }, |
| 40 | + }, |
| 41 | + interface::TcpInterface, |
| 42 | + protocol::{ |
| 43 | + EbId, Point, |
| 44 | + handshake::n2n::{LEIOS_MIN_VERSION, VersionTable}, |
| 45 | + leiosfetch, leiosnotify, |
| 46 | + }, |
| 47 | +}; |
| 48 | +use tokio::{select, time::Interval}; |
| 49 | + |
| 50 | +/// Public bootstrap relay for the Leios "Musashi Dojo" testnet. |
| 51 | +/// |
| 52 | +/// This is a throwaway, continuously-resetting devnet — if the connection is |
| 53 | +/// refused, check <https://leios.cardano-scaling.org/docs/testnet/getting-started/> |
| 54 | +/// for the current relay address and network magic. |
| 55 | +const LEIOS_RELAY: &str = "leios-node.play.dev.cardano.org:3001"; |
| 56 | + |
| 57 | +/// Network magic for the Leios testnet (a nod to CIP-0164). |
| 58 | +const LEIOS_TESTNET_MAGIC: u64 = 164; |
| 59 | + |
| 60 | +/// Chain-sync intersection point to start following from, so we sync near the |
| 61 | +/// tip instead of replaying the chain from origin. |
| 62 | +/// |
| 63 | +/// The Musashi testnet resets periodically; if sync stalls or the intersection |
| 64 | +/// is not found, replace this with a current point from the chain. |
| 65 | +const INTERSECT_SLOT: u64 = 2724123; |
| 66 | +const INTERSECT_HASH: &str = "daea7e4b9754244bceb713b269771756450d2fe19ee551f7cb24ba65fa841aeb"; |
| 67 | + |
| 68 | +/// How many of an EB's transactions to fetch per request. We only request txs a |
| 69 | +/// peer has offered (so it holds the closure), and bound each request to one |
| 70 | +/// 64-tx bitmap window — requesting a whole large EB at once can exceed the |
| 71 | +/// relay's per-response limits. A real client pages across windows as needed. |
| 72 | +const MAX_TXS_PER_FETCH: usize = 64; |
| 73 | + |
| 74 | +struct LeiosNode { |
| 75 | + network: Manager<TcpInterface<AnyMessage>, InitiatorBehavior, AnyMessage>, |
| 76 | + housekeeping_interval: Interval, |
| 77 | + /// Transaction count per EB, learned by decoding the EB body. Used to size a |
| 78 | + /// correct tx bitmap when the peer later offers that EB's transactions. |
| 79 | + eb_tx_counts: HashMap<EbId, usize>, |
| 80 | +} |
| 81 | + |
| 82 | +impl LeiosNode { |
| 83 | + fn new() -> Self { |
| 84 | + let interface = TcpInterface::new(); |
| 85 | + |
| 86 | + // The default behavior only proposes a mainnet v13 handshake, which does |
| 87 | + // not carry the Leios overlay. Swap in a version table that proposes |
| 88 | + // v11..=v15 with the testnet magic so the peer can negotiate v15 and |
| 89 | + // enable leios-notify / leios-fetch. The rest of the behavior (chain-sync, |
| 90 | + // block-fetch, keepalive, ...) is left at its defaults. |
| 91 | + let behavior = InitiatorBehavior { |
| 92 | + handshake: HandshakeBehavior::new(HandshakeConfig { |
| 93 | + supported_version: VersionTable::v11_and_above_with_query( |
| 94 | + LEIOS_TESTNET_MAGIC, |
| 95 | + false, |
| 96 | + ), |
| 97 | + }), |
| 98 | + ..Default::default() |
| 99 | + }; |
| 100 | + |
| 101 | + let network = Manager::new(interface, behavior); |
| 102 | + |
| 103 | + Self { |
| 104 | + network, |
| 105 | + housekeeping_interval: tokio::time::interval(Duration::from_secs(3)), |
| 106 | + eb_tx_counts: HashMap::new(), |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + fn handle_event(&mut self, event: InitiatorEvent) { |
| 111 | + match event { |
| 112 | + InitiatorEvent::PeerInitialized(pid, (version, _data)) => { |
| 113 | + let leios = version >= LEIOS_MIN_VERSION; |
| 114 | + tracing::info!(%pid, version, leios, "peer initialized"); |
| 115 | + if !leios { |
| 116 | + tracing::warn!( |
| 117 | + %pid, |
| 118 | + version, |
| 119 | + min = LEIOS_MIN_VERSION, |
| 120 | + "peer negotiated a pre-Leios version; no EBs will be diffused" |
| 121 | + ); |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + // --- Praos chain-sync (runs underneath Leios) --- |
| 126 | + InitiatorEvent::IntersectionFound(pid, point, tip) => { |
| 127 | + tracing::info!(%pid, ?point, tip = %fmt_eb(&tip.0), "intersection found"); |
| 128 | + self.network.execute(InitiatorCommand::ContinueSync(pid)); |
| 129 | + } |
| 130 | + InitiatorEvent::BlockHeaderReceived(pid, header, tip) => { |
| 131 | + tracing::info!(%pid, variant = header.variant, tip_block = tip.1, "header received"); |
| 132 | + self.network.execute(InitiatorCommand::ContinueSync(pid)); |
| 133 | + } |
| 134 | + InitiatorEvent::RollbackReceived(pid, point, tip) => { |
| 135 | + tracing::warn!(%pid, ?point, tip_block = tip.1, "rollback received"); |
| 136 | + self.network.execute(InitiatorCommand::ContinueSync(pid)); |
| 137 | + } |
| 138 | + |
| 139 | + // --- Leios: server-pushed announcements / offers (leios-notify) --- |
| 140 | + InitiatorEvent::EbNotification(pid, notification) => { |
| 141 | + self.handle_notification(pid, notification); |
| 142 | + } |
| 143 | + |
| 144 | + // --- Leios: pulled bodies / txs (leios-fetch) --- |
| 145 | + InitiatorEvent::EbFetched(pid, eb, response) => match response { |
| 146 | + leiosfetch::Response::Block(body) => { |
| 147 | + // The EB body is a `{ tx_hash => size }` map; its entry count |
| 148 | + // is the EB's transaction count. Remember it so we can size a |
| 149 | + // correct tx bitmap once the peer offers the transactions. |
| 150 | + let n = eb_tx_count(body.raw_bytes()); |
| 151 | + tracing::info!(%pid, eb = %fmt_eb(&eb), bytes = body.raw_bytes().len(), txs = n, "EB body fetched"); |
| 152 | + self.eb_tx_counts.insert(eb, n); |
| 153 | + } |
| 154 | + leiosfetch::Response::BlockTxs { txs } => { |
| 155 | + let total: usize = txs.iter().map(|tx| tx.raw_bytes().len()).sum(); |
| 156 | + tracing::info!(%pid, eb = %fmt_eb(&eb), count = txs.len(), bytes = total, "EB transactions fetched"); |
| 157 | + for (i, tx) in txs.iter().enumerate() { |
| 158 | + tracing::debug!(%pid, index = i, bytes = tx.raw_bytes().len(), " tx"); |
| 159 | + } |
| 160 | + } |
| 161 | + }, |
| 162 | + |
| 163 | + other => { |
| 164 | + tracing::debug!(?other, "unhandled event"); |
| 165 | + } |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + fn handle_notification(&mut self, pid: PeerId, notification: leiosnotify::Notification) { |
| 170 | + match notification { |
| 171 | + leiosnotify::Notification::BlockOffer(eb_id, size) => { |
| 172 | + tracing::info!(%pid, eb = %fmt_eb(&eb_id), size, "EB offered → fetching body"); |
| 173 | + // Pull the body to learn the EB's tx count; we fetch the txs |
| 174 | + // later, when the peer offers them (`BlockTxsOffer`). |
| 175 | + self.network.execute(InitiatorCommand::FetchEb(pid, eb_id)); |
| 176 | + } |
| 177 | + leiosnotify::Notification::BlockAnnouncement(raw) => { |
| 178 | + tracing::info!(%pid, bytes = raw.raw_bytes().len(), "EB announced"); |
| 179 | + } |
| 180 | + leiosnotify::Notification::BlockTxsOffer(eb_id) => { |
| 181 | + // The peer signals it holds this EB's transaction closure, so it |
| 182 | + // can serve a BlockTxsRequest. Requesting txs a peer has NOT |
| 183 | + // offered makes the prototype relay reset the connection, so we |
| 184 | + // only fetch in response to this offer, sized from the body's tx |
| 185 | + // count (learned when we fetched the body). |
| 186 | + match self.eb_tx_counts.get(&eb_id).copied() { |
| 187 | + Some(n) if n > 0 => { |
| 188 | + let want = n.min(MAX_TXS_PER_FETCH); |
| 189 | + tracing::info!(%pid, eb = %fmt_eb(&eb_id), want, total = n, "txs offered → fetching"); |
| 190 | + self.network.execute(InitiatorCommand::FetchEbTxs( |
| 191 | + pid, |
| 192 | + eb_id, |
| 193 | + leiosfetch::Bitmaps::all(want), |
| 194 | + )); |
| 195 | + } |
| 196 | + _ => { |
| 197 | + tracing::info!(%pid, eb = %fmt_eb(&eb_id), "txs offered (body not yet fetched)"); |
| 198 | + } |
| 199 | + } |
| 200 | + } |
| 201 | + leiosnotify::Notification::Votes(votes) => { |
| 202 | + tracing::info!(%pid, count = votes.len(), "votes received"); |
| 203 | + } |
| 204 | + } |
| 205 | + } |
| 206 | + |
| 207 | + async fn tick(&mut self) { |
| 208 | + select! { |
| 209 | + _ = self.housekeeping_interval.tick() => { |
| 210 | + tracing::debug!("housekeeping tick"); |
| 211 | + self.network.execute(InitiatorCommand::Housekeeping); |
| 212 | + } |
| 213 | + evt = self.network.poll_next() => { |
| 214 | + if let Some(evt) = evt { |
| 215 | + self.handle_event(evt); |
| 216 | + } |
| 217 | + } |
| 218 | + } |
| 219 | + } |
| 220 | + |
| 221 | + async fn run_forever(&mut self) { |
| 222 | + loop { |
| 223 | + self.tick().await; |
| 224 | + } |
| 225 | + } |
| 226 | +} |
| 227 | + |
| 228 | +/// Formats an EB reference (`[slot, hash]`) for logging. |
| 229 | +fn fmt_eb(eb: &Point) -> String { |
| 230 | + match eb { |
| 231 | + Point::Origin => "origin".to_string(), |
| 232 | + Point::Specific(slot, hash) => format!("{slot}@{}", hex::encode(hash)), |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +/// Counts the transactions in an EB body, which is a `{ tx_hash => size }` CBOR |
| 237 | +/// map — the number of entries is the transaction count. |
| 238 | +fn eb_tx_count(body: &[u8]) -> usize { |
| 239 | + let mut d = Decoder::new(body); |
| 240 | + match d.map() { |
| 241 | + Ok(Some(n)) => n as usize, |
| 242 | + // Indefinite-length map: count key/value pairs until the break marker. |
| 243 | + Ok(None) => { |
| 244 | + let mut n = 0; |
| 245 | + while !matches!(d.datatype(), Ok(Type::Break)) { |
| 246 | + if d.skip().is_err() || d.skip().is_err() { |
| 247 | + break; |
| 248 | + } |
| 249 | + n += 1; |
| 250 | + } |
| 251 | + n |
| 252 | + } |
| 253 | + Err(_) => 0, |
| 254 | + } |
| 255 | +} |
| 256 | + |
| 257 | +#[tokio::main] |
| 258 | +async fn main() { |
| 259 | + tracing_subscriber::fmt() |
| 260 | + .with_env_filter( |
| 261 | + tracing_subscriber::EnvFilter::try_from_default_env() |
| 262 | + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), |
| 263 | + ) |
| 264 | + .init(); |
| 265 | + |
| 266 | + let mut node = LeiosNode::new(); |
| 267 | + |
| 268 | + let peer = LEIOS_RELAY |
| 269 | + .parse() |
| 270 | + .expect("LEIOS_RELAY should be a valid host:port"); |
| 271 | + |
| 272 | + tracing::info!( |
| 273 | + relay = LEIOS_RELAY, |
| 274 | + magic = LEIOS_TESTNET_MAGIC, |
| 275 | + "connecting to Leios testnet" |
| 276 | + ); |
| 277 | + |
| 278 | + node.network.execute(InitiatorCommand::IncludePeer(peer)); |
| 279 | + // Start chain-sync near the tip (not origin) so we follow live blocks without |
| 280 | + // replaying the whole chain. The Leios overlay diffuses EBs over the same |
| 281 | + // connection independently of where we are in chain-sync. |
| 282 | + let intersect = Point::Specific( |
| 283 | + INTERSECT_SLOT, |
| 284 | + hex::decode(INTERSECT_HASH).expect("INTERSECT_HASH should be valid hex"), |
| 285 | + ); |
| 286 | + node.network |
| 287 | + .execute(InitiatorCommand::StartSync(vec![intersect])); |
| 288 | + |
| 289 | + node.run_forever().await; |
| 290 | +} |
0 commit comments