Skip to content

Commit 1038c66

Browse files
authored
feat(network2): implement Leios mini-protocols (LeiosNotify, LeiosFetch) (#788)
1 parent 7ffae5c commit 1038c66

25 files changed

Lines changed: 2019 additions & 4 deletions

File tree

.gitmodules

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
[submodule "cardano-blueprint"]
22
path = cardano-blueprint
33
url = https://github.com/cardano-scaling/cardano-blueprint.git
4+
branch = leios-prototype

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ members = [
3232
"examples/p2p-discovery",
3333
"examples/p2p-responder",
3434
"examples/p2p-initiator",
35+
"examples/leios-testnet",
3536
]
3637

3738
[workspace.dependencies]

cardano-blueprint

Submodule cardano-blueprint updated 75 files

examples/leios-testnet/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "leios-testnet"
3+
version = "0.1.0"
4+
edition.workspace = true
5+
rust-version.workspace = true
6+
publish = false
7+
8+
[package.metadata.release]
9+
release = false
10+
11+
[dependencies]
12+
pallas-network2 = { path = "../../pallas-network2" }
13+
pallas-codec = { path = "../../pallas-codec" }
14+
hex = "0.4.3"
15+
tokio = { version = "1.27.0", features = ["rt-multi-thread", "macros", "time"] }
16+
tracing = "0.1.41"
17+
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

examples/leios-testnet/src/main.rs

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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+
}

examples/p2p-discovery/src/node.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ impl<I: Interface<AnyMessage>> MyNode<I> {
143143
InitiatorEvent::TxRequested(pid, _) => {
144144
tracing::info!(%pid, "tx requested");
145145
}
146+
InitiatorEvent::EbNotification(pid, _) => {
147+
tracing::info!(%pid, "leios notification received");
148+
}
149+
InitiatorEvent::EbFetched(pid, _, _) => {
150+
tracing::info!(%pid, "leios fetch received");
151+
}
146152
}
147153

148154
self.enqueue_next_cmds();

examples/p2p-responder/src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ impl MockResponderNode {
116116
ResponderEvent::TxReceived(pid, _tx) => {
117117
tracing::info!(%pid, "tx received");
118118
}
119+
ResponderEvent::EbNotificationRequested(pid) => {
120+
tracing::info!(%pid, "leios notification requested");
121+
}
122+
ResponderEvent::EbRequested(pid, _) => {
123+
tracing::info!(%pid, "leios eb requested");
124+
}
125+
ResponderEvent::EbTxsRequested(pid, _, _) => {
126+
tracing::info!(%pid, "leios eb txs requested");
127+
}
119128
}
120129
}
121130

pallas-codec/src/utils.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,13 @@ pub struct AnyCbor {
12331233
}
12341234

12351235
impl AnyCbor {
1236+
/// Wraps already-encoded CBOR bytes verbatim, without re-encoding. Use this
1237+
/// to embed a pre-serialized CBOR item; contrast with [`AnyCbor::from_encode`],
1238+
/// which encodes a value into CBOR.
1239+
pub fn from_raw_bytes(inner: Vec<u8>) -> Self {
1240+
Self { inner }
1241+
}
1242+
12361243
pub fn raw_bytes(&self) -> &[u8] {
12371244
&self.inner
12381245
}
@@ -1257,6 +1264,13 @@ impl AnyCbor {
12571264
}
12581265
}
12591266

1267+
impl From<Vec<u8>> for AnyCbor {
1268+
/// Wraps already-encoded CBOR bytes verbatim (see [`AnyCbor::from_raw_bytes`]).
1269+
fn from(inner: Vec<u8>) -> Self {
1270+
Self { inner }
1271+
}
1272+
}
1273+
12601274
impl Deref for AnyCbor {
12611275
type Target = Vec<u8>;
12621276

pallas-network2/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ byteorder.workspace = true
2424
socket2.workspace = true
2525
thiserror.workspace = true
2626
hex.workspace = true
27+
cddl = { version = "0.10", optional = true }
2728

2829
[features]
2930
emulation = ["tokio", "rand"]
3031
default = ["emulation"]
31-
blueprint = []
32+
blueprint = ["dep:cddl"]
3233

3334
[dev-dependencies]
3435
tokio = { version = "1.45.1", features = ["full", "test-util"] }

0 commit comments

Comments
 (0)