Skip to main content

thornode_pulse_wire/
frame.rs

1//! Frame and datagram codecs for Pulse wire v2.
2
3pub const MAX_FULL_TX_BODY: usize = 1 << 16; // 64 KiB cap
4
5/// Wire protocol version carried by the stream preamble.
6pub const WIRE_VERSION: u8 = crate::protocol::WIRE_VERSION as u8;
7
8/// Written once at the head of every full-tx unidirectional stream, before any
9/// frame. `b"PLS2"` then version then a reserved flags byte.
10///
11/// This exists because a per-frame version byte cannot work: byte 0 of a v1
12/// body is `slot.to_le_bytes()[0]`, which takes all 256 values roughly every
13/// 100 seconds. A v1 stream's first byte, by contrast, is always `0x00` — the
14/// high byte of a u32 big-endian length prefix on a frame capped at 64 KiB —
15/// so a non-zero magic is unambiguous.
16pub const PREAMBLE: &[u8; 6] = b"PLS2\x02\x00";
17
18// ---- frame message types ---------------------------------------------------
19
20pub const MSG_TX: u8 = 1;
21pub const MSG_HEARTBEAT: u8 = 2;
22/// Wire v2 assigns this message type to shed notices but does not emit them.
23pub const MSG_SHED: u8 = 3;
24
25// ---- frame flags (per-frame booleans, NOT a TLV presence bitmap) ------------
26
27/// The ALT address set on this frame may be incomplete.
28pub const FLAG_ALT_INCOMPLETE: u8 = 0x01;
29
30// ---- TLV types -------------------------------------------------------------
31
32pub const TLV_LOADED_WRITABLE: u8 = 1;
33pub const TLV_LOADED_READONLY: u8 = 2;
34pub const TLV_SERVER_TS_MS: u8 = 3;
35pub const TLV_HIGHEST_SEQ: u8 = 4;
36
37#[derive(Debug, PartialEq, Eq)]
38pub struct FrameError;
39
40/// Appends one `u8 type | u16 LE len | value` record.
41///
42/// `len` is `u16` because a loaded-address list is 32 bytes per address and
43/// ALT-heavy transactions load dozens; a `u8` length would cap at 7.
44pub fn put_tlv(buf: &mut Vec<u8>, t: u8, value: &[u8]) {
45    debug_assert!(value.len() <= u16::MAX as usize);
46    buf.push(t);
47    buf.extend_from_slice(&(value.len() as u16).to_le_bytes());
48    buf.extend_from_slice(value);
49}
50
51/// Parses a TLV trailer to the end of `src`.
52///
53/// Unknown types are returned to the caller rather than rejected — skipping
54/// them is what makes new fields additive. A duplicate type IS rejected:
55/// silently preferring first or last is the kind of ambiguity that produces two
56/// implementations which disagree.
57pub fn parse_tlvs(src: &[u8]) -> Result<Vec<(u8, &[u8])>, FrameError> {
58    let mut out: Vec<(u8, &[u8])> = Vec::new();
59    let mut off = 0usize;
60    while off < src.len() {
61        let t = *src.get(off).ok_or(FrameError)?;
62        let lo = *src.get(off + 1).ok_or(FrameError)?;
63        let hi = *src.get(off + 2).ok_or(FrameError)?;
64        let len = u16::from_le_bytes([lo, hi]) as usize;
65        let start = off.checked_add(3).ok_or(FrameError)?;
66        let end = start.checked_add(len).ok_or(FrameError)?;
67        let value = src.get(start..end).ok_or(FrameError)?;
68        if out.iter().any(|(seen, _)| *seen == t) {
69            return Err(FrameError);
70        }
71        out.push((t, value));
72        off = end;
73    }
74    Ok(out)
75}
76
77#[derive(Debug, Default, Clone, PartialEq, Eq)]
78pub struct FullInstruction {
79    pub program_id_index: u32,
80    pub accounts: Vec<u8>,
81    pub data: Vec<u8>,
82}
83
84#[derive(Debug, Default, Clone, PartialEq, Eq)]
85pub struct FullAtl {
86    pub account_key: [u8; 32],
87    pub writable_indexes: Vec<u8>,
88    pub readonly_indexes: Vec<u8>,
89}
90
91#[derive(Debug, Default, Clone, PartialEq, Eq)]
92pub struct FullTx {
93    pub slot: u64,
94    pub versioned: bool,
95    pub num_required_signatures: u32,
96    pub num_readonly_signed_accounts: u32,
97    pub num_readonly_unsigned_accounts: u32,
98    pub recent_blockhash: [u8; 32],
99    pub signatures: Vec<[u8; 64]>,
100    pub account_keys: Vec<[u8; 32]>,
101    pub instructions: Vec<FullInstruction>,
102    pub address_table_lookups: Vec<FullAtl>,
103}
104
105/// Encodes a FullTx into the length-delimited binary body (the reliable-stream
106/// writer prepends a u32 length).
107pub fn encode_full_tx(ft: &FullTx) -> Vec<u8> {
108    let mut b = Vec::with_capacity(256);
109    b.extend_from_slice(&ft.slot.to_le_bytes());
110    b.push(ft.num_required_signatures as u8);
111    b.push(ft.num_readonly_signed_accounts as u8);
112    b.push(ft.num_readonly_unsigned_accounts as u8);
113    b.push(ft.versioned as u8);
114    b.extend_from_slice(&ft.recent_blockhash);
115
116    put_u16(&mut b, ft.signatures.len());
117    for s in &ft.signatures {
118        b.extend_from_slice(s);
119    }
120    put_u16(&mut b, ft.account_keys.len());
121    for k in &ft.account_keys {
122        b.extend_from_slice(k);
123    }
124    put_u16(&mut b, ft.instructions.len());
125    for ix in &ft.instructions {
126        b.push(ix.program_id_index as u8);
127        put_u16(&mut b, ix.accounts.len());
128        b.extend_from_slice(&ix.accounts);
129        put_u16(&mut b, ix.data.len());
130        b.extend_from_slice(&ix.data);
131    }
132    put_u16(&mut b, ft.address_table_lookups.len());
133    for l in &ft.address_table_lookups {
134        b.extend_from_slice(&l.account_key);
135        put_u16(&mut b, l.writable_indexes.len());
136        b.extend_from_slice(&l.writable_indexes);
137        put_u16(&mut b, l.readonly_indexes.len());
138        b.extend_from_slice(&l.readonly_indexes);
139    }
140    b
141}
142
143/// Decodes a FullTx. Strict: bounds-checked, rejects oversized bodies and any
144/// truncation or trailing garbage; never panics on malformed input.
145pub fn decode_full_tx(src: &[u8]) -> Result<FullTx, FrameError> {
146    let (ft, consumed) = decode_full_tx_prefix(src)?;
147    if consumed != src.len() {
148        return Err(FrameError); // exact consumption
149    }
150    Ok(ft)
151}
152
153/// Decodes a FullTx body from the front of `src` and returns the cursor offset
154/// just past it, without requiring `src` to be fully consumed. This lets a v2
155/// frame decode the v1 body and then read whatever follows as a TLV trailer.
156///
157/// `decode_full_tx` wraps this and enforces exact consumption for the plain
158/// v1 case.
159pub fn decode_full_tx_prefix(src: &[u8]) -> Result<(FullTx, usize), FrameError> {
160    if src.len() > MAX_FULL_TX_BODY {
161        return Err(FrameError);
162    }
163    let mut d = Decoder { b: src, off: 0 };
164    let mut ft = FullTx {
165        slot: d.u64()?,
166        num_required_signatures: d.u8()? as u32,
167        num_readonly_signed_accounts: d.u8()? as u32,
168        num_readonly_unsigned_accounts: d.u8()? as u32,
169        versioned: d.u8()? != 0,
170        ..Default::default()
171    };
172    ft.recent_blockhash.copy_from_slice(d.take(32)?);
173
174    let n = d.count(64)?;
175    ft.signatures.reserve(n);
176    for _ in 0..n {
177        let mut s = [0u8; 64];
178        s.copy_from_slice(d.take(64)?);
179        ft.signatures.push(s);
180    }
181    let n = d.count(32)?;
182    ft.account_keys.reserve(n);
183    for _ in 0..n {
184        let mut k = [0u8; 32];
185        k.copy_from_slice(d.take(32)?);
186        ft.account_keys.push(k);
187    }
188    let n = d.count(5)?; // min instruction = progIdx(1)+accLen(2)+dataLen(2)
189    ft.instructions.reserve(n);
190    for _ in 0..n {
191        let program_id_index = d.u8()? as u32;
192        let alen = d.u16()?;
193        let accounts = d.take(alen)?.to_vec();
194        let dlen = d.u16()?;
195        let data = d.take(dlen)?.to_vec();
196        ft.instructions.push(FullInstruction {
197            program_id_index,
198            accounts,
199            data,
200        });
201    }
202    let n = d.count(36)?; // min ATL = key(32)+wLen(2)+rLen(2)
203    ft.address_table_lookups.reserve(n);
204    for _ in 0..n {
205        let mut account_key = [0u8; 32];
206        account_key.copy_from_slice(d.take(32)?);
207        let wlen = d.u16()?;
208        let writable_indexes = d.take(wlen)?.to_vec();
209        let rlen = d.u16()?;
210        let readonly_indexes = d.take(rlen)?.to_vec();
211        ft.address_table_lookups.push(FullAtl {
212            account_key,
213            writable_indexes,
214            readonly_indexes,
215        });
216    }
217    Ok((ft, d.off))
218}
219
220/// A decoded v2 transaction frame: the v1 body plus its v2 additions.
221#[derive(Debug, Default, Clone, PartialEq, Eq)]
222pub struct FullTxV2 {
223    pub tx: FullTx,
224    pub alt_incomplete: bool,
225    pub loaded_writable: Vec<[u8; 32]>,
226    pub loaded_readonly: Vec<[u8; 32]>,
227}
228
229/// A decoded v2 frame. `Unknown` carries the message type so a client can skip
230/// it deliberately rather than erroring.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub enum Frame {
233    Tx(FullTxV2),
234    Heartbeat { server_ts_ms: u64, highest_seq: u64 },
235    Unknown(u8),
236}
237
238fn flatten32(addrs: &[[u8; 32]]) -> Vec<u8> {
239    let mut v = Vec::with_capacity(addrs.len() * 32);
240    for a in addrs {
241        v.extend_from_slice(a);
242    }
243    v
244}
245
246fn unflatten32(src: &[u8]) -> Result<Vec<[u8; 32]>, FrameError> {
247    if src.len() % 32 != 0 {
248        return Err(FrameError);
249    }
250    let mut out = Vec::with_capacity(src.len() / 32);
251    for c in src.chunks_exact(32) {
252        out.push(<[u8; 32]>::try_from(c).map_err(|_| FrameError)?);
253    }
254    Ok(out)
255}
256
257/// Encodes a v2 transaction frame: `msg_type | flags | v1 body | TLV trailer`.
258///
259/// The v1 body is reused byte-for-byte; only the framing around it is new.
260/// Pass empty slices for a non-enriched subscriber — the trailer is then empty
261/// and the frame costs two bytes more than v1.
262pub fn encode_frame_tx(
263    ft: &FullTx,
264    alt_incomplete: bool,
265    loaded_writable: &[[u8; 32]],
266    loaded_readonly: &[[u8; 32]],
267) -> Vec<u8> {
268    let body = encode_full_tx(ft);
269    let mut b = Vec::with_capacity(body.len() + 2 + 64);
270    b.push(MSG_TX);
271    b.push(if alt_incomplete {
272        FLAG_ALT_INCOMPLETE
273    } else {
274        0
275    });
276    b.extend_from_slice(&body);
277    if !loaded_writable.is_empty() {
278        put_tlv(&mut b, TLV_LOADED_WRITABLE, &flatten32(loaded_writable));
279    }
280    if !loaded_readonly.is_empty() {
281        put_tlv(&mut b, TLV_LOADED_READONLY, &flatten32(loaded_readonly));
282    }
283    b
284}
285
286/// Decodes one v2 frame (the caller has already stripped the u32 BE length
287/// prefix). Bounds-checked; never panics.
288pub fn decode_frame(src: &[u8]) -> Result<Frame, FrameError> {
289    let msg_type = *src.first().ok_or(FrameError)?;
290    let flags = *src.get(1).ok_or(FrameError)?;
291    let rest = src.get(2..).ok_or(FrameError)?;
292
293    match msg_type {
294        MSG_TX => {
295            if flags & !FLAG_ALT_INCOMPLETE != 0 {
296                return Err(FrameError); // reserved bits must be zero
297            }
298            // The v1 body is self-delimiting: decode it, then treat whatever
299            // follows as the TLV trailer.
300            let (tx, consumed) = decode_full_tx_prefix(rest)?;
301            let tlvs = parse_tlvs(rest.get(consumed..).ok_or(FrameError)?)?;
302            let mut v2 = FullTxV2 {
303                tx,
304                alt_incomplete: flags & FLAG_ALT_INCOMPLETE != 0,
305                loaded_writable: Vec::new(),
306                loaded_readonly: Vec::new(),
307            };
308            for (t, value) in tlvs {
309                match t {
310                    TLV_LOADED_WRITABLE => v2.loaded_writable = unflatten32(value)?,
311                    TLV_LOADED_READONLY => v2.loaded_readonly = unflatten32(value)?,
312                    _ => {} // unknown TLV: skip, do not error
313                }
314            }
315            Ok(Frame::Tx(v2))
316        }
317        MSG_HEARTBEAT => {
318            // alt_incomplete (bit 0) is a tx-frame-only concept, so unlike
319            // MSG_TX there is no bit this message type defines: all 8 bits
320            // are reserved here and MUST be zero. Do not reuse the MSG_TX
321            // `!FLAG_ALT_INCOMPLETE` mask — that would silently accept bit 0
322            // on a frame kind where it has no meaning.
323            if flags != 0 {
324                return Err(FrameError);
325            }
326            let tlvs = parse_tlvs(rest)?;
327            let mut server_ts_ms = 0u64;
328            let mut highest_seq = 0u64;
329            for (t, value) in tlvs {
330                match t {
331                    TLV_SERVER_TS_MS => {
332                        server_ts_ms =
333                            u64::from_le_bytes(<[u8; 8]>::try_from(value).map_err(|_| FrameError)?)
334                    }
335                    TLV_HIGHEST_SEQ => {
336                        highest_seq =
337                            u64::from_le_bytes(<[u8; 8]>::try_from(value).map_err(|_| FrameError)?)
338                    }
339                    _ => {}
340                }
341            }
342            Ok(Frame::Heartbeat {
343                server_ts_ms,
344                highest_seq,
345            })
346        }
347        other => Ok(Frame::Unknown(other)),
348    }
349}
350
351// ---- typed datagrams -------------------------------------------------------
352
353pub const DG_SIG_FIRST: u8 = 1;
354pub const DG_HEARTBEAT: u8 = 2;
355
356/// `u8 type | u64 slot | u64 seq | 64B signature`
357pub const DG_SIG_FIRST_MIN: usize = 1 + 8 + 8 + 64;
358/// `u8 type | u64 server_ts_ms | u64 highest_seq`
359pub const DG_HEARTBEAT_MIN: usize = 1 + 8 + 8;
360
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum Datagram {
363    SigFirst {
364        slot: u64,
365        seq: u64,
366        signature: [u8; 64],
367    },
368    Heartbeat {
369        server_ts_ms: u64,
370        highest_seq: u64,
371    },
372    Unknown(u8),
373}
374
375pub fn encode_dg_sig_first(buf: &mut [u8; DG_SIG_FIRST_MIN], slot: u64, seq: u64, sig: &[u8; 64]) {
376    buf[0] = DG_SIG_FIRST;
377    buf[1..9].copy_from_slice(&slot.to_le_bytes());
378    buf[9..17].copy_from_slice(&seq.to_le_bytes());
379    buf[17..81].copy_from_slice(sig);
380}
381
382pub fn encode_dg_heartbeat(buf: &mut [u8; DG_HEARTBEAT_MIN], server_ts_ms: u64, highest_seq: u64) {
383    buf[0] = DG_HEARTBEAT;
384    buf[1..9].copy_from_slice(&server_ts_ms.to_le_bytes());
385    buf[9..17].copy_from_slice(&highest_seq.to_le_bytes());
386}
387
388/// Decodes a datagram by its type tag.
389///
390/// **Each type declares a MINIMUM length, not an exact one.** A known type that
391/// is long enough parses, and trailing bytes are ignored — that is what lets a
392/// later version add a field without breaking this decoder. An unknown type is
393/// reported so the caller can skip it deliberately.
394pub fn decode_datagram(src: &[u8]) -> Option<Datagram> {
395    match *src.first()? {
396        DG_SIG_FIRST if src.len() >= DG_SIG_FIRST_MIN => Some(Datagram::SigFirst {
397            slot: u64::from_le_bytes(src.get(1..9)?.try_into().ok()?),
398            seq: u64::from_le_bytes(src.get(9..17)?.try_into().ok()?),
399            signature: src.get(17..81)?.try_into().ok()?,
400        }),
401        DG_HEARTBEAT if src.len() >= DG_HEARTBEAT_MIN => Some(Datagram::Heartbeat {
402            server_ts_ms: u64::from_le_bytes(src.get(1..9)?.try_into().ok()?),
403            highest_seq: u64::from_le_bytes(src.get(9..17)?.try_into().ok()?),
404        }),
405        DG_SIG_FIRST | DG_HEARTBEAT => None, // known type, too short
406        other => Some(Datagram::Unknown(other)),
407    }
408}
409
410fn put_u16(b: &mut Vec<u8>, n: usize) {
411    b.extend_from_slice(&(n as u16).to_le_bytes());
412}
413
414struct Decoder<'a> {
415    b: &'a [u8],
416    off: usize,
417}
418
419impl<'a> Decoder<'a> {
420    fn take(&mut self, n: usize) -> Result<&'a [u8], FrameError> {
421        let end = self.off.checked_add(n).ok_or(FrameError)?;
422        if end > self.b.len() {
423            return Err(FrameError);
424        }
425        let s = &self.b[self.off..end];
426        self.off = end;
427        Ok(s)
428    }
429    fn u8(&mut self) -> Result<u8, FrameError> {
430        Ok(self.take(1)?[0])
431    }
432    fn u16(&mut self) -> Result<usize, FrameError> {
433        let s = self.take(2)?;
434        Ok(u16::from_le_bytes([s[0], s[1]]) as usize)
435    }
436    fn u64(&mut self) -> Result<u64, FrameError> {
437        let s = self.take(8)?;
438        Ok(u64::from_le_bytes(s.try_into().unwrap()))
439    }
440    /// Reads a u16 count and rejects it before allocating if that many elements
441    /// of at least `min_elem` bytes cannot fit in the remaining frame.
442    fn count(&mut self, min_elem: usize) -> Result<usize, FrameError> {
443        let n = self.u16()?;
444        if min_elem == 0 || n > (self.b.len() - self.off) / min_elem {
445            return Err(FrameError);
446        }
447        Ok(n)
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    fn sample(versioned: bool) -> FullTx {
456        FullTx {
457            slot: 7,
458            versioned,
459            num_required_signatures: 1,
460            recent_blockhash: [0xCC; 32],
461            signatures: vec![[7u8; 64]],
462            account_keys: vec![[0xA1; 32]],
463            instructions: vec![FullInstruction {
464                program_id_index: 1,
465                accounts: vec![0],
466                data: vec![0xDE, 0xAD, 0xBE],
467            }],
468            address_table_lookups: if versioned {
469                vec![FullAtl {
470                    account_key: [0xEE; 32],
471                    writable_indexes: vec![5],
472                    readonly_indexes: vec![7],
473                }]
474            } else {
475                vec![]
476            },
477            ..Default::default()
478        }
479    }
480
481    #[test]
482    fn full_tx_round_trip_legacy_and_v0() {
483        for versioned in [false, true] {
484            let ft = sample(versioned);
485            let body = encode_full_tx(&ft);
486            let got = decode_full_tx(&body).unwrap();
487            assert_eq!(got, ft);
488        }
489    }
490
491    #[test]
492    fn full_tx_rejects_truncated() {
493        let body = encode_full_tx(&sample(true));
494        assert_eq!(decode_full_tx(&body[..body.len() - 3]), Err(FrameError));
495    }
496
497    #[test]
498    fn preamble_is_six_bytes_and_starts_nonzero() {
499        // A v1 stream's first byte is ALWAYS 0x00 (u32 BE length prefix, frames
500        // <= 64 KiB), so a non-zero first byte is what makes the preamble
501        // unambiguous. Guard that property.
502        assert_eq!(PREAMBLE.len(), 6);
503        assert_ne!(PREAMBLE[0], 0x00);
504        assert_eq!(&PREAMBLE[0..4], b"PLS2");
505        assert_eq!(PREAMBLE[4], WIRE_VERSION);
506        assert_eq!(PREAMBLE[5], 0);
507    }
508
509    #[test]
510    fn tlv_round_trips_in_order() {
511        let mut b = Vec::new();
512        put_tlv(&mut b, TLV_LOADED_WRITABLE, &[1u8; 64]);
513        put_tlv(&mut b, TLV_LOADED_READONLY, &[2u8; 32]);
514        let got = parse_tlvs(&b).expect("valid");
515        assert_eq!(got.len(), 2);
516        assert_eq!(got[0].0, TLV_LOADED_WRITABLE);
517        assert_eq!(got[0].1, &[1u8; 64][..]);
518        assert_eq!(got[1].0, TLV_LOADED_READONLY);
519        assert_eq!(got[1].1, &[2u8; 32][..]);
520    }
521
522    #[test]
523    fn tlv_length_is_u16_so_a_large_value_fits() {
524        // 100 addresses x 32 bytes = 3200 — impossible with a u8 length.
525        let big = vec![7u8; 3200];
526        let mut b = Vec::new();
527        put_tlv(&mut b, TLV_LOADED_WRITABLE, &big);
528        let got = parse_tlvs(&b).expect("valid");
529        assert_eq!(got[0].1.len(), 3200);
530    }
531
532    #[test]
533    fn tlv_unknown_type_is_kept_for_the_caller_to_skip() {
534        let mut b = Vec::new();
535        put_tlv(&mut b, 200, &[9u8; 4]);
536        let got = parse_tlvs(&b).expect("unknown types parse fine");
537        assert_eq!(got[0].0, 200);
538    }
539
540    #[test]
541    fn tlv_duplicate_type_is_rejected() {
542        let mut b = Vec::new();
543        put_tlv(&mut b, TLV_LOADED_WRITABLE, &[1u8; 32]);
544        put_tlv(&mut b, TLV_LOADED_WRITABLE, &[2u8; 32]);
545        assert_eq!(parse_tlvs(&b), Err(FrameError));
546    }
547
548    #[test]
549    fn tlv_truncated_is_rejected() {
550        let mut b = Vec::new();
551        put_tlv(&mut b, TLV_LOADED_WRITABLE, &[1u8; 32]);
552        for cut in 1..b.len() {
553            assert_eq!(parse_tlvs(&b[..cut]), Err(FrameError), "cut={cut}");
554        }
555        assert_eq!(parse_tlvs(&[]), Ok(Vec::new()));
556    }
557
558    #[test]
559    fn tlv_length_overrunning_the_buffer_is_rejected() {
560        // type=1, len=0xFFFF, but no payload
561        let b = vec![1u8, 0xFF, 0xFF];
562        assert_eq!(parse_tlvs(&b), Err(FrameError));
563    }
564
565    fn sample_fulltx() -> FullTx {
566        FullTx {
567            slot: 438_690_000,
568            versioned: true,
569            num_required_signatures: 1,
570            num_readonly_signed_accounts: 0,
571            num_readonly_unsigned_accounts: 1,
572            recent_blockhash: [0xCC; 32],
573            signatures: vec![[7u8; 64]],
574            account_keys: vec![[0xA1; 32], [0xB2; 32]],
575            instructions: vec![FullInstruction {
576                program_id_index: 1,
577                accounts: vec![0],
578                data: vec![9, 9],
579            }],
580            address_table_lookups: vec![FullAtl {
581                account_key: [0xEE; 32],
582                writable_indexes: vec![0],
583                readonly_indexes: vec![1],
584            }],
585        }
586    }
587
588    #[test]
589    fn v2_tx_frame_round_trips_bare() {
590        let tx = sample_fulltx();
591        let enc = encode_frame_tx(&tx, false, &[], &[]);
592        assert_eq!(enc[0], MSG_TX);
593        assert_eq!(enc[1], 0, "no flags set");
594        match decode_frame(&enc).expect("valid") {
595            Frame::Tx(v2) => {
596                assert_eq!(v2.tx, tx);
597                assert!(!v2.alt_incomplete);
598                assert!(v2.loaded_writable.is_empty());
599                assert!(v2.loaded_readonly.is_empty());
600            }
601            other => panic!("expected Tx, got {other:?}"),
602        }
603    }
604
605    #[test]
606    fn v2_tx_frame_round_trips_enriched() {
607        let tx = sample_fulltx();
608        let w = [[0x11u8; 32], [0x22u8; 32]];
609        let r = [[0x33u8; 32]];
610        let enc = encode_frame_tx(&tx, true, &w, &r);
611        assert_eq!(enc[1] & FLAG_ALT_INCOMPLETE, FLAG_ALT_INCOMPLETE);
612        match decode_frame(&enc).expect("valid") {
613            Frame::Tx(v2) => {
614                assert!(v2.alt_incomplete);
615                assert_eq!(v2.loaded_writable, w.to_vec());
616                assert_eq!(v2.loaded_readonly, r.to_vec());
617            }
618            other => panic!("expected Tx, got {other:?}"),
619        }
620    }
621
622    #[test]
623    fn v2_body_is_byte_identical_to_v1_encoding() {
624        // The v1 positional body is reused unchanged; only the framing is new.
625        let tx = sample_fulltx();
626        let v1 = encode_full_tx(&tx);
627        let v2 = encode_frame_tx(&tx, false, &[], &[]);
628        assert_eq!(&v2[2..2 + v1.len()], &v1[..]);
629    }
630
631    #[test]
632    fn unknown_msg_type_is_reported_not_rejected() {
633        let mut enc = encode_frame_tx(&sample_fulltx(), false, &[], &[]);
634        enc[0] = 99;
635        match decode_frame(&enc).expect("unknown type must not error") {
636            Frame::Unknown(99) => {}
637            other => panic!("expected Unknown(99), got {other:?}"),
638        }
639    }
640
641    #[test]
642    fn reserved_flag_bits_are_rejected() {
643        let mut enc = encode_frame_tx(&sample_fulltx(), false, &[], &[]);
644        enc[1] = 0x02; // bit 1 reserved
645        assert_eq!(decode_frame(&enc), Err(FrameError));
646    }
647
648    #[test]
649    fn loaded_address_tlv_with_a_non_multiple_of_32_is_rejected() {
650        let mut enc = Vec::new();
651        enc.push(MSG_TX);
652        enc.push(0);
653        enc.extend_from_slice(&encode_full_tx(&sample_fulltx()));
654        put_tlv(&mut enc, TLV_LOADED_WRITABLE, &[0u8; 33]);
655        assert_eq!(decode_frame(&enc), Err(FrameError));
656    }
657
658    #[test]
659    fn heartbeat_frame_round_trips() {
660        let mut enc = Vec::new();
661        enc.push(MSG_HEARTBEAT);
662        enc.push(0);
663        put_tlv(
664            &mut enc,
665            TLV_SERVER_TS_MS,
666            &1_700_000_000_123u64.to_le_bytes(),
667        );
668        put_tlv(&mut enc, TLV_HIGHEST_SEQ, &4242u64.to_le_bytes());
669        match decode_frame(&enc).expect("valid") {
670            Frame::Heartbeat {
671                server_ts_ms,
672                highest_seq,
673            } => {
674                assert_eq!(server_ts_ms, 1_700_000_000_123);
675                assert_eq!(highest_seq, 4242);
676            }
677            other => panic!("expected Heartbeat, got {other:?}"),
678        }
679    }
680
681    #[test]
682    fn heartbeat_frame_rejects_any_nonzero_flags() {
683        // Unlike MSG_TX, alt_incomplete (bit 0) has no meaning on a heartbeat,
684        // so every bit is reserved for this message type — not just bits 1-7.
685        for flags in [FLAG_ALT_INCOMPLETE, 0x02, 0xFF] {
686            let mut enc = Vec::new();
687            enc.push(MSG_HEARTBEAT);
688            enc.push(flags);
689            put_tlv(&mut enc, TLV_SERVER_TS_MS, &1u64.to_le_bytes());
690            assert_eq!(decode_frame(&enc), Err(FrameError), "flags={flags:#x}");
691        }
692    }
693
694    #[test]
695    fn frame_too_short_is_rejected() {
696        assert_eq!(decode_frame(&[]), Err(FrameError));
697        assert_eq!(decode_frame(&[MSG_TX]), Err(FrameError));
698    }
699
700    #[test]
701    fn dg_sig_first_round_trips() {
702        let mut buf = [0u8; DG_SIG_FIRST_MIN];
703        encode_dg_sig_first(&mut buf, 438_690_000, 12345, &[9u8; 64]);
704        assert_eq!(buf[0], DG_SIG_FIRST);
705        match decode_datagram(&buf).expect("valid") {
706            Datagram::SigFirst {
707                slot,
708                seq,
709                signature,
710            } => {
711                assert_eq!(slot, 438_690_000);
712                assert_eq!(seq, 12345);
713                assert_eq!(signature, [9u8; 64]);
714            }
715            other => panic!("expected SigFirst, got {other:?}"),
716        }
717    }
718
719    #[test]
720    fn dg_heartbeat_round_trips() {
721        let mut buf = [0u8; DG_HEARTBEAT_MIN];
722        encode_dg_heartbeat(&mut buf, 1_700_000_000_123, 999);
723        match decode_datagram(&buf).expect("valid") {
724            Datagram::Heartbeat {
725                server_ts_ms,
726                highest_seq,
727            } => {
728                assert_eq!(server_ts_ms, 1_700_000_000_123);
729                assert_eq!(highest_seq, 999);
730            }
731            other => panic!("expected Heartbeat, got {other:?}"),
732        }
733    }
734
735    #[test]
736    fn dg_minimum_length_not_exact_length() {
737        // THE forward-compatibility rule: a longer datagram of a known type
738        // must parse, ignoring the trailing bytes. Without this, v2 re-freezes
739        // the format exactly as v1 did and the next field is another break.
740        let mut buf = [0u8; DG_SIG_FIRST_MIN];
741        encode_dg_sig_first(&mut buf, 7, 8, &[3u8; 64]);
742        let mut longer = buf.to_vec();
743        longer.extend_from_slice(&[0xAB; 16]);
744        match decode_datagram(&longer).expect("trailing bytes must be ignored") {
745            Datagram::SigFirst { slot, seq, .. } => {
746                assert_eq!(slot, 7);
747                assert_eq!(seq, 8);
748            }
749            other => panic!("expected SigFirst, got {other:?}"),
750        }
751    }
752
753    #[test]
754    fn dg_below_minimum_is_rejected() {
755        let mut buf = [0u8; DG_SIG_FIRST_MIN];
756        encode_dg_sig_first(&mut buf, 1, 2, &[0u8; 64]);
757        assert!(decode_datagram(&buf[..DG_SIG_FIRST_MIN - 1]).is_none());
758        let mut hb = [0u8; DG_HEARTBEAT_MIN];
759        encode_dg_heartbeat(&mut hb, 1, 2);
760        assert!(decode_datagram(&hb[..DG_HEARTBEAT_MIN - 1]).is_none());
761        assert!(decode_datagram(&[]).is_none());
762    }
763
764    #[test]
765    fn dg_unknown_type_is_reported_not_rejected() {
766        let buf = [200u8, 1, 2, 3];
767        match decode_datagram(&buf).expect("unknown type must not be an error") {
768            Datagram::Unknown(200) => {}
769            other => panic!("expected Unknown(200), got {other:?}"),
770        }
771    }
772
773    #[test]
774    fn a_v1_72_byte_datagram_is_rejected_by_the_length_rule() {
775        // v1 datagrams began with the low byte of the slot, so their first byte
776        // can be 1 — colliding with DG_SIG_FIRST. The length rule is what
777        // separates them: 72 < DG_SIG_FIRST_MIN (81), so a known type that is
778        // too short returns None rather than a garbage decode.
779        let v1 = [1u8; 72];
780        assert_eq!(decode_datagram(&v1), None);
781    }
782}