Skip to main content

zakura_network/zakura/
legacy_gossip.rs

1//! Legacy Zebra gossip compatibility over Zakura streams.
2
3use std::{
4    collections::{HashMap, HashSet, VecDeque},
5    fmt,
6    future::Future,
7    io::Cursor,
8    pin::Pin,
9    sync::{
10        atomic::{AtomicU64, Ordering},
11        Arc, Mutex as StdMutex, OnceLock,
12    },
13    task::{Context, Poll},
14    time::Duration,
15};
16
17use serde_json::{Map, Number, Value};
18use thiserror::Error;
19use tokio::{
20    sync::{mpsc, oneshot, Mutex, OwnedSemaphorePermit, Semaphore},
21    time::{sleep, timeout, Instant},
22};
23use tower::{Service, ServiceExt};
24
25use zakura_chain::{
26    block::{self, Block, MAX_BLOCK_LOCATOR_LENGTH},
27    serialization::{
28        CompactSizeMessage, SerializationError, ZcashDeserialize, ZcashSerialize,
29        MAX_HEADERS_PER_MESSAGE, MAX_PROTOCOL_MESSAGE_LEN,
30    },
31    transaction::{Transaction, UnminedTx, UnminedTxId},
32};
33
34use crate::{
35    protocol::{
36        external::InventoryHash,
37        internal::{InventoryResponse, PeerSource, Request, Response},
38    },
39    BoxError, MAX_TX_INV_IN_SENT_MESSAGE,
40};
41
42use super::{
43    spawn_supervised_peer_task, trace::peer_label as trace_peer_label, BoxRunFuture, Frame,
44    FramedSend, OrderedSendError, Peer, RequestResponseService, Service as ZakuraService,
45    SinkReject, Stream, StreamMode, ZakuraConnId, ZakuraPeerHandle, ZakuraPeerId,
46    ZakuraSupervisorHandle, ZakuraTrace, FRAME_HEADER_BYTES, LEGACY_REQUEST_TABLE,
47    LOCAL_MAX_CONTROL_FRAME_BYTES, ZAKURA_CAP_LEGACY_GOSSIP,
48};
49
50/// Zakura stream kind reserved for legacy gossip compatibility.
51pub const ZAKURA_STREAM_GOSSIP: u16 = 2;
52/// Zakura stream kind reserved for legacy inventory request/response compatibility.
53pub const ZAKURA_STREAM_LEGACY_REQUESTS: u16 = 3;
54/// Version of the legacy gossip compatibility stream.
55pub const LEGACY_GOSSIP_VERSION: u16 = 1;
56/// A block hash advertisement.
57pub const MSG_ADVERTISE_BLOCK: u16 = 1;
58/// A transaction-id advertisement.
59pub const MSG_ADVERTISE_TX_IDS: u16 = 2;
60/// Request block contents by hash.
61pub const MSG_REQUEST_BLOCKS_BY_HASH: u16 = 3;
62/// Request transaction contents by unmined transaction id.
63pub const MSG_REQUEST_TRANSACTIONS_BY_ID: u16 = 4;
64/// A chunk of one available block response.
65pub const MSG_RESPONSE_BLOCK: u16 = 5;
66/// A chunk of one available transaction response.
67pub const MSG_RESPONSE_TRANSACTION: u16 = 6;
68/// Missing block hashes.
69pub const MSG_RESPONSE_MISSING_BLOCKS: u16 = 7;
70/// Missing transaction ids.
71pub const MSG_RESPONSE_MISSING_TRANSACTIONS: u16 = 8;
72/// Request subsequent block hashes from a block locator.
73pub const MSG_REQUEST_FIND_BLOCKS: u16 = 9;
74/// Request subsequent block headers from a block locator.
75pub const MSG_REQUEST_FIND_HEADERS: u16 = 10;
76/// Request mempool transaction IDs.
77pub const MSG_REQUEST_MEMPOOL_TRANSACTION_IDS: u16 = 11;
78/// Ping a peer over the request/response stream.
79pub const MSG_REQUEST_PING: u16 = 12;
80/// Push an unsolicited transaction to a peer.
81pub const MSG_REQUEST_PUSH_TRANSACTION: u16 = 13;
82/// Subsequent block hashes response.
83pub const MSG_RESPONSE_BLOCK_HASHES: u16 = 14;
84/// Subsequent block headers response.
85pub const MSG_RESPONSE_BLOCK_HEADERS: u16 = 15;
86/// Mempool transaction IDs response.
87pub const MSG_RESPONSE_TRANSACTION_IDS: u16 = 16;
88/// Pong response for a ping request.
89pub const MSG_RESPONSE_PONG: u16 = 17;
90/// Nil response for fire-and-forget legacy requests.
91pub const MSG_RESPONSE_NIL: u16 = 18;
92
93const LEGACY_GOSSIP_INBOUND_QUEUE: usize = 256;
94const LEGACY_REQUEST_IN_FLIGHT_LIMIT: usize = 64;
95const LEGACY_GOSSIP_SERVICE_TIMEOUT: Duration = Duration::from_secs(30);
96const DEFAULT_FIRST_SEEN_TTL: Duration = Duration::from_secs(10 * 60);
97const DEFAULT_FIRST_SEEN_CAPACITY: usize = 50_000;
98/// A failed gossip inbound attempt that consumed at least this long is treated as
99/// "expensive" (a slow/backpressured/timed-out service), and the same inventory is
100/// placed in a short cooldown. The first-seen cache is only updated after a
101/// *successful* call, so without this an authenticated peer could replay the same
102/// valid advertisement while the inbound service is slow/erroring and make the
103/// serial gossip worker re-pay the full 30s readiness/call budget for every
104/// duplicate. Fast failures stay below this threshold and remain immediately
105/// retryable, so a transient blip does not drop the advertisement.
106const LEGACY_GOSSIP_EXPENSIVE_ATTEMPT: Duration = Duration::from_secs(1);
107/// How long an expensive failed attempt suppresses duplicate copies of the same
108/// inventory before a genuine re-advertisement may retry.
109const LEGACY_GOSSIP_DUPLICATE_COOLDOWN: Duration = Duration::from_secs(30);
110const LEGACY_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
111const SOURCE_INVENTORY_MISSING_RETRIES: usize = 8;
112const SOURCE_INVENTORY_MISSING_RETRY_DELAY: Duration = Duration::from_millis(500);
113const LEGACY_REQUEST_READY_TIMEOUT: Duration = Duration::from_secs(10);
114/// How long the dual-stack tries the (buffered) legacy peer set for an inventory
115/// fetch before falling back to Zakura. Without this bound, a node that upgraded
116/// all its peers to Zakura (and so has no ready legacy peer) would block every
117/// fetch on the legacy peer set forever, starving the Zakura path.
118const DUAL_STACK_LEGACY_INVENTORY_TIMEOUT: Duration = Duration::from_secs(3);
119const LEGACY_RESPONSE_CHUNK_BYTES: usize = 512 * 1024;
120/// Maximum cumulative response payload bytes the inbound responder will buffer
121/// for a single legacy request before aborting.
122///
123/// A peer can name up to `MAX_TX_INV_IN_SENT_MESSAGE` block/transaction hashes
124/// on one request; without an aggregate cap `encode_response` would serialize
125/// and retain the entire multi-frame `Vec<Frame>` (worst case
126/// `MAX_TX_INV_IN_SENT_MESSAGE * MAX_PROTOCOL_MESSAGE_LEN`, tens of GiB) before
127/// the first byte is written. The outbound reader already enforces a symmetric
128/// per-response cap (`LegacyResponseBudget`); this is the responder-side mirror.
129/// Sized well above any single response the local service emits (zakurad caps
130/// `getdata` at ~1 MiB) yet far below memory-risk thresholds.
131const LEGACY_RESPONSE_MAX_AGGREGATE_BYTES: usize = 8 * MAX_PROTOCOL_MESSAGE_LEN;
132const REQUEST_ID_BYTES: usize = 8;
133const RESPONSE_CHUNK_HEADER_BYTES: usize = REQUEST_ID_BYTES + 1;
134const NO_STOP_HASH: block::Hash = block::Hash([0; 32]);
135const LEGACY_GOSSIP_SERVICE_STREAMS: [Stream; 2] = [
136    Stream {
137        kind: ZAKURA_STREAM_GOSSIP,
138        version: LEGACY_GOSSIP_VERSION,
139        // Advisory until the transport wires Stream::frame_cap end-to-end; the
140        // authoritative inbound cap is app_frame_cap_for_stream_kind.
141        frame_cap: LOCAL_MAX_CONTROL_FRAME_BYTES,
142        capability: ZAKURA_CAP_LEGACY_GOSSIP,
143        mode: StreamMode::Ordered,
144    },
145    Stream {
146        kind: ZAKURA_STREAM_LEGACY_REQUESTS,
147        version: LEGACY_GOSSIP_VERSION,
148        // Advisory until the transport wires Stream::frame_cap end-to-end; the
149        // authoritative inbound cap is app_frame_cap_for_stream_kind.
150        frame_cap: LOCAL_MAX_CONTROL_FRAME_BYTES,
151        capability: ZAKURA_CAP_LEGACY_GOSSIP,
152        mode: StreamMode::RequestResponse,
153    },
154];
155
156/// Service-declared streams for legacy gossip compatibility.
157pub(crate) fn legacy_gossip_streams() -> &'static [Stream] {
158    &LEGACY_GOSSIP_SERVICE_STREAMS
159}
160
161static FIRST_SEEN_BY_SUPERVISOR: OnceLock<std::sync::Mutex<HashMap<u64, FirstSeenCache>>> =
162    OnceLock::new();
163static NEXT_LEGACY_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
164
165/// A typed legacy gossip frame carried by Zakura stream kind 2.
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub enum LegacyGossipFrame {
168    /// Advertise one block hash.
169    AdvertiseBlock(block::Hash),
170    /// Advertise one or more unmined transaction IDs.
171    AdvertiseTransactionIds(Vec<UnminedTxId>),
172}
173
174impl LegacyGossipFrame {
175    /// Convert a legacy network request into a gossip frame.
176    pub fn from_request(request: Request) -> Result<Self, LegacyGossipError> {
177        match request {
178            Request::AdvertiseBlock(hash, _) | Request::AdvertiseBlockToAll(hash) => {
179                Ok(Self::AdvertiseBlock(hash))
180            }
181            Request::AdvertiseTransactionIds(ids, _) => Self::advertise_transaction_ids(ids),
182            request => Err(LegacyGossipError::UnsupportedRequest(request.command())),
183        }
184    }
185
186    /// Build a tx-id gossip frame, enforcing the outbound non-empty/cap invariant.
187    pub fn advertise_transaction_ids(
188        ids: impl IntoIterator<Item = UnminedTxId>,
189    ) -> Result<Self, LegacyGossipError> {
190        let ids = collect_outbound_tx_ids(ids)?;
191        Ok(Self::AdvertiseTransactionIds(ids))
192    }
193
194    /// Convert this typed frame to a Zakura wire frame.
195    pub fn encode_frame(&self) -> Result<Frame, LegacyGossipError> {
196        match self {
197            Self::AdvertiseBlock(hash) => {
198                let mut payload = Vec::new();
199                hash.zcash_serialize(&mut payload)?;
200                Ok(Frame {
201                    message_type: MSG_ADVERTISE_BLOCK,
202                    flags: 0,
203                    payload,
204                })
205            }
206            Self::AdvertiseTransactionIds(ids) => {
207                let ids = outbound_tx_id_slice(ids)?;
208                let mut payload = Vec::new();
209                write_tx_id_list(&mut payload, ids)?;
210                Ok(Frame {
211                    message_type: MSG_ADVERTISE_TX_IDS,
212                    flags: 0,
213                    payload,
214                })
215            }
216        }
217    }
218
219    /// Decode a Zakura frame into a typed gossip frame.
220    pub fn decode_frame(frame: Frame) -> Result<Self, LegacyGossipError> {
221        if frame.flags != 0 {
222            return Err(LegacyGossipError::UnsupportedFlags(frame.flags));
223        }
224
225        match frame.message_type {
226            MSG_ADVERTISE_BLOCK => {
227                let mut reader = Cursor::new(frame.payload.as_slice());
228                let hash = block::Hash::zcash_deserialize(&mut reader)?;
229                reject_trailing(&reader)?;
230                Ok(Self::AdvertiseBlock(hash))
231            }
232            MSG_ADVERTISE_TX_IDS => {
233                let mut reader = Cursor::new(frame.payload.as_slice());
234                let ids = read_tx_id_list(&mut reader)?;
235                if ids.is_empty() {
236                    return Err(LegacyGossipError::EmptyTransactionAdvertisement);
237                }
238                reject_trailing(&reader)?;
239                Ok(Self::AdvertiseTransactionIds(ids))
240            }
241            message_type => Err(LegacyGossipError::UnknownMessageType(message_type)),
242        }
243    }
244
245    fn into_request(self, peer_id: ZakuraPeerId) -> Request {
246        match self {
247            Self::AdvertiseBlock(hash) => {
248                Request::AdvertiseBlock(hash, Some(PeerSource::Zakura(peer_id)))
249            }
250            Self::AdvertiseTransactionIds(ids) => Request::AdvertiseTransactionIds(
251                ids.into_iter().collect(),
252                Some(PeerSource::Zakura(peer_id)),
253            ),
254        }
255    }
256}
257
258/// A typed legacy inventory request carried by Zakura stream kind 3.
259#[derive(Clone, Debug, Eq, PartialEq)]
260pub enum LegacyRequestFrame {
261    /// Request block contents by hash.
262    BlocksByHash(Vec<block::Hash>),
263    /// Request transaction contents by unmined transaction id.
264    TransactionsById(Vec<UnminedTxId>),
265    /// Request block hashes after a locator.
266    FindBlocks {
267        /// Hashes of known blocks, ordered from highest height to lowest height.
268        known_blocks: Vec<block::Hash>,
269        /// Optional stop hash.
270        stop: Option<block::Hash>,
271    },
272    /// Request block headers after a locator.
273    FindHeaders {
274        /// Hashes of known blocks, ordered from highest height to lowest height.
275        known_blocks: Vec<block::Hash>,
276        /// Optional stop hash.
277        stop: Option<block::Hash>,
278    },
279    /// Request mempool transaction IDs.
280    MempoolTransactionIds,
281    /// Ping a peer.
282    Ping,
283    /// Push an unsolicited transaction.
284    PushTransaction(UnminedTx),
285}
286
287impl LegacyRequestFrame {
288    /// Convert a legacy network request into an inventory request frame.
289    pub fn from_request(request: Request) -> Result<Self, LegacyGossipError> {
290        match request {
291            Request::BlocksByHash(hashes) | Request::BlocksByHashFrom { hashes, .. } => {
292                let hashes = truncate_to_inventory_cap(hashes)?;
293                Ok(Self::BlocksByHash(hashes))
294            }
295            Request::TransactionsById(ids) | Request::TransactionsByIdFrom { ids, .. } => {
296                let ids = truncate_to_inventory_cap(ids)?;
297                Ok(Self::TransactionsById(ids))
298            }
299            Request::FindBlocks { known_blocks, stop } => {
300                ensure_block_locator_count(known_blocks.len())?;
301                Ok(Self::FindBlocks { known_blocks, stop })
302            }
303            Request::FindHeaders { known_blocks, stop } => {
304                ensure_block_locator_count(known_blocks.len())?;
305                Ok(Self::FindHeaders { known_blocks, stop })
306            }
307            Request::MempoolTransactionIds => Ok(Self::MempoolTransactionIds),
308            Request::Ping(_) => Ok(Self::Ping),
309            Request::PushTransaction(transaction, _) => Ok(Self::PushTransaction(transaction)),
310            request => Err(LegacyGossipError::UnsupportedRequest(request.command())),
311        }
312    }
313
314    /// Convert this typed request to a Zakura wire frame.
315    pub fn encode_frame(&self) -> Result<Frame, LegacyGossipError> {
316        match self {
317            Self::BlocksByHash(hashes) => {
318                let mut payload = Vec::new();
319                write_hash_list(&mut payload, hashes)?;
320                Ok(Frame {
321                    message_type: MSG_REQUEST_BLOCKS_BY_HASH,
322                    flags: 0,
323                    payload,
324                })
325            }
326            Self::TransactionsById(ids) => {
327                let mut payload = Vec::new();
328                write_tx_id_list(&mut payload, ids)?;
329                Ok(Frame {
330                    message_type: MSG_REQUEST_TRANSACTIONS_BY_ID,
331                    flags: 0,
332                    payload,
333                })
334            }
335            Self::FindBlocks { known_blocks, stop } => {
336                let mut payload = Vec::new();
337                write_block_locator(&mut payload, known_blocks, *stop)?;
338                Ok(Frame {
339                    message_type: MSG_REQUEST_FIND_BLOCKS,
340                    flags: 0,
341                    payload,
342                })
343            }
344            Self::FindHeaders { known_blocks, stop } => {
345                let mut payload = Vec::new();
346                write_block_locator(&mut payload, known_blocks, *stop)?;
347                Ok(Frame {
348                    message_type: MSG_REQUEST_FIND_HEADERS,
349                    flags: 0,
350                    payload,
351                })
352            }
353            Self::MempoolTransactionIds => Ok(Frame {
354                message_type: MSG_REQUEST_MEMPOOL_TRANSACTION_IDS,
355                flags: 0,
356                payload: Vec::new(),
357            }),
358            Self::Ping => Ok(Frame {
359                message_type: MSG_REQUEST_PING,
360                flags: 0,
361                payload: Vec::new(),
362            }),
363            Self::PushTransaction(transaction) => Ok(Frame {
364                message_type: MSG_REQUEST_PUSH_TRANSACTION,
365                flags: 0,
366                payload: transaction.transaction.zcash_serialize_to_vec()?,
367            }),
368        }
369    }
370
371    /// Decode a Zakura frame into a typed inventory request.
372    pub fn decode_frame(frame: Frame) -> Result<Self, LegacyGossipError> {
373        if frame.flags != 0 {
374            return Err(LegacyGossipError::UnsupportedFlags(frame.flags));
375        }
376
377        match frame.message_type {
378            MSG_REQUEST_BLOCKS_BY_HASH => {
379                let mut reader = Cursor::new(frame.payload.as_slice());
380                let hashes = read_hash_list(&mut reader)?;
381                reject_trailing(&reader)?;
382                Ok(Self::BlocksByHash(hashes))
383            }
384            MSG_REQUEST_TRANSACTIONS_BY_ID => {
385                let mut reader = Cursor::new(frame.payload.as_slice());
386                let ids = read_tx_id_list(&mut reader)?;
387                reject_trailing(&reader)?;
388                Ok(Self::TransactionsById(ids))
389            }
390            MSG_REQUEST_FIND_BLOCKS => {
391                let mut reader = Cursor::new(frame.payload.as_slice());
392                let (known_blocks, stop) = read_block_locator(&mut reader)?;
393                reject_trailing(&reader)?;
394                Ok(Self::FindBlocks { known_blocks, stop })
395            }
396            MSG_REQUEST_FIND_HEADERS => {
397                let mut reader = Cursor::new(frame.payload.as_slice());
398                let (known_blocks, stop) = read_block_locator(&mut reader)?;
399                reject_trailing(&reader)?;
400                Ok(Self::FindHeaders { known_blocks, stop })
401            }
402            MSG_REQUEST_MEMPOOL_TRANSACTION_IDS => {
403                if !frame.payload.is_empty() {
404                    return Err(LegacyGossipError::TrailingBytes);
405                }
406                Ok(Self::MempoolTransactionIds)
407            }
408            MSG_REQUEST_PING => {
409                if !frame.payload.is_empty() {
410                    return Err(LegacyGossipError::TrailingBytes);
411                }
412                Ok(Self::Ping)
413            }
414            MSG_REQUEST_PUSH_TRANSACTION => {
415                let mut reader = Cursor::new(frame.payload.as_slice());
416                let transaction = Transaction::zcash_deserialize(&mut reader)?;
417                reject_trailing(&reader)?;
418                Ok(Self::PushTransaction(UnminedTx::from(transaction)))
419            }
420            message_type => Err(LegacyGossipError::UnknownMessageType(message_type)),
421        }
422    }
423
424    fn into_service_request(self, peer_id: ZakuraPeerId) -> Option<Request> {
425        match self {
426            Self::BlocksByHash(hashes) => Some(Request::BlocksByHash(hashes.into_iter().collect())),
427            Self::TransactionsById(ids) => {
428                Some(Request::TransactionsById(ids.into_iter().collect()))
429            }
430            Self::FindBlocks { known_blocks, stop } => {
431                Some(Request::FindBlocks { known_blocks, stop })
432            }
433            Self::FindHeaders { known_blocks, stop } => {
434                Some(Request::FindHeaders { known_blocks, stop })
435            }
436            Self::MempoolTransactionIds => Some(Request::MempoolTransactionIds),
437            Self::Ping => None,
438            Self::PushTransaction(transaction) => Some(Request::PushTransaction(
439                transaction,
440                Some(PeerSource::Zakura(peer_id)),
441            )),
442        }
443    }
444
445    fn kind(&self) -> LegacyRequestKind {
446        match self {
447            Self::BlocksByHash(_) => LegacyRequestKind::Blocks,
448            Self::TransactionsById(_) => LegacyRequestKind::Transactions,
449            Self::FindBlocks { .. } => LegacyRequestKind::FindBlocks,
450            Self::FindHeaders { .. } => LegacyRequestKind::FindHeaders,
451            Self::MempoolTransactionIds => LegacyRequestKind::MempoolTransactionIds,
452            Self::Ping => LegacyRequestKind::Ping,
453            Self::PushTransaction(_) => LegacyRequestKind::PushTransaction,
454        }
455    }
456}
457
458#[derive(Copy, Clone, Debug, Eq, PartialEq)]
459pub(super) enum LegacyRequestKind {
460    Blocks,
461    Transactions,
462    FindBlocks,
463    FindHeaders,
464    MempoolTransactionIds,
465    Ping,
466    PushTransaction,
467}
468
469impl LegacyRequestKind {
470    fn command(self) -> &'static str {
471        match self {
472            LegacyRequestKind::Blocks => "BlocksByHash",
473            LegacyRequestKind::Transactions => "TransactionsById",
474            LegacyRequestKind::FindBlocks => "FindBlocks",
475            LegacyRequestKind::FindHeaders => "FindHeaders",
476            LegacyRequestKind::MempoolTransactionIds => "MempoolTransactionIds",
477            LegacyRequestKind::Ping => "Ping",
478            LegacyRequestKind::PushTransaction => "PushTransaction",
479        }
480    }
481
482    fn message_type(self) -> u16 {
483        match self {
484            LegacyRequestKind::Blocks => MSG_REQUEST_BLOCKS_BY_HASH,
485            LegacyRequestKind::Transactions => MSG_REQUEST_TRANSACTIONS_BY_ID,
486            LegacyRequestKind::FindBlocks => MSG_REQUEST_FIND_BLOCKS,
487            LegacyRequestKind::FindHeaders => MSG_REQUEST_FIND_HEADERS,
488            LegacyRequestKind::MempoolTransactionIds => MSG_REQUEST_MEMPOOL_TRANSACTION_IDS,
489            LegacyRequestKind::Ping => MSG_REQUEST_PING,
490            LegacyRequestKind::PushTransaction => MSG_REQUEST_PUSH_TRANSACTION,
491        }
492    }
493}
494
495pub(super) struct LegacyResponseCodec;
496
497impl LegacyResponseCodec {
498    pub(super) fn encode_response(
499        request_id: u64,
500        response: Response,
501        max_frame_bytes: u32,
502        max_message_bytes: u32,
503    ) -> Result<Vec<Frame>, LegacyGossipError> {
504        let mut frames = Vec::new();
505        // Bound the cumulative response so a peer that requests many available
506        // blocks/transactions cannot force us to serialize and retain an
507        // unbounded `Vec<Frame>` before the first byte is written. The budget is
508        // shared across every frame of this response and aborts encoding early.
509        let mut budget = ResponseEncodeBudget::default();
510        match response {
511            Response::Blocks(blocks) => {
512                let mut missing = Vec::new();
513                for block in blocks {
514                    match block {
515                        InventoryResponse::Available((block, _)) => {
516                            push_chunked_response(
517                                &mut frames,
518                                &mut budget,
519                                MSG_RESPONSE_BLOCK,
520                                request_id,
521                                max_frame_bytes,
522                                max_message_bytes,
523                                block.zcash_serialize_to_vec()?,
524                            )?;
525                        }
526                        InventoryResponse::Missing(hash) => missing.push(hash),
527                    }
528                }
529                if !missing.is_empty() {
530                    push_response_frame(
531                        &mut frames,
532                        &mut budget,
533                        missing_blocks_frame(request_id, missing)?,
534                    )?;
535                }
536            }
537            Response::Transactions(transactions) => {
538                let mut missing = Vec::new();
539                for transaction in transactions {
540                    match transaction {
541                        InventoryResponse::Available((transaction, _)) => {
542                            push_chunked_response(
543                                &mut frames,
544                                &mut budget,
545                                MSG_RESPONSE_TRANSACTION,
546                                request_id,
547                                max_frame_bytes,
548                                max_message_bytes,
549                                transaction.transaction.zcash_serialize_to_vec()?,
550                            )?;
551                        }
552                        InventoryResponse::Missing(id) => missing.push(id),
553                    }
554                }
555                if !missing.is_empty() {
556                    push_response_frame(
557                        &mut frames,
558                        &mut budget,
559                        missing_transactions_frame(request_id, missing)?,
560                    )?;
561                }
562            }
563            Response::BlockHashes(hashes) => {
564                // FindBlocks should already be service-capped; overflowing the wire cap is a bug.
565                push_response_frame(
566                    &mut frames,
567                    &mut budget,
568                    block_hashes_frame(request_id, hashes)?,
569                )?;
570            }
571            Response::BlockHeaders(headers) => {
572                // FindHeaders is protocol-capped by MAX_HEADERS_PER_MESSAGE; reject overflow.
573                push_response_frame(
574                    &mut frames,
575                    &mut budget,
576                    block_headers_frame(request_id, headers)?,
577                )?;
578            }
579            Response::TransactionIds(ids) => {
580                // Mempools can exceed one legacy inv response, so advertise the first capped page.
581                push_response_frame(
582                    &mut frames,
583                    &mut budget,
584                    transaction_ids_frame(request_id, truncate_to_inventory_cap(ids)?)?,
585                )?;
586            }
587            Response::Pong(_) => {
588                push_response_frame(
589                    &mut frames,
590                    &mut budget,
591                    id_only_frame(MSG_RESPONSE_PONG, request_id),
592                )?;
593            }
594            Response::Nil => {
595                push_response_frame(
596                    &mut frames,
597                    &mut budget,
598                    id_only_frame(MSG_RESPONSE_NIL, request_id),
599                )?;
600            }
601            response => return Err(LegacyGossipError::UnexpectedResponse(response.command())),
602        }
603        Ok(frames)
604    }
605
606    pub(super) fn decode_response(
607        request_id: u64,
608        request_kind: LegacyRequestKind,
609        frames: Vec<Frame>,
610        requested_block_hashes: Option<&HashSet<block::Hash>>,
611    ) -> Result<Response, LegacyGossipError> {
612        let mut blocks = Vec::new();
613        let mut transactions = Vec::new();
614        let mut block_hashes = Vec::new();
615        let mut block_headers = Vec::new();
616        let mut transaction_ids = Vec::new();
617        let mut saw_pong = false;
618        let mut saw_nil = false;
619        let mut reassembler = ResponseReassembler::new(request_id);
620
621        for frame in frames {
622            if frame.flags != 0 {
623                return Err(LegacyGossipError::UnsupportedFlags(frame.flags));
624            }
625
626            match frame.message_type {
627                MSG_RESPONSE_BLOCK => {
628                    if request_kind != LegacyRequestKind::Blocks {
629                        return Err(LegacyGossipError::UnexpectedResponse("Blocks"));
630                    }
631                    if let Some(bytes) = reassembler.accept(&frame.payload)? {
632                        let block = Arc::new(Block::zcash_deserialize(&mut Cursor::new(
633                            bytes.as_slice(),
634                        ))?);
635                        // Bind the delivered block to a hash we actually requested.
636                        // Without this, a peer can substitute any other valid block
637                        // for the one requested, corrupting downstream hash/source
638                        // accounting (the response is correlated only by request id
639                        // and kind, not by hash).
640                        if let Some(requested) = requested_block_hashes {
641                            let hash = block.hash();
642                            if !requested.contains(&hash) {
643                                return Err(LegacyGossipError::UnsolicitedBlock(hash));
644                            }
645                        }
646                        blocks.push(InventoryResponse::Available((block, None)));
647                    }
648                }
649                MSG_RESPONSE_TRANSACTION => {
650                    if request_kind != LegacyRequestKind::Transactions {
651                        return Err(LegacyGossipError::UnexpectedResponse("Transactions"));
652                    }
653                    if let Some(bytes) = reassembler.accept(&frame.payload)? {
654                        let transaction =
655                            Transaction::zcash_deserialize(&mut Cursor::new(bytes.as_slice()))?;
656                        transactions.push(InventoryResponse::Available((
657                            UnminedTx::from(transaction),
658                            None,
659                        )));
660                    }
661                }
662                MSG_RESPONSE_MISSING_BLOCKS => {
663                    if request_kind != LegacyRequestKind::Blocks {
664                        return Err(LegacyGossipError::UnexpectedResponse("MissingBlocks"));
665                    }
666                    reassembler.reject_if_active()?;
667                    for hash in decode_hashes_response(request_id, frame.payload)? {
668                        // A peer may only report blocks we requested as missing.
669                        if let Some(requested) = requested_block_hashes {
670                            if !requested.contains(&hash) {
671                                return Err(LegacyGossipError::UnsolicitedBlock(hash));
672                            }
673                        }
674                        blocks.push(InventoryResponse::Missing(hash));
675                    }
676                }
677                MSG_RESPONSE_MISSING_TRANSACTIONS => {
678                    if request_kind != LegacyRequestKind::Transactions {
679                        return Err(LegacyGossipError::UnexpectedResponse("MissingTransactions"));
680                    }
681                    reassembler.reject_if_active()?;
682                    for id in decode_tx_ids_response(request_id, frame.payload)? {
683                        transactions.push(InventoryResponse::Missing(id));
684                    }
685                }
686                MSG_RESPONSE_BLOCK_HASHES => {
687                    if request_kind != LegacyRequestKind::FindBlocks {
688                        return Err(LegacyGossipError::UnexpectedResponse("BlockHashes"));
689                    }
690                    reassembler.reject_if_active()?;
691                    block_hashes.extend(decode_hashes_response(request_id, frame.payload)?);
692                }
693                MSG_RESPONSE_BLOCK_HEADERS => {
694                    if request_kind != LegacyRequestKind::FindHeaders {
695                        return Err(LegacyGossipError::UnexpectedResponse("BlockHeaders"));
696                    }
697                    reassembler.reject_if_active()?;
698                    block_headers.extend(decode_block_headers(request_id, frame.payload)?);
699                }
700                MSG_RESPONSE_TRANSACTION_IDS => {
701                    if request_kind != LegacyRequestKind::MempoolTransactionIds {
702                        return Err(LegacyGossipError::UnexpectedResponse("TransactionIds"));
703                    }
704                    reassembler.reject_if_active()?;
705                    transaction_ids.extend(decode_tx_ids_response(request_id, frame.payload)?);
706                }
707                MSG_RESPONSE_PONG => {
708                    if request_kind != LegacyRequestKind::Ping {
709                        return Err(LegacyGossipError::UnexpectedResponse("Pong"));
710                    }
711                    reassembler.reject_if_active()?;
712                    decode_id_only_response(request_id, frame.payload)?;
713                    saw_pong = true;
714                }
715                MSG_RESPONSE_NIL => {
716                    // NIL is the empty-result sentinel for chain discovery and
717                    // mempool queries: the inbound service answers an empty
718                    // FindBlocks/FindHeaders/MempoolTransactionIds (and a queued
719                    // PushTransaction) with Response::Nil, so accept it for those
720                    // kinds and let the final match below turn it into that
721                    // kind's empty response. Inventory fetches and Ping must not
722                    // receive a bare NIL — for BlocksByHash/TransactionsById it
723                    // means the peer has none of the requested items, so reject
724                    // it as unexpected and let the caller fall back to another
725                    // peer (rather than silently returning an empty fetch).
726                    match request_kind {
727                        LegacyRequestKind::FindBlocks
728                        | LegacyRequestKind::FindHeaders
729                        | LegacyRequestKind::MempoolTransactionIds
730                        | LegacyRequestKind::PushTransaction => {}
731                        LegacyRequestKind::Blocks
732                        | LegacyRequestKind::Transactions
733                        | LegacyRequestKind::Ping => {
734                            return Err(LegacyGossipError::UnexpectedResponse("Nil"));
735                        }
736                    }
737                    reassembler.reject_if_active()?;
738                    decode_id_only_response(request_id, frame.payload)?;
739                    saw_nil = true;
740                }
741                message_type => return Err(LegacyGossipError::UnknownMessageType(message_type)),
742            }
743        }
744
745        reassembler.finish()?;
746
747        match request_kind {
748            LegacyRequestKind::Blocks => Ok(Response::Blocks(blocks)),
749            LegacyRequestKind::Transactions => Ok(Response::Transactions(transactions)),
750            LegacyRequestKind::FindBlocks => Ok(Response::BlockHashes(block_hashes)),
751            LegacyRequestKind::FindHeaders => Ok(Response::BlockHeaders(block_headers)),
752            LegacyRequestKind::MempoolTransactionIds => {
753                Ok(Response::TransactionIds(transaction_ids))
754            }
755            LegacyRequestKind::Ping if saw_pong => Ok(Response::Pong(Duration::ZERO)),
756            LegacyRequestKind::PushTransaction if saw_nil => Ok(Response::Nil),
757            LegacyRequestKind::Ping | LegacyRequestKind::PushTransaction => {
758                Err(LegacyGossipError::MissingResponse(request_kind.command()))
759            }
760        }
761    }
762}
763
764fn collect_outbound_tx_ids(
765    ids: impl IntoIterator<Item = UnminedTxId>,
766) -> Result<Vec<UnminedTxId>, LegacyGossipError> {
767    let max_tx_inv_in_message = usize::try_from(MAX_TX_INV_IN_SENT_MESSAGE)?;
768    let ids: Vec<_> = ids.into_iter().take(max_tx_inv_in_message).collect();
769    if ids.is_empty() {
770        return Err(LegacyGossipError::EmptyTransactionAdvertisement);
771    }
772    Ok(ids)
773}
774
775fn truncate_to_inventory_cap<T>(
776    items: impl IntoIterator<Item = T>,
777) -> Result<Vec<T>, LegacyGossipError> {
778    // Legacy `inv` uses the transaction inventory cap for both tx IDs and block hashes.
779    let max = usize::try_from(MAX_TX_INV_IN_SENT_MESSAGE)?;
780    let mut collected = Vec::new();
781    let mut dropped = 0usize;
782
783    for item in items {
784        if collected.len() < max {
785            collected.push(item);
786        } else {
787            dropped = dropped.saturating_add(1);
788        }
789    }
790
791    if dropped > 0 {
792        debug!(
793            dropped,
794            max, "legacy inventory request exceeded item cap; dropping extras"
795        );
796    }
797
798    Ok(collected)
799}
800
801fn write_hash_list(out: &mut Vec<u8>, hashes: &[block::Hash]) -> Result<(), LegacyGossipError> {
802    ensure_inventory_count(hashes.len())?;
803    CompactSizeMessage::try_from(hashes.len())?.zcash_serialize(&mut *out)?;
804    for hash in hashes {
805        hash.zcash_serialize(&mut *out)?;
806    }
807    Ok(())
808}
809
810fn read_hash_list(reader: &mut Cursor<&[u8]>) -> Result<Vec<block::Hash>, LegacyGossipError> {
811    let count = bounded_inventory_count(reader)?;
812    let mut hashes = Vec::with_capacity(count);
813    for _ in 0..count {
814        hashes.push(block::Hash::zcash_deserialize(&mut *reader)?);
815    }
816    Ok(hashes)
817}
818
819fn write_block_locator(
820    out: &mut Vec<u8>,
821    known_blocks: &[block::Hash],
822    stop: Option<block::Hash>,
823) -> Result<(), LegacyGossipError> {
824    ensure_block_locator_count(known_blocks.len())?;
825    CompactSizeMessage::try_from(known_blocks.len())?.zcash_serialize(&mut *out)?;
826    for hash in known_blocks {
827        hash.zcash_serialize(&mut *out)?;
828    }
829    stop.unwrap_or(NO_STOP_HASH).zcash_serialize(&mut *out)?;
830    Ok(())
831}
832
833fn read_block_locator(
834    reader: &mut Cursor<&[u8]>,
835) -> Result<(Vec<block::Hash>, Option<block::Hash>), LegacyGossipError> {
836    let count = bounded_block_locator_count(reader)?;
837    let mut known_blocks = Vec::with_capacity(count);
838    for _ in 0..count {
839        known_blocks.push(block::Hash::zcash_deserialize(&mut *reader)?);
840    }
841    let stop_hash = block::Hash::zcash_deserialize(&mut *reader)?;
842    // A zero stop hash is the legacy locator sentinel for "no stop hash".
843    let stop = (stop_hash != NO_STOP_HASH).then_some(stop_hash);
844    Ok((known_blocks, stop))
845}
846
847fn write_tx_id_list(out: &mut Vec<u8>, ids: &[UnminedTxId]) -> Result<(), LegacyGossipError> {
848    ensure_inventory_count(ids.len())?;
849    CompactSizeMessage::try_from(ids.len())?.zcash_serialize(&mut *out)?;
850    for id in ids {
851        InventoryHash::from(id).zcash_serialize(&mut *out)?;
852    }
853    Ok(())
854}
855
856fn read_tx_id_list(reader: &mut Cursor<&[u8]>) -> Result<Vec<UnminedTxId>, LegacyGossipError> {
857    let count = bounded_inventory_count(reader)?;
858    let mut ids = Vec::with_capacity(count);
859    for _ in 0..count {
860        let inv = InventoryHash::zcash_deserialize(&mut *reader)?;
861        let Some(id) = inv.unmined_tx_id() else {
862            return Err(LegacyGossipError::NonTransactionInventory);
863        };
864        ids.push(id);
865    }
866    Ok(ids)
867}
868
869fn bounded_inventory_count(reader: &mut Cursor<&[u8]>) -> Result<usize, LegacyGossipError> {
870    let count = usize::from(CompactSizeMessage::zcash_deserialize(reader)?);
871    ensure_inventory_count(count)?;
872    Ok(count)
873}
874
875fn ensure_inventory_count(count: usize) -> Result<(), LegacyGossipError> {
876    let max = usize::try_from(MAX_TX_INV_IN_SENT_MESSAGE)?;
877    if count > max {
878        return Err(LegacyGossipError::TooManyInventoryItems(count));
879    }
880    Ok(())
881}
882
883fn bounded_block_locator_count(reader: &mut Cursor<&[u8]>) -> Result<usize, LegacyGossipError> {
884    let count = usize::from(CompactSizeMessage::zcash_deserialize(reader)?);
885    ensure_block_locator_count(count)?;
886    Ok(count)
887}
888
889fn ensure_block_locator_count(count: usize) -> Result<(), LegacyGossipError> {
890    let max = usize::try_from(MAX_BLOCK_LOCATOR_LENGTH)?;
891    if count > max {
892        return Err(LegacyGossipError::TooManyBlockLocatorHashes(count));
893    }
894    Ok(())
895}
896
897fn write_header_list(
898    out: &mut Vec<u8>,
899    headers: &[block::CountedHeader],
900) -> Result<(), LegacyGossipError> {
901    ensure_header_count(headers.len())?;
902    CompactSizeMessage::try_from(headers.len())?.zcash_serialize(&mut *out)?;
903    for header in headers {
904        header.zcash_serialize(&mut *out)?;
905    }
906    Ok(())
907}
908
909fn read_header_list(
910    reader: &mut Cursor<&[u8]>,
911) -> Result<Vec<block::CountedHeader>, LegacyGossipError> {
912    let count = bounded_header_count(reader)?;
913    let mut headers = Vec::with_capacity(count);
914    for _ in 0..count {
915        headers.push(block::CountedHeader::zcash_deserialize(&mut *reader)?);
916    }
917    Ok(headers)
918}
919
920fn bounded_header_count(reader: &mut Cursor<&[u8]>) -> Result<usize, LegacyGossipError> {
921    let count = usize::from(CompactSizeMessage::zcash_deserialize(reader)?);
922    ensure_header_count(count)?;
923    Ok(count)
924}
925
926fn ensure_header_count(count: usize) -> Result<(), LegacyGossipError> {
927    if count > MAX_HEADERS_PER_MESSAGE {
928        return Err(LegacyGossipError::TooManyHeaders(count));
929    }
930    Ok(())
931}
932
933/// Tracks the cumulative size of an encoded legacy response so the inbound
934/// responder aborts a single request's response early instead of buffering an
935/// unbounded `Vec<Frame>` before the first byte is written. The outbound reader
936/// enforces a symmetric per-response cap via `LegacyResponseBudget`.
937#[derive(Default)]
938struct ResponseEncodeBudget {
939    bytes: usize,
940}
941
942impl ResponseEncodeBudget {
943    /// Account for one buffered response frame's payload, rejecting the whole
944    /// response once cumulative payload bytes exceed the responder aggregate
945    /// budget.
946    fn account(&mut self, payload_len: usize) -> Result<(), LegacyGossipError> {
947        self.bytes = self.bytes.saturating_add(payload_len);
948        if self.bytes > LEGACY_RESPONSE_MAX_AGGREGATE_BYTES {
949            return Err(LegacyGossipError::ResponseAggregateBudget(self.bytes));
950        }
951        Ok(())
952    }
953}
954
955/// Push one fully-built response frame, charging it against the aggregate
956/// budget first so an over-budget response aborts before it is retained.
957fn push_response_frame(
958    frames: &mut Vec<Frame>,
959    budget: &mut ResponseEncodeBudget,
960    frame: Frame,
961) -> Result<(), LegacyGossipError> {
962    budget.account(frame.payload.len())?;
963    frames.push(frame);
964    Ok(())
965}
966
967fn push_chunked_response(
968    frames: &mut Vec<Frame>,
969    budget: &mut ResponseEncodeBudget,
970    message_type: u16,
971    request_id: u64,
972    max_frame_bytes: u32,
973    max_message_bytes: u32,
974    bytes: Vec<u8>,
975) -> Result<(), LegacyGossipError> {
976    if bytes.len() > MAX_PROTOCOL_MESSAGE_LEN {
977        return Err(LegacyGossipError::OversizedResponse(bytes.len()));
978    }
979
980    // Size each chunk frame against the *effective* outbound cap: the smaller of
981    // the negotiated frame payload cap (`max_frame_bytes - FRAME_HEADER_BYTES`)
982    // and the peer's negotiated `max_message_bytes`. The handshake clamps the two
983    // caps independently, so sizing against the frame cap alone produces chunk
984    // frames whose payload exceeds the peer's accepted message cap, which the
985    // peer (and our own `write_response_frame`) reject as oversize.
986    let max_payload_bytes = usize::try_from(max_frame_bytes)?
987        .saturating_sub(FRAME_HEADER_BYTES)
988        .min(usize::try_from(max_message_bytes)?);
989    let max_chunk_bytes = max_payload_bytes
990        .checked_sub(RESPONSE_CHUNK_HEADER_BYTES)
991        .ok_or(LegacyGossipError::OversizedResponse(bytes.len()))?;
992    if max_chunk_bytes == 0 {
993        return Err(LegacyGossipError::OversizedResponse(bytes.len()));
994    }
995    let chunk_bytes = LEGACY_RESPONSE_CHUNK_BYTES.min(max_chunk_bytes);
996
997    debug_assert!(!bytes.is_empty());
998    let chunks = bytes.chunks(chunk_bytes).collect::<Vec<_>>();
999
1000    for (index, chunk) in chunks.iter().enumerate() {
1001        let mut payload = Vec::with_capacity(RESPONSE_CHUNK_HEADER_BYTES + chunk.len());
1002        // The stream prelude already has this id, but each response frame repeats it so
1003        // malformed peers are rejected at the frame boundary before decoding payload bytes.
1004        payload.extend_from_slice(&request_id.to_le_bytes());
1005        payload.push(u8::from(index + 1 == chunks.len()));
1006        payload.extend_from_slice(chunk);
1007        // Charge each chunk against the shared budget so a response that
1008        // aggregates many available items aborts mid-encode rather than after
1009        // the whole `Vec<Frame>` has been materialized.
1010        budget.account(payload.len())?;
1011        frames.push(Frame {
1012            message_type,
1013            flags: 0,
1014            payload,
1015        });
1016    }
1017    Ok(())
1018}
1019
1020struct ResponseReassembler {
1021    expected_id: u64,
1022    active: bool,
1023    buffer: Vec<u8>,
1024}
1025
1026impl ResponseReassembler {
1027    fn new(expected_id: u64) -> Self {
1028        Self {
1029            expected_id,
1030            active: false,
1031            buffer: Vec::new(),
1032        }
1033    }
1034
1035    fn accept(&mut self, payload: &[u8]) -> Result<Option<Vec<u8>>, LegacyGossipError> {
1036        let (response_id, is_last, bytes) = decode_response_chunk_header(payload)?;
1037        if response_id != self.expected_id {
1038            return Err(LegacyGossipError::WrongRequestId {
1039                expected: self.expected_id,
1040                actual: response_id,
1041            });
1042        }
1043
1044        let new_len = self
1045            .buffer
1046            .len()
1047            .checked_add(bytes.len())
1048            .ok_or(LegacyGossipError::OversizedResponse(usize::MAX))?;
1049        if new_len > MAX_PROTOCOL_MESSAGE_LEN {
1050            return Err(LegacyGossipError::OversizedResponse(new_len));
1051        }
1052        self.active = true;
1053        self.buffer.extend_from_slice(bytes);
1054
1055        if is_last {
1056            self.active = false;
1057            Ok(Some(std::mem::take(&mut self.buffer)))
1058        } else {
1059            Ok(None)
1060        }
1061    }
1062
1063    fn reject_if_active(&self) -> Result<(), LegacyGossipError> {
1064        if self.active {
1065            Err(LegacyGossipError::IncompleteResponseChunk)
1066        } else {
1067            Ok(())
1068        }
1069    }
1070
1071    fn finish(self) -> Result<(), LegacyGossipError> {
1072        if self.active {
1073            Err(LegacyGossipError::IncompleteResponseChunk)
1074        } else {
1075            Ok(())
1076        }
1077    }
1078}
1079
1080fn decode_response_chunk_header(payload: &[u8]) -> Result<(u64, bool, &[u8]), LegacyGossipError> {
1081    if payload.len() < RESPONSE_CHUNK_HEADER_BYTES {
1082        return Err(LegacyGossipError::TruncatedResponse);
1083    }
1084    let (request_id, payload) = read_request_id(payload)?;
1085    Ok((request_id, payload[0] != 0, &payload[1..]))
1086}
1087
1088fn missing_blocks_frame(
1089    request_id: u64,
1090    hashes: Vec<block::Hash>,
1091) -> Result<Frame, LegacyGossipError> {
1092    let mut payload = Vec::new();
1093    // Missing responses also repeat the id, keeping all response-frame validation local.
1094    payload.extend_from_slice(&request_id.to_le_bytes());
1095    write_hash_list(&mut payload, &hashes)?;
1096    Ok(Frame {
1097        message_type: MSG_RESPONSE_MISSING_BLOCKS,
1098        flags: 0,
1099        payload,
1100    })
1101}
1102
1103fn missing_transactions_frame(
1104    request_id: u64,
1105    ids: Vec<UnminedTxId>,
1106) -> Result<Frame, LegacyGossipError> {
1107    let mut payload = Vec::new();
1108    payload.extend_from_slice(&request_id.to_le_bytes());
1109    write_tx_id_list(&mut payload, &ids)?;
1110    Ok(Frame {
1111        message_type: MSG_RESPONSE_MISSING_TRANSACTIONS,
1112        flags: 0,
1113        payload,
1114    })
1115}
1116
1117fn block_hashes_frame(
1118    request_id: u64,
1119    hashes: Vec<block::Hash>,
1120) -> Result<Frame, LegacyGossipError> {
1121    let mut payload = Vec::new();
1122    payload.extend_from_slice(&request_id.to_le_bytes());
1123    write_hash_list(&mut payload, &hashes)?;
1124    Ok(Frame {
1125        message_type: MSG_RESPONSE_BLOCK_HASHES,
1126        flags: 0,
1127        payload,
1128    })
1129}
1130
1131fn block_headers_frame(
1132    request_id: u64,
1133    headers: Vec<block::CountedHeader>,
1134) -> Result<Frame, LegacyGossipError> {
1135    let mut payload = Vec::new();
1136    payload.extend_from_slice(&request_id.to_le_bytes());
1137    write_header_list(&mut payload, &headers)?;
1138    Ok(Frame {
1139        message_type: MSG_RESPONSE_BLOCK_HEADERS,
1140        flags: 0,
1141        payload,
1142    })
1143}
1144
1145fn transaction_ids_frame(
1146    request_id: u64,
1147    ids: Vec<UnminedTxId>,
1148) -> Result<Frame, LegacyGossipError> {
1149    let mut payload = Vec::new();
1150    payload.extend_from_slice(&request_id.to_le_bytes());
1151    write_tx_id_list(&mut payload, &ids)?;
1152    Ok(Frame {
1153        message_type: MSG_RESPONSE_TRANSACTION_IDS,
1154        flags: 0,
1155        payload,
1156    })
1157}
1158
1159fn id_only_frame(message_type: u16, request_id: u64) -> Frame {
1160    Frame {
1161        message_type,
1162        flags: 0,
1163        payload: request_id.to_le_bytes().to_vec(),
1164    }
1165}
1166
1167fn read_request_id(payload: &[u8]) -> Result<(u64, &[u8]), LegacyGossipError> {
1168    if payload.len() < REQUEST_ID_BYTES {
1169        return Err(LegacyGossipError::TruncatedResponse);
1170    }
1171    let mut id_bytes = [0; REQUEST_ID_BYTES];
1172    id_bytes.copy_from_slice(&payload[..REQUEST_ID_BYTES]);
1173    Ok((u64::from_le_bytes(id_bytes), &payload[REQUEST_ID_BYTES..]))
1174}
1175
1176fn verify_response_id(request_id: u64, payload: &[u8]) -> Result<&[u8], LegacyGossipError> {
1177    let (response_id, payload) = read_request_id(payload)?;
1178    if response_id != request_id {
1179        return Err(LegacyGossipError::WrongRequestId {
1180            expected: request_id,
1181            actual: response_id,
1182        });
1183    }
1184    Ok(payload)
1185}
1186
1187fn decode_hashes_response(
1188    request_id: u64,
1189    payload: Vec<u8>,
1190) -> Result<Vec<block::Hash>, LegacyGossipError> {
1191    let payload = verify_response_id(request_id, &payload)?;
1192    let mut reader = Cursor::new(payload);
1193    let hashes = read_hash_list(&mut reader)?;
1194    reject_trailing(&reader)?;
1195    Ok(hashes)
1196}
1197
1198fn decode_tx_ids_response(
1199    request_id: u64,
1200    payload: Vec<u8>,
1201) -> Result<Vec<UnminedTxId>, LegacyGossipError> {
1202    let payload = verify_response_id(request_id, &payload)?;
1203    let mut reader = Cursor::new(payload);
1204    let ids = read_tx_id_list(&mut reader)?;
1205    reject_trailing(&reader)?;
1206    Ok(ids)
1207}
1208
1209fn decode_block_headers(
1210    request_id: u64,
1211    payload: Vec<u8>,
1212) -> Result<Vec<block::CountedHeader>, LegacyGossipError> {
1213    let payload = verify_response_id(request_id, &payload)?;
1214    let mut reader = Cursor::new(payload);
1215    let headers = read_header_list(&mut reader)?;
1216    reject_trailing(&reader)?;
1217    Ok(headers)
1218}
1219
1220fn decode_id_only_response(request_id: u64, payload: Vec<u8>) -> Result<(), LegacyGossipError> {
1221    let payload = verify_response_id(request_id, &payload)?;
1222    if !payload.is_empty() {
1223        return Err(LegacyGossipError::TrailingBytes);
1224    }
1225    Ok(())
1226}
1227
1228fn outbound_tx_id_slice(ids: &[UnminedTxId]) -> Result<&[UnminedTxId], LegacyGossipError> {
1229    let max_tx_inv_in_message = usize::try_from(MAX_TX_INV_IN_SENT_MESSAGE)?;
1230    let ids = &ids[..ids.len().min(max_tx_inv_in_message)];
1231    if ids.is_empty() {
1232        return Err(LegacyGossipError::EmptyTransactionAdvertisement);
1233    }
1234    Ok(ids)
1235}
1236
1237fn reject_trailing(reader: &Cursor<&[u8]>) -> Result<(), LegacyGossipError> {
1238    let len = u64::try_from(reader.get_ref().len())?;
1239    if reader.position() != len {
1240        return Err(LegacyGossipError::TrailingBytes);
1241    }
1242    Ok(())
1243}
1244
1245/// Broadcasts legacy gossip frames to outbound-ready Zakura peers.
1246#[derive(Clone, Debug)]
1247pub struct ZakuraGossipBroadcast {
1248    first_seen: FirstSeenCache,
1249    outbound: LegacyGossipOutbound,
1250}
1251
1252impl ZakuraGossipBroadcast {
1253    /// Create a broadcaster from a Zakura supervisor.
1254    pub fn new(supervisor: ZakuraSupervisorHandle) -> Self {
1255        let first_seen = first_seen_for_supervisor(&supervisor);
1256        let outbound = outbound_for_supervisor(&supervisor);
1257        Self {
1258            first_seen,
1259            outbound,
1260        }
1261    }
1262
1263    async fn broadcast(
1264        &self,
1265        frame: LegacyGossipFrame,
1266        exclude: Option<&ZakuraPeerId>,
1267    ) -> Result<(), BoxError> {
1268        self.outbound.remember_latest_block(&frame);
1269        self.outbound.send_to_peers(frame, exclude).await
1270    }
1271
1272    async fn record_first_seen(&self, frame: &LegacyGossipFrame) -> Option<LegacyGossipFrame> {
1273        self.first_seen.record_first_seen(frame).await
1274    }
1275
1276    async fn unseen(&self, frame: &LegacyGossipFrame) -> Option<LegacyGossipFrame> {
1277        self.first_seen.unseen(frame).await
1278    }
1279
1280    async fn mark_seen(&self, frame: &LegacyGossipFrame) {
1281        self.first_seen.record_seen(frame).await;
1282    }
1283}
1284
1285#[derive(Clone, Debug, Default)]
1286struct LegacyGossipOutbound {
1287    sessions: Arc<StdMutex<HashMap<ZakuraPeerId, LegacyGossipPeerSession>>>,
1288    latest_block: Arc<StdMutex<Option<block::Hash>>>,
1289}
1290
1291impl LegacyGossipOutbound {
1292    fn insert(&self, session: LegacyGossipPeerSession) -> bool {
1293        let mut sessions = self
1294            .sessions
1295            .lock()
1296            .expect("legacy gossip outbound mutex is never poisoned");
1297        if sessions
1298            .get(session.peer_id())
1299            .is_some_and(|active| active.conn_id() > session.conn_id())
1300        {
1301            return false;
1302        }
1303        sessions.insert(session.peer_id().clone(), session);
1304        true
1305    }
1306
1307    fn remove(&self, peer: &ZakuraPeerId, conn_id: ZakuraConnId) {
1308        let mut sessions = self
1309            .sessions
1310            .lock()
1311            .expect("legacy gossip outbound mutex is never poisoned");
1312        if sessions
1313            .get(peer)
1314            .is_some_and(|session| session.conn_id() == conn_id)
1315        {
1316            sessions.remove(peer);
1317        }
1318    }
1319
1320    #[cfg(test)]
1321    fn contains(&self, peer: &ZakuraPeerId) -> bool {
1322        self.sessions
1323            .lock()
1324            .expect("legacy gossip outbound mutex is never poisoned")
1325            .contains_key(peer)
1326    }
1327
1328    fn remember_latest_block(&self, frame: &LegacyGossipFrame) {
1329        if let LegacyGossipFrame::AdvertiseBlock(hash) = frame {
1330            *self
1331                .latest_block
1332                .lock()
1333                .expect("legacy gossip latest-block mutex is never poisoned") = Some(*hash);
1334        }
1335    }
1336
1337    async fn replay_latest_block_to_peer(
1338        &self,
1339        session: LegacyGossipPeerSession,
1340    ) -> Result<(), BoxError> {
1341        let Some(hash) = *self
1342            .latest_block
1343            .lock()
1344            .expect("legacy gossip latest-block mutex is never poisoned")
1345        else {
1346            return Ok(());
1347        };
1348
1349        session.try_send_advertise_block(hash).map_err(Into::into)
1350    }
1351
1352    async fn send_to_peers(
1353        &self,
1354        frame: LegacyGossipFrame,
1355        exclude: Option<&ZakuraPeerId>,
1356    ) -> Result<(), BoxError> {
1357        let sessions: Vec<_> = {
1358            let sessions = self
1359                .sessions
1360                .lock()
1361                .expect("legacy gossip outbound mutex is never poisoned");
1362            sessions
1363                .iter()
1364                .filter(|(peer_id, _)| !exclude.is_some_and(|exclude| exclude == *peer_id))
1365                .map(|(_peer_id, session)| session.clone())
1366                .collect()
1367        };
1368
1369        send_to_sessions(sessions, frame)
1370    }
1371}
1372
1373#[cfg(test)]
1374fn legacy_gossip_recv_loop_panic_target() -> &'static StdMutex<Option<ZakuraPeerId>> {
1375    static TARGET: OnceLock<StdMutex<Option<ZakuraPeerId>>> = OnceLock::new();
1376    TARGET.get_or_init(Default::default)
1377}
1378
1379#[cfg(test)]
1380fn arm_legacy_gossip_recv_loop_panic(peer: ZakuraPeerId) {
1381    *legacy_gossip_recv_loop_panic_target()
1382        .lock()
1383        .expect("legacy gossip recv-loop panic target mutex is never poisoned") = Some(peer);
1384}
1385
1386#[cfg(test)]
1387fn should_panic_legacy_gossip_recv_loop(peer: &ZakuraPeerId) -> bool {
1388    let mut target = legacy_gossip_recv_loop_panic_target()
1389        .lock()
1390        .expect("legacy gossip recv-loop panic target mutex is never poisoned");
1391    if target.as_ref() == Some(peer) {
1392        *target = None;
1393        true
1394    } else {
1395        false
1396    }
1397}
1398
1399/// Typed ordered legacy gossip sender for one peer.
1400#[derive(Clone, Debug)]
1401pub struct LegacyGossipPeerSession {
1402    peer_id: ZakuraPeerId,
1403    conn_id: ZakuraConnId,
1404    send: FramedSend,
1405}
1406
1407impl LegacyGossipPeerSession {
1408    fn new(peer_id: ZakuraPeerId, conn_id: ZakuraConnId, send: FramedSend) -> Self {
1409        Self {
1410            peer_id,
1411            conn_id,
1412            send,
1413        }
1414    }
1415
1416    /// Authenticated peer identity for this legacy gossip stream.
1417    pub fn peer_id(&self) -> &ZakuraPeerId {
1418        &self.peer_id
1419    }
1420
1421    fn conn_id(&self) -> ZakuraConnId {
1422        self.conn_id
1423    }
1424
1425    /// Send an explicit block advertisement.
1426    pub fn try_send_advertise_block(&self, hash: block::Hash) -> Result<(), OrderedSendError> {
1427        self.try_send_gossip_frame(LegacyGossipFrame::AdvertiseBlock(hash))
1428    }
1429
1430    /// Send explicit transaction id advertisements.
1431    pub fn try_send_advertise_transaction_ids(
1432        &self,
1433        ids: Vec<UnminedTxId>,
1434    ) -> Result<(), OrderedSendError> {
1435        self.try_send_gossip_frame(LegacyGossipFrame::AdvertiseTransactionIds(ids))
1436    }
1437
1438    fn try_send_gossip_frame(&self, frame: LegacyGossipFrame) -> Result<(), OrderedSendError> {
1439        let frame = frame
1440            .encode_frame()
1441            .map_err(|error| OrderedSendError::Encode(Box::new(error)))?;
1442        match self.send.try_send(frame) {
1443            Ok(()) => Ok(()),
1444            Err(mpsc::error::TrySendError::Full(_frame)) => Err(OrderedSendError::Full),
1445            Err(mpsc::error::TrySendError::Closed(_frame)) => Err(OrderedSendError::Closed),
1446        }
1447    }
1448}
1449
1450static LEGACY_GOSSIP_OUTBOUND_BY_SUPERVISOR: OnceLock<
1451    std::sync::Mutex<HashMap<u64, LegacyGossipOutbound>>,
1452> = OnceLock::new();
1453
1454fn outbound_for_supervisor(supervisor: &ZakuraSupervisorHandle) -> LegacyGossipOutbound {
1455    let registry = LEGACY_GOSSIP_OUTBOUND_BY_SUPERVISOR.get_or_init(Default::default);
1456    let mut registry = registry
1457        .lock()
1458        .expect("legacy gossip outbound registry mutex is never poisoned");
1459    registry.entry(supervisor.id()).or_default().clone()
1460}
1461
1462fn first_seen_for_supervisor(supervisor: &ZakuraSupervisorHandle) -> FirstSeenCache {
1463    let registry = FIRST_SEEN_BY_SUPERVISOR.get_or_init(Default::default);
1464    let mut registry = registry
1465        .lock()
1466        .expect("legacy gossip first-seen registry mutex is never poisoned");
1467    registry
1468        .entry(supervisor.id())
1469        .or_insert_with(|| FirstSeenCache::new(DEFAULT_FIRST_SEEN_CAPACITY, DEFAULT_FIRST_SEEN_TTL))
1470        .clone()
1471}
1472
1473fn send_to_sessions(
1474    sessions: Vec<LegacyGossipPeerSession>,
1475    frame: LegacyGossipFrame,
1476) -> Result<(), BoxError> {
1477    let mut first_error = None;
1478    for session in sessions {
1479        let result = match &frame {
1480            LegacyGossipFrame::AdvertiseBlock(hash) => session.try_send_advertise_block(*hash),
1481            LegacyGossipFrame::AdvertiseTransactionIds(ids) => {
1482                session.try_send_advertise_transaction_ids(ids.clone())
1483            }
1484        };
1485
1486        if let Err(error) = result {
1487            debug!(
1488                peer = ?session.peer_id(),
1489                ?error,
1490                "failed to queue Zakura legacy gossip frame"
1491            );
1492            if first_error.is_none() {
1493                first_error = Some(error.into());
1494            }
1495        }
1496    }
1497
1498    if let Some(error) = first_error {
1499        return Err(error);
1500    }
1501    Ok(())
1502}
1503
1504/// Tower service that adapts legacy Zebra gossip requests onto Zakura streams.
1505#[derive(Clone, Debug)]
1506pub struct LegacyGossipAdapter {
1507    broadcast: ZakuraGossipBroadcast,
1508}
1509
1510impl LegacyGossipAdapter {
1511    /// Create an adapter backed by the given Zakura supervisor.
1512    pub fn new(supervisor: ZakuraSupervisorHandle) -> Self {
1513        Self {
1514            broadcast: ZakuraGossipBroadcast::new(supervisor),
1515        }
1516    }
1517}
1518
1519impl Service<Request> for LegacyGossipAdapter {
1520    type Response = Response;
1521    type Error = BoxError;
1522    type Future = Pin<Box<dyn Future<Output = Result<Response, BoxError>> + Send + 'static>>;
1523
1524    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1525        Poll::Ready(Ok(()))
1526    }
1527
1528    fn call(&mut self, request: Request) -> Self::Future {
1529        let broadcast = self.broadcast.clone();
1530        Box::pin(async move {
1531            let frame = LegacyGossipFrame::from_request(request)?;
1532            let Some(frame) = broadcast.record_first_seen(&frame).await else {
1533                return Ok(Response::Nil);
1534            };
1535            broadcast.broadcast(frame, None).await?;
1536            Ok(Response::Nil)
1537        })
1538    }
1539}
1540
1541/// Tower service that adapts legacy request/response traffic onto Zakura streams.
1542///
1543/// Legacy `Peers` discovery is intentionally not adapted here. Zakura v1 controlled
1544/// networks use configured [`crate::zakura::ZakuraConfig::bootstrap_peers`] until
1545/// native Zakura discovery is available, keeping this compatibility layer narrow.
1546#[derive(Clone, Debug)]
1547pub struct LegacyRequestAdapter {
1548    client: ZakuraRequestClient,
1549}
1550
1551impl LegacyRequestAdapter {
1552    /// Create an adapter backed by the given Zakura supervisor.
1553    pub fn new(supervisor: ZakuraSupervisorHandle) -> Self {
1554        Self {
1555            client: ZakuraRequestClient::new(supervisor),
1556        }
1557    }
1558
1559    /// Create an adapter backed by the given Zakura supervisor and trace emitter.
1560    pub fn new_with_trace(supervisor: ZakuraSupervisorHandle, trace: ZakuraTrace) -> Self {
1561        Self {
1562            client: ZakuraRequestClient::new_with_trace(supervisor, trace),
1563        }
1564    }
1565
1566    #[cfg(test)]
1567    fn new_with_timeout(supervisor: ZakuraSupervisorHandle, request_timeout: Duration) -> Self {
1568        Self {
1569            client: ZakuraRequestClient::new_with_timeout(supervisor, request_timeout),
1570        }
1571    }
1572
1573    /// Request inventory, preferring the Zakura peer that advertised it when known.
1574    pub async fn request_from_source(
1575        &self,
1576        request: Request,
1577        source: Option<PeerSource>,
1578    ) -> Result<Response, BoxError> {
1579        let frame = LegacyRequestFrame::from_request(request)?;
1580        self.client.request(frame, source, false).await
1581    }
1582}
1583
1584impl Service<Request> for LegacyRequestAdapter {
1585    type Response = Response;
1586    type Error = BoxError;
1587    type Future = Pin<Box<dyn Future<Output = Result<Response, BoxError>> + Send + 'static>>;
1588
1589    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1590        Poll::Ready(Ok(()))
1591    }
1592
1593    fn call(&mut self, request: Request) -> Self::Future {
1594        let client = self.client.clone();
1595        Box::pin(async move {
1596            let source = request.inventory_source();
1597            let retry_source_missing = source.is_some();
1598            let frame = LegacyRequestFrame::from_request(request)?;
1599            client.request(frame, source, retry_source_missing).await
1600        })
1601    }
1602}
1603
1604/// Which transports a [`ZakuraDualStackService`] request fans out to.
1605#[derive(Copy, Clone)]
1606enum DualStackRoute {
1607    /// Block/transaction advertisements: fan out to both stacks, fire-and-forget.
1608    Advertise,
1609    /// Requests the Zakura adapter can serve (inventory fetches, chain-sync
1610    /// discovery, and mempool data): legacy peer set first, then Zakura fallback.
1611    ///
1612    /// This must cover every request a node needs to follow the chain, otherwise
1613    /// a node whose only peer was upgraded to Zakura sends them to its empty
1614    /// legacy peer set and stalls (its syncer can never obtain tips or fetch
1615    /// blocks). The set mirrors `LegacyRequestFrame::from_request`.
1616    LegacyFirstThenZakura,
1617    /// Anything the Zakura adapter cannot serve (e.g. peer-address discovery):
1618    /// legacy peer set only.
1619    Passthrough,
1620}
1621
1622/// Tower service that fans legacy gossip/inventory requests across both the
1623/// legacy TCP peer set and the Zakura P2P-v2 transport.
1624///
1625/// This is the production seam that turns the Zakura legacy-gossip adapters from
1626/// an isolated library into a live dual-stack: it wraps the legacy `peer_set`
1627/// returned by [`crate::init`] when `v2_p2p` is enabled, so the syncer, mempool,
1628/// and inbound downloads transparently gossip and fetch over Zakura too.
1629///
1630/// Routing (with `legacy_enabled` = `config.legacy_p2p()`):
1631/// - Advertisements fan out concurrently to the legacy peer set (when
1632///   `legacy_enabled`) and the Zakura gossip adapter. Advertise is
1633///   fire-and-forget, so per-path errors are logged and swallowed and the
1634///   composite always reports success — one stack failing never fails the local
1635///   advertisement.
1636/// - Inventory fetches hit the legacy peer set first and fall back to Zakura
1637///   when the legacy response is all-missing or errors. With `legacy_enabled`
1638///   false they route straight to Zakura.
1639/// - Every other request passes through to the legacy peer set.
1640///
1641/// The gossip and request adapters MUST be built from the same
1642/// [`ZakuraSupervisorHandle`] that backs the inbound [`LegacyGossipSink`], so the
1643/// per-supervisor first-seen cache (see [`first_seen_for_supervisor`]) dedups
1644/// locally originated gossip against echoes that peers send back to us.
1645#[derive(Clone)]
1646pub(crate) struct ZakuraDualStackService<L> {
1647    legacy: L,
1648    gossip: LegacyGossipAdapter,
1649    request: LegacyRequestAdapter,
1650    legacy_enabled: bool,
1651}
1652
1653impl<L> ZakuraDualStackService<L> {
1654    /// Wrap `legacy` with Zakura gossip/request adapters backed by `supervisor`.
1655    ///
1656    /// `supervisor` must be the endpoint's supervisor so the first-seen cache is
1657    /// shared with the inbound sink.
1658    #[cfg(test)]
1659    pub(crate) fn new(legacy: L, supervisor: ZakuraSupervisorHandle, legacy_enabled: bool) -> Self {
1660        Self {
1661            legacy,
1662            gossip: LegacyGossipAdapter::new(supervisor.clone()),
1663            request: LegacyRequestAdapter::new(supervisor),
1664            legacy_enabled,
1665        }
1666    }
1667
1668    /// Wrap `legacy` with Zakura adapters and a trace emitter.
1669    pub(crate) fn new_with_trace(
1670        legacy: L,
1671        supervisor: ZakuraSupervisorHandle,
1672        legacy_enabled: bool,
1673        trace: ZakuraTrace,
1674    ) -> Self {
1675        Self {
1676            legacy,
1677            gossip: LegacyGossipAdapter::new(supervisor.clone()),
1678            request: LegacyRequestAdapter::new_with_trace(supervisor, trace),
1679            legacy_enabled,
1680        }
1681    }
1682}
1683
1684impl<L> Service<Request> for ZakuraDualStackService<L>
1685where
1686    L: Service<Request, Response = Response, Error = BoxError> + Clone + Send + 'static,
1687    L::Future: Send + 'static,
1688{
1689    type Response = Response;
1690    type Error = BoxError;
1691    type Future = Pin<Box<dyn Future<Output = Result<Response, BoxError>> + Send + 'static>>;
1692
1693    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1694        // The route is request-dependent, so legacy readiness is awaited inside
1695        // the branches that actually use legacy. Source-aware Zakura requests
1696        // must not block behind an empty or unready legacy peer set.
1697        std::task::ready!(self.gossip.poll_ready(cx))?;
1698        std::task::ready!(self.request.poll_ready(cx))?;
1699        Poll::Ready(Ok(()))
1700    }
1701
1702    fn call(&mut self, request: Request) -> Self::Future {
1703        let route = match &request {
1704            Request::AdvertiseBlock(..)
1705            | Request::AdvertiseBlockToAll(..)
1706            | Request::AdvertiseTransactionIds(..) => DualStackRoute::Advertise,
1707            Request::BlocksByHash(..)
1708            | Request::BlocksByHashFrom { .. }
1709            | Request::TransactionsById(..)
1710            | Request::TransactionsByIdFrom { .. }
1711            | Request::FindBlocks { .. }
1712            | Request::FindHeaders { .. }
1713            | Request::MempoolTransactionIds
1714            | Request::PushTransaction(..) => DualStackRoute::LegacyFirstThenZakura,
1715            _ => DualStackRoute::Passthrough,
1716        };
1717
1718        let legacy_enabled = self.legacy_enabled;
1719        let mut gossip = self.gossip.clone();
1720        let mut request_adapter = self.request.clone();
1721        let mut legacy = self.legacy.clone();
1722
1723        Box::pin(async move {
1724            match route {
1725                DualStackRoute::Advertise => {
1726                    // Fan out concurrently so a slow/empty Zakura path can't delay
1727                    // legacy advertise, and vice versa. The gossip adapter bounds
1728                    // each peer send with its own per-peer fanout timeout.
1729                    let gossip_fut = gossip.call(request.clone());
1730                    let legacy_fut = async move {
1731                        if legacy_enabled {
1732                            Some(match legacy.ready().await {
1733                                Ok(service) => service.call(request).await,
1734                                Err(error) => Err(error),
1735                            })
1736                        } else {
1737                            None
1738                        }
1739                    };
1740                    let (legacy_res, gossip_res) = futures::join!(legacy_fut, gossip_fut);
1741                    if let Some(Err(error)) = legacy_res {
1742                        debug!(%error, "legacy advertise path failed; continuing");
1743                    }
1744                    if let Err(error) = gossip_res {
1745                        debug!(%error, "zakura advertise path failed; continuing");
1746                    }
1747                    Ok(Response::Nil)
1748                }
1749                DualStackRoute::LegacyFirstThenZakura => {
1750                    if matches!(request.inventory_source(), Some(PeerSource::Zakura(_))) {
1751                        return request_adapter.call(request).await;
1752                    }
1753                    if !legacy_enabled {
1754                        return request_adapter.call(request).await;
1755                    }
1756                    // The legacy peer set is buffered, so it queues this fetch
1757                    // even when it has no ready peer (e.g. every legacy peer was
1758                    // upgraded to Zakura). Bound the legacy attempt so we fall
1759                    // back to the Zakura path instead of blocking forever.
1760                    let legacy_attempt = timeout(DUAL_STACK_LEGACY_INVENTORY_TIMEOUT, async {
1761                        legacy.ready().await?.call(request.clone()).await
1762                    })
1763                    .await;
1764                    match legacy_attempt {
1765                        Ok(Ok(response)) if !all_inventory_missing(&response) => Ok(response),
1766                        Ok(Ok(response)) => match request_adapter.call(request).await {
1767                            Ok(zakura) if !all_inventory_missing(&zakura) => Ok(zakura),
1768                            _ => Ok(response),
1769                        },
1770                        Ok(Err(legacy_error)) => match request_adapter.call(request).await {
1771                            Ok(zakura) => Ok(zakura),
1772                            Err(_) => Err(legacy_error),
1773                        },
1774                        // Legacy timed out (no ready peer): use Zakura.
1775                        Err(_) => request_adapter.call(request).await,
1776                    }
1777                }
1778                DualStackRoute::Passthrough => legacy.ready().await?.call(request).await,
1779            }
1780        })
1781    }
1782}
1783
1784/// Outbound Zakura inventory request client.
1785#[derive(Clone, Debug)]
1786pub struct ZakuraRequestClient {
1787    supervisor: ZakuraSupervisorHandle,
1788    request_timeout: Duration,
1789    trace: ZakuraTrace,
1790}
1791
1792impl ZakuraRequestClient {
1793    /// Create a client from a Zakura supervisor.
1794    pub fn new(supervisor: ZakuraSupervisorHandle) -> Self {
1795        Self::new_with_trace(supervisor, ZakuraTrace::noop())
1796    }
1797
1798    /// Create a client from a Zakura supervisor and trace emitter.
1799    pub fn new_with_trace(supervisor: ZakuraSupervisorHandle, trace: ZakuraTrace) -> Self {
1800        Self {
1801            supervisor,
1802            request_timeout: LEGACY_REQUEST_TIMEOUT,
1803            trace,
1804        }
1805    }
1806
1807    #[cfg(test)]
1808    fn new_with_timeout(supervisor: ZakuraSupervisorHandle, request_timeout: Duration) -> Self {
1809        Self {
1810            supervisor,
1811            request_timeout,
1812            trace: ZakuraTrace::noop(),
1813        }
1814    }
1815
1816    async fn request(
1817        &self,
1818        frame: LegacyRequestFrame,
1819        source: Option<PeerSource>,
1820        retry_source_missing: bool,
1821    ) -> Result<Response, BoxError> {
1822        let preferred = match source {
1823            Some(PeerSource::Zakura(peer_id)) => Some(peer_id),
1824            _ => None,
1825        };
1826
1827        let handles = self.ready_handles().await?;
1828        let Some(primary) = select_handle(&handles, preferred.as_ref()) else {
1829            return Err("no ready Zakura peer for legacy inventory request".into());
1830        };
1831
1832        let request_kind = frame.kind();
1833        let first_result = self
1834            .request_one(primary.clone(), frame.clone(), request_kind)
1835            .await;
1836        match first_result {
1837            Ok(response) if !all_inventory_missing(&response) => Ok(response),
1838            Ok(response) => {
1839                let Some(fallback) = select_fallback_handle(&handles, primary.peer_id()) else {
1840                    if retry_source_missing && preferred.is_some() {
1841                        return self
1842                            .retry_source_missing(primary, frame, request_kind, response)
1843                            .await;
1844                    }
1845                    return Ok(response);
1846                };
1847                self.request_one(fallback, frame, request_kind)
1848                    .await
1849                    .or(Ok(response))
1850            }
1851            Err(error) => {
1852                let Some(fallback) = select_fallback_handle(&handles, primary.peer_id()) else {
1853                    return Err(error);
1854                };
1855                self.request_one(fallback, frame, request_kind).await
1856            }
1857        }
1858    }
1859
1860    async fn retry_source_missing(
1861        &self,
1862        handle: ZakuraPeerHandle,
1863        frame: LegacyRequestFrame,
1864        request_kind: LegacyRequestKind,
1865        initial_response: Response,
1866    ) -> Result<Response, BoxError> {
1867        let mut last_response = initial_response;
1868        for _ in 0..SOURCE_INVENTORY_MISSING_RETRIES {
1869            sleep(SOURCE_INVENTORY_MISSING_RETRY_DELAY).await;
1870            match self
1871                .request_one(handle.clone(), frame.clone(), request_kind)
1872                .await
1873            {
1874                Ok(response) if !all_inventory_missing(&response) => return Ok(response),
1875                Ok(response) => last_response = response,
1876                Err(error) => return Err(error),
1877            }
1878        }
1879        Ok(last_response)
1880    }
1881
1882    async fn ready_handles(&self) -> Result<Vec<ZakuraPeerHandle>, BoxError> {
1883        let mut registered = self.supervisor.subscribe();
1884
1885        timeout(LEGACY_REQUEST_READY_TIMEOUT, async {
1886            loop {
1887                let handles = self.supervisor.outbound_peer_handles().await;
1888                if !handles.is_empty() {
1889                    return Ok(handles);
1890                }
1891
1892                if registered.changed().await.is_err() {
1893                    return Err("Zakura peer set closed before a peer was ready".into());
1894                }
1895            }
1896        })
1897        .await
1898        .map_err(|_| -> BoxError { "no ready Zakura peer for legacy inventory request".into() })?
1899    }
1900
1901    async fn request_one(
1902        &self,
1903        handle: ZakuraPeerHandle,
1904        frame: LegacyRequestFrame,
1905        request_kind: LegacyRequestKind,
1906    ) -> Result<Response, BoxError> {
1907        let request_id = NEXT_LEGACY_REQUEST_ID.fetch_add(1, Ordering::Relaxed);
1908        // Capture the requested block hashes (if any) before consuming the frame,
1909        // so the response can be bound to a hash we actually asked for.
1910        let requested_block_hashes: Option<HashSet<block::Hash>> = match &frame {
1911            LegacyRequestFrame::BlocksByHash(hashes) => Some(hashes.iter().copied().collect()),
1912            _ => None,
1913        };
1914        let frame = frame.encode_frame()?;
1915        trace_legacy_request_start(
1916            &self.trace,
1917            "outbound.request",
1918            Some(handle.peer_id()),
1919            request_id,
1920            request_kind,
1921            frame.message_type,
1922        );
1923        let started_at = Instant::now();
1924        let response = match timeout(
1925            self.request_timeout,
1926            handle.request(
1927                ZAKURA_STREAM_LEGACY_REQUESTS,
1928                request_id,
1929                frame.message_type,
1930                frame.flags,
1931                frame.payload,
1932            ),
1933        )
1934        .await
1935        {
1936            Ok(Ok(response)) => response,
1937            Ok(Err(error)) => {
1938                trace_legacy_request_error(
1939                    &self.trace,
1940                    "outbound.error",
1941                    Some(handle.peer_id()),
1942                    request_id,
1943                    request_kind.command(),
1944                    error.to_string(),
1945                );
1946                return Err(error);
1947            }
1948            Err(_) => {
1949                let error: BoxError = format!(
1950                    "Zakura legacy request timed out for peer {:?}",
1951                    handle.peer_id()
1952                )
1953                .into();
1954                trace_legacy_request_error(
1955                    &self.trace,
1956                    "outbound.error",
1957                    Some(handle.peer_id()),
1958                    request_id,
1959                    request_kind.command(),
1960                    error.to_string(),
1961                );
1962                return Err(error);
1963            }
1964        };
1965        let mut response = match LegacyResponseCodec::decode_response(
1966            request_id,
1967            request_kind,
1968            response,
1969            requested_block_hashes.as_ref(),
1970        ) {
1971            Ok(response) => response,
1972            Err(error) => {
1973                trace_legacy_request_error(
1974                    &self.trace,
1975                    "outbound.decode_error",
1976                    Some(handle.peer_id()),
1977                    request_id,
1978                    request_kind.command(),
1979                    error.to_string(),
1980                );
1981                return Err(error.into());
1982            }
1983        };
1984        if request_kind == LegacyRequestKind::Ping {
1985            // The responder can only acknowledge a Ping; the requester stamps the RTT.
1986            response = Response::Pong(started_at.elapsed());
1987        }
1988        trace_legacy_request_response(
1989            &self.trace,
1990            "outbound.response",
1991            Some(handle.peer_id()),
1992            request_id,
1993            request_kind.command(),
1994            &response,
1995        );
1996        Ok(response)
1997    }
1998}
1999
2000fn select_handle(
2001    handles: &[ZakuraPeerHandle],
2002    preferred: Option<&ZakuraPeerId>,
2003) -> Option<ZakuraPeerHandle> {
2004    // v1 controlled-network routing: source-less chain-sync requests use the
2005    // first ready Zakura peer. Production spreading/rotation belongs with the
2006    // future zakurad wiring milestone.
2007    preferred
2008        .and_then(|peer_id| {
2009            handles
2010                .iter()
2011                .find(|handle| handle.peer_id() == peer_id)
2012                .cloned()
2013        })
2014        .or_else(|| handles.first().cloned())
2015}
2016
2017fn select_fallback_handle(
2018    handles: &[ZakuraPeerHandle],
2019    primary: &ZakuraPeerId,
2020) -> Option<ZakuraPeerHandle> {
2021    handles
2022        .iter()
2023        .find(|handle| handle.peer_id() != primary)
2024        .cloned()
2025}
2026
2027fn all_inventory_missing(response: &Response) -> bool {
2028    match response {
2029        Response::Blocks(blocks) => {
2030            blocks.is_empty() || blocks.iter().all(|block| block.is_missing())
2031        }
2032        Response::Transactions(transactions) => {
2033            transactions.is_empty()
2034                || transactions
2035                    .iter()
2036                    .all(|transaction| transaction.is_missing())
2037        }
2038        Response::BlockHashes(hashes) => hashes.is_empty(),
2039        Response::BlockHeaders(headers) => headers.is_empty(),
2040        Response::TransactionIds(ids) => ids.is_empty(),
2041        _ => false,
2042    }
2043}
2044
2045fn trace_legacy_request_start(
2046    trace: &ZakuraTrace,
2047    event: &'static str,
2048    peer: Option<&ZakuraPeerId>,
2049    request_id: u64,
2050    request_kind: LegacyRequestKind,
2051    message_type: u16,
2052) {
2053    trace.emit_with(LEGACY_REQUEST_TABLE, |row| {
2054        insert_trace_str(row, "event", event);
2055        insert_trace_peer(row, peer);
2056        insert_trace_u64(row, "request_id", request_id);
2057        insert_trace_str(row, "request", request_kind.command());
2058        insert_trace_u64(row, "message_type", u64::from(message_type));
2059    });
2060}
2061
2062fn trace_legacy_request_response(
2063    trace: &ZakuraTrace,
2064    event: &'static str,
2065    peer: Option<&ZakuraPeerId>,
2066    request_id: u64,
2067    request: &'static str,
2068    response: &Response,
2069) {
2070    let (response_kind, item_count, missing_count) = response_summary(response);
2071    trace.emit_with(LEGACY_REQUEST_TABLE, |row| {
2072        insert_trace_str(row, "event", event);
2073        insert_trace_peer(row, peer);
2074        insert_trace_u64(row, "request_id", request_id);
2075        insert_trace_str(row, "request", request);
2076        insert_trace_str(row, "response", response_kind);
2077        insert_trace_u64(row, "item_count", item_count);
2078        insert_trace_u64(row, "missing_count", missing_count);
2079    });
2080}
2081
2082fn trace_legacy_request_error(
2083    trace: &ZakuraTrace,
2084    event: &'static str,
2085    peer: Option<&ZakuraPeerId>,
2086    request_id: u64,
2087    request: &'static str,
2088    error: String,
2089) {
2090    trace.emit_with(LEGACY_REQUEST_TABLE, |row| {
2091        insert_trace_str(row, "event", event);
2092        insert_trace_peer(row, peer);
2093        insert_trace_u64(row, "request_id", request_id);
2094        insert_trace_str(row, "request", request);
2095        row.insert("error".to_string(), Value::String(error));
2096    });
2097}
2098
2099fn response_summary(response: &Response) -> (&'static str, u64, u64) {
2100    match response {
2101        Response::Blocks(blocks) => (
2102            "Blocks",
2103            bounded_u64(blocks.len()),
2104            bounded_u64(blocks.iter().filter(|block| block.is_missing()).count()),
2105        ),
2106        Response::Transactions(transactions) => (
2107            "Transactions",
2108            bounded_u64(transactions.len()),
2109            bounded_u64(
2110                transactions
2111                    .iter()
2112                    .filter(|transaction| transaction.is_missing())
2113                    .count(),
2114            ),
2115        ),
2116        Response::BlockHashes(hashes) => ("BlockHashes", bounded_u64(hashes.len()), 0),
2117        Response::BlockHeaders(headers) => ("BlockHeaders", bounded_u64(headers.len()), 0),
2118        Response::TransactionIds(ids) => ("TransactionIds", bounded_u64(ids.len()), 0),
2119        Response::Pong(_) => ("Pong", 1, 0),
2120        Response::Nil => ("Nil", 0, 0),
2121        response => (response.command(), 0, 0),
2122    }
2123}
2124
2125fn insert_trace_peer(row: &mut Map<String, Value>, peer: Option<&ZakuraPeerId>) {
2126    row.insert(
2127        "peer".to_string(),
2128        peer.map_or(Value::Null, |peer| Value::String(trace_peer_label(peer))),
2129    );
2130}
2131
2132fn insert_trace_str(row: &mut Map<String, Value>, key: &'static str, value: &'static str) {
2133    row.insert(key.to_string(), Value::String(value.to_string()));
2134}
2135
2136fn insert_trace_u64(row: &mut Map<String, Value>, key: &'static str, value: u64) {
2137    row.insert(key.to_string(), Value::Number(Number::from(value)));
2138}
2139
2140fn bounded_u64(value: usize) -> u64 {
2141    u64::try_from(value).unwrap_or(u64::MAX)
2142}
2143
2144#[derive(Clone, Debug)]
2145struct LegacyGossipForwarder {
2146    broadcast: ZakuraGossipBroadcast,
2147    /// Short-lived dedup of inventory already handed to the inbound service but not
2148    /// yet confirmed `mark_seen`. Bounds duplicate work when the service is slow,
2149    /// not ready, or erroring (see [`LEGACY_GOSSIP_DUPLICATE_COOLDOWN`]).
2150    attempt_cooldown: FirstSeenCache,
2151}
2152
2153impl LegacyGossipForwarder {
2154    fn new(supervisor: ZakuraSupervisorHandle) -> Self {
2155        Self {
2156            broadcast: ZakuraGossipBroadcast::new(supervisor),
2157            attempt_cooldown: FirstSeenCache::new(
2158                DEFAULT_FIRST_SEEN_CAPACITY,
2159                LEGACY_GOSSIP_DUPLICATE_COOLDOWN,
2160            ),
2161        }
2162    }
2163
2164    async fn unseen(&self, frame: &LegacyGossipFrame) -> Option<LegacyGossipFrame> {
2165        self.broadcast.unseen(frame).await
2166    }
2167
2168    /// Return the subset of `frame`'s inventory not currently suppressed by a recent
2169    /// expensive failed attempt. Read-only: the cooldown is populated only by
2170    /// [`Self::note_failed_attempt`], so fast failures remain immediately retryable.
2171    async fn fresh_attempt(&self, frame: &LegacyGossipFrame) -> Option<LegacyGossipFrame> {
2172        self.attempt_cooldown.unseen(frame).await
2173    }
2174
2175    /// Record `frame`'s inventory in the cooldown when a failed attempt consumed at
2176    /// least [`LEGACY_GOSSIP_EXPENSIVE_ATTEMPT`], so queued duplicates skip re-paying
2177    /// a slow readiness/call. Cheap failures are left immediately retryable.
2178    async fn note_failed_attempt(&self, frame: &LegacyGossipFrame, elapsed: Duration) {
2179        if elapsed >= LEGACY_GOSSIP_EXPENSIVE_ATTEMPT {
2180            self.attempt_cooldown.record_seen(frame).await;
2181        }
2182    }
2183
2184    async fn mark_seen(&self, frame: &LegacyGossipFrame) {
2185        self.broadcast.mark_seen(frame).await;
2186    }
2187
2188    async fn forward(
2189        &self,
2190        peer_id: &ZakuraPeerId,
2191        frame: LegacyGossipFrame,
2192    ) -> Result<(), BoxError> {
2193        self.broadcast.broadcast(frame, Some(peer_id)).await
2194    }
2195}
2196
2197/// Inbound sink that adapts Zakura gossip frames into Zebra's legacy inbound service.
2198#[derive(Debug)]
2199pub struct LegacyGossipSink {
2200    inbound_tx: mpsc::Sender<LegacyInboundWork>,
2201    outbound: LegacyGossipOutbound,
2202    trace: ZakuraTrace,
2203}
2204
2205impl LegacyGossipSink {
2206    /// Spawn a bounded inbound worker around the existing legacy inbound service.
2207    pub fn spawn<Inbound>(inbound: Inbound, supervisor: ZakuraSupervisorHandle) -> Self
2208    where
2209        Inbound: Service<Request, Response = Response, Error = BoxError> + Send + Clone + 'static,
2210        Inbound::Future: Send + 'static,
2211    {
2212        Self::spawn_with_trace(inbound, supervisor, ZakuraTrace::noop())
2213    }
2214
2215    /// Spawn a bounded inbound worker with a trace emitter.
2216    pub fn spawn_with_trace<Inbound>(
2217        inbound: Inbound,
2218        supervisor: ZakuraSupervisorHandle,
2219        trace: ZakuraTrace,
2220    ) -> Self
2221    where
2222        Inbound: Service<Request, Response = Response, Error = BoxError> + Send + Clone + 'static,
2223        Inbound::Future: Send + 'static,
2224    {
2225        let (inbound_tx, inbound_rx) = mpsc::channel(LEGACY_GOSSIP_INBOUND_QUEUE);
2226        tokio::spawn(legacy_gossip_worker(
2227            inbound,
2228            inbound_rx,
2229            LegacyGossipForwarder::new(supervisor.clone()),
2230            trace.clone(),
2231        ));
2232        let outbound = outbound_for_supervisor(&supervisor);
2233        Self {
2234            inbound_tx,
2235            outbound,
2236            trace,
2237        }
2238    }
2239}
2240
2241impl LegacyGossipSink {
2242    fn enqueue_gossip_frame(
2243        inbound_tx: &mpsc::Sender<LegacyInboundWork>,
2244        peer_id: ZakuraPeerId,
2245        frame: Frame,
2246    ) -> Result<(), SinkReject> {
2247        let frame = LegacyGossipFrame::decode_frame(frame).map_err(SinkReject::protocol)?;
2248        match inbound_tx.try_send(LegacyInboundWork::Gossip(LegacyGossipInbound {
2249            peer_id,
2250            frame,
2251        })) {
2252            Ok(()) => Ok(()),
2253            Err(mpsc::error::TrySendError::Full(_)) => {
2254                debug!("legacy gossip inbound queue full: dropping frame");
2255                Ok(())
2256            }
2257            Err(mpsc::error::TrySendError::Closed(_)) => {
2258                Err(SinkReject::local("legacy gossip inbound queue closed"))
2259            }
2260        }
2261    }
2262
2263    fn deliver(
2264        &self,
2265        peer_id: ZakuraPeerId,
2266        stream_kind: u16,
2267        frame: Frame,
2268    ) -> Result<(), SinkReject> {
2269        if stream_kind != ZAKURA_STREAM_GOSSIP {
2270            return Ok(());
2271        }
2272
2273        Self::enqueue_gossip_frame(&self.inbound_tx, peer_id, frame)
2274    }
2275
2276    fn request<'a>(
2277        &'a self,
2278        peer_id: ZakuraPeerId,
2279        stream_kind: u16,
2280        request_id: u64,
2281        max_frame_bytes: u32,
2282        max_message_bytes: u32,
2283        frame: Frame,
2284    ) -> BoxRunFuture<'a, Result<Vec<Frame>, SinkReject>> {
2285        Box::pin(async move {
2286            if stream_kind != ZAKURA_STREAM_LEGACY_REQUESTS {
2287                return Err(SinkReject::protocol(
2288                    "unsupported legacy request stream kind",
2289                ));
2290            }
2291
2292            let frame = LegacyRequestFrame::decode_frame(frame).map_err(SinkReject::protocol)?;
2293            let (response_tx, response_rx) = oneshot::channel();
2294            match self
2295                .inbound_tx
2296                .try_send(LegacyInboundWork::Request(LegacyRequestInbound {
2297                    peer_id: peer_id.clone(),
2298                    request_id,
2299                    frame,
2300                    response_tx,
2301                })) {
2302                Ok(()) => {}
2303                Err(mpsc::error::TrySendError::Full(_)) => {
2304                    return Err(SinkReject::local("legacy request inbound queue full"));
2305                }
2306                Err(mpsc::error::TrySendError::Closed(_)) => {
2307                    return Err(SinkReject::local("legacy request inbound queue closed"));
2308                }
2309            }
2310
2311            let response = timeout(LEGACY_REQUEST_TIMEOUT, response_rx)
2312                .await
2313                .map_err(|_| SinkReject::local("legacy request service timed out"))?
2314                .map_err(|_| SinkReject::local("legacy request response dropped"))?
2315                .map_err(SinkReject::local)?;
2316            trace_legacy_request_response(
2317                &self.trace,
2318                "inbound.response",
2319                Some(&peer_id),
2320                request_id,
2321                response.command(),
2322                &response,
2323            );
2324            LegacyResponseCodec::encode_response(
2325                request_id,
2326                response,
2327                max_frame_bytes,
2328                max_message_bytes,
2329            )
2330            .map_err(SinkReject::local)
2331        })
2332    }
2333}
2334
2335impl ZakuraService for LegacyGossipSink {
2336    fn name(&self) -> &'static str {
2337        "legacy-gossip"
2338    }
2339
2340    fn streams(&self) -> &[Stream] {
2341        legacy_gossip_streams()
2342    }
2343
2344    fn add_peer(&self, mut peer: Peer) {
2345        let Some((mut recv, send)) = peer.take_stream(ZAKURA_STREAM_GOSSIP) else {
2346            return;
2347        };
2348        let outbound = self.outbound.clone();
2349        let inbound_tx = self.inbound_tx.clone();
2350        let peer_id = peer.id.clone();
2351        let conn_id = peer.conn_id;
2352        let cancel_token = peer.cancel_token();
2353        let session = LegacyGossipPeerSession::new(peer_id.clone(), conn_id, send);
2354
2355        if !outbound.insert(session.clone()) {
2356            return;
2357        }
2358        let replay_task_peer_id = peer_id.clone();
2359        let replay_panic_peer_id = replay_task_peer_id.clone();
2360        let replay_panic_outbound = outbound.clone();
2361        let replay_panic_cancel = cancel_token.clone();
2362        spawn_supervised_peer_task(
2363            replay_task_peer_id,
2364            || {},
2365            move || {
2366                replay_panic_cancel.cancel();
2367                replay_panic_outbound.remove(&replay_panic_peer_id, conn_id);
2368            },
2369            {
2370                let outbound = outbound.clone();
2371                let session = session.clone();
2372                async move {
2373                    if let Err(error) = outbound.replay_latest_block_to_peer(session).await {
2374                        debug!(?error, "latest Zakura block gossip replay failed");
2375                    }
2376                }
2377            },
2378        );
2379
2380        let recv_task_peer_id = peer_id.clone();
2381        let recv_panic_peer_id = recv_task_peer_id.clone();
2382        let recv_panic_outbound = outbound.clone();
2383        let recv_panic_cancel = cancel_token.clone();
2384        spawn_supervised_peer_task(
2385            recv_task_peer_id,
2386            || {},
2387            move || {
2388                recv_panic_cancel.cancel();
2389                recv_panic_outbound.remove(&recv_panic_peer_id, conn_id);
2390            },
2391            async move {
2392                loop {
2393                    let frame = tokio::select! {
2394                        _ = cancel_token.cancelled() => {
2395                            outbound.remove(&peer_id, conn_id);
2396                            return;
2397                        }
2398                        frame = recv.recv() => {
2399                            let Some(frame) = frame else {
2400                                outbound.remove(&peer_id, conn_id);
2401                                return;
2402                            };
2403                            frame
2404                        }
2405                    };
2406
2407                    #[cfg(test)]
2408                    if should_panic_legacy_gossip_recv_loop(&peer_id) {
2409                        panic!("injected legacy gossip recv-loop panic after state registration");
2410                    }
2411
2412                    match Self::enqueue_gossip_frame(&inbound_tx, peer_id.clone(), frame) {
2413                        Ok(()) => {}
2414                        Err(SinkReject::Protocol(error)) => {
2415                            debug!(
2416                                ?error,
2417                                ?peer_id,
2418                                "legacy gossip stream rejected protocol-invalid frame"
2419                            );
2420                            cancel_token.cancel();
2421                            outbound.remove(&peer_id, conn_id);
2422                            return;
2423                        }
2424                        Err(SinkReject::Local(error)) => {
2425                            debug!(?error, ?peer_id, "legacy gossip inbound queue closed");
2426                            return;
2427                        }
2428                    }
2429                }
2430            },
2431        );
2432    }
2433
2434    fn remove_peer(&self, peer: &ZakuraPeerId, conn_id: ZakuraConnId) {
2435        self.outbound.remove(peer, conn_id);
2436    }
2437
2438    fn deliver_frame(
2439        &self,
2440        peer_id: ZakuraPeerId,
2441        stream_kind: u16,
2442        frame: Frame,
2443    ) -> Result<(), SinkReject> {
2444        self.deliver(peer_id, stream_kind, frame)
2445    }
2446
2447    fn as_request_response(&self) -> Option<&dyn RequestResponseService> {
2448        Some(self)
2449    }
2450}
2451
2452impl RequestResponseService for LegacyGossipSink {
2453    fn request_frame<'a>(
2454        &'a self,
2455        peer_id: ZakuraPeerId,
2456        stream_kind: u16,
2457        request_id: u64,
2458        max_frame_bytes: u32,
2459        max_message_bytes: u32,
2460        frame: Frame,
2461    ) -> BoxRunFuture<'a, Result<Vec<Frame>, SinkReject>> {
2462        self.request(
2463            peer_id,
2464            stream_kind,
2465            request_id,
2466            max_frame_bytes,
2467            max_message_bytes,
2468            frame,
2469        )
2470    }
2471}
2472
2473#[derive(Debug)]
2474enum LegacyInboundWork {
2475    Gossip(LegacyGossipInbound),
2476    Request(LegacyRequestInbound),
2477}
2478
2479#[derive(Debug)]
2480struct LegacyGossipInbound {
2481    peer_id: ZakuraPeerId,
2482    frame: LegacyGossipFrame,
2483}
2484
2485#[derive(Debug)]
2486struct LegacyRequestInbound {
2487    peer_id: ZakuraPeerId,
2488    request_id: u64,
2489    frame: LegacyRequestFrame,
2490    response_tx: oneshot::Sender<Result<Response, BoxError>>,
2491}
2492
2493async fn legacy_gossip_worker<Inbound>(
2494    mut inbound: Inbound,
2495    mut inbound_rx: mpsc::Receiver<LegacyInboundWork>,
2496    forwarder: LegacyGossipForwarder,
2497    trace: ZakuraTrace,
2498) where
2499    Inbound: Service<Request, Response = Response, Error = BoxError> + Send + Clone + 'static,
2500    Inbound::Future: Send + 'static,
2501{
2502    let request_permits = Arc::new(Semaphore::new(LEGACY_REQUEST_IN_FLIGHT_LIMIT));
2503
2504    while let Some(work) = inbound_rx.recv().await {
2505        match work {
2506            LegacyInboundWork::Gossip(gossip) => {
2507                handle_legacy_gossip(&mut inbound, gossip, &forwarder).await;
2508            }
2509            LegacyInboundWork::Request(request) => {
2510                let Ok(permit) = request_permits.clone().try_acquire_owned() else {
2511                    let _ = request.response_tx.send(Err(
2512                        "legacy request inbound concurrency limit reached".into(),
2513                    ));
2514                    continue;
2515                };
2516                tokio::spawn(handle_legacy_request(
2517                    inbound.clone(),
2518                    request,
2519                    permit,
2520                    trace.clone(),
2521                ));
2522            }
2523        }
2524    }
2525}
2526
2527async fn handle_legacy_gossip<Inbound>(
2528    inbound: &mut Inbound,
2529    gossip: LegacyGossipInbound,
2530    forwarder: &LegacyGossipForwarder,
2531) where
2532    Inbound: Service<Request, Response = Response, Error = BoxError> + Send + 'static,
2533    Inbound::Future: Send + 'static,
2534{
2535    let Some(unseen_frame) = forwarder.unseen(&gossip.frame).await else {
2536        return;
2537    };
2538    // Skip inventory whose recent service attempt was expensive but did not succeed.
2539    // The not-ready, call-error, and call-timeout paths below all return without
2540    // `mark_seen`, so without this an authenticated peer could replay the same valid
2541    // advertisement while the inbound service is slow/erroring and make the serial
2542    // worker re-pay the full 30s+30s readiness/call budget for every queued
2543    // duplicate. Fast failures are not recorded, so a transient blip stays retryable.
2544    let Some(unseen_frame) = forwarder.fresh_attempt(&unseen_frame).await else {
2545        debug!("legacy gossip duplicate suppressed after recent expensive attempt");
2546        return;
2547    };
2548    let request = unseen_frame.clone().into_request(gossip.peer_id.clone());
2549
2550    let started = Instant::now();
2551    let ready = timeout(LEGACY_GOSSIP_SERVICE_TIMEOUT, inbound.ready()).await;
2552    let Ok(Ok(service)) = ready else {
2553        debug!("legacy gossip inbound service was not ready");
2554        forwarder
2555            .note_failed_attempt(&unseen_frame, started.elapsed())
2556            .await;
2557        return;
2558    };
2559
2560    match timeout(LEGACY_GOSSIP_SERVICE_TIMEOUT, service.call(request)).await {
2561        Ok(Ok(_)) => {}
2562        Ok(Err(error)) => {
2563            debug!(?error, "legacy gossip inbound service call failed");
2564            forwarder
2565                .note_failed_attempt(&unseen_frame, started.elapsed())
2566                .await;
2567            return;
2568        }
2569        Err(_) => {
2570            debug!("legacy gossip inbound service call timed out");
2571            forwarder
2572                .note_failed_attempt(&unseen_frame, started.elapsed())
2573                .await;
2574            return;
2575        }
2576    }
2577
2578    forwarder.mark_seen(&unseen_frame).await;
2579    if let Err(error) = forwarder.forward(&gossip.peer_id, unseen_frame).await {
2580        debug!(?error, "legacy gossip forward failed");
2581    }
2582}
2583
2584async fn handle_legacy_request<Inbound>(
2585    mut inbound: Inbound,
2586    request: LegacyRequestInbound,
2587    _permit: OwnedSemaphorePermit,
2588    trace: ZakuraTrace,
2589) where
2590    Inbound: Service<Request, Response = Response, Error = BoxError> + Send + 'static,
2591    Inbound::Future: Send + 'static,
2592{
2593    let command = request.frame.to_string();
2594    let peer_id = request.peer_id.clone();
2595    let request_id = request.request_id;
2596    let request_kind = request.frame.kind();
2597    let mut response_tx = request.response_tx;
2598    let Some(legacy_request) = request.frame.into_service_request(peer_id.clone()) else {
2599        // Inbound legacy Ping is handled locally; the requester measures round-trip time.
2600        let _ = response_tx.send(Ok(Response::Pong(Duration::ZERO)));
2601        return;
2602    };
2603    trace_legacy_request_start(
2604        &trace,
2605        "inbound.request",
2606        Some(&peer_id),
2607        request_id,
2608        request_kind,
2609        request_kind.message_type(),
2610    );
2611
2612    let service_work = async move {
2613        let ready = timeout(LEGACY_REQUEST_TIMEOUT, inbound.ready()).await;
2614        match ready {
2615            Ok(Ok(service)) => timeout(LEGACY_REQUEST_TIMEOUT, service.call(legacy_request))
2616                .await
2617                .map_err(|_| -> BoxError { format!("{command} inbound service timed out").into() })
2618                .and_then(|response| response),
2619            Ok(Err(error)) => Err(error),
2620            Err(_) => Err(format!("{command} inbound service readiness timed out").into()),
2621        }
2622    };
2623
2624    // Tie the spawned handler's lifetime to the request stream. The request-stream
2625    // side (`LegacyGossipSink::request`) waits only LEGACY_REQUEST_TIMEOUT for the
2626    // oneshot result and then drops the receiver; the peer/connection going away
2627    // drops it too. In either case `closed()` resolves, so abort the in-flight
2628    // service work and release the permit instead of letting this orphaned handler
2629    // keep one of LEGACY_REQUEST_IN_FLIGHT_LIMIT permits (and keep doing backend
2630    // work) for up to another full readiness + service-call timeout.
2631    let result = tokio::select! {
2632        biased;
2633        () = response_tx.closed() => {
2634            debug!(
2635                ?peer_id,
2636                request_id,
2637                "legacy request handler aborted: response receiver dropped before service completed"
2638            );
2639            return;
2640        }
2641        result = service_work => result,
2642    };
2643
2644    if let Err(error) = &result {
2645        trace_legacy_request_error(
2646            &trace,
2647            "inbound.error",
2648            Some(&peer_id),
2649            request_id,
2650            request_kind.command(),
2651            error.to_string(),
2652        );
2653    }
2654
2655    if response_tx.send(result).is_err() {
2656        debug!(
2657            ?peer_id,
2658            request_id, "legacy request response receiver dropped before service completed"
2659        );
2660    }
2661}
2662
2663#[derive(Clone, Debug)]
2664struct FirstSeenCache {
2665    inner: std::sync::Arc<Mutex<FirstSeenCacheInner>>,
2666}
2667
2668impl FirstSeenCache {
2669    fn new(capacity: usize, ttl: Duration) -> Self {
2670        Self {
2671            inner: std::sync::Arc::new(Mutex::new(FirstSeenCacheInner {
2672                capacity: capacity.max(1),
2673                ttl,
2674                entries: HashMap::new(),
2675                order: VecDeque::new(),
2676            })),
2677        }
2678    }
2679
2680    async fn record_unseen(
2681        &self,
2682        keys: impl IntoIterator<Item = InventoryKey>,
2683    ) -> Vec<InventoryKey> {
2684        let mut inner = self.inner.lock().await;
2685        let now = Instant::now();
2686        inner.prune(now);
2687        let mut recorded = Vec::new();
2688        for key in keys {
2689            if inner.insert(key, now) {
2690                recorded.push(key);
2691            }
2692        }
2693        recorded
2694    }
2695
2696    async fn record_first_seen(&self, frame: &LegacyGossipFrame) -> Option<LegacyGossipFrame> {
2697        let unseen = self.unseen(frame).await?;
2698        self.record_seen(&unseen).await;
2699        Some(unseen)
2700    }
2701
2702    async fn unseen(&self, frame: &LegacyGossipFrame) -> Option<LegacyGossipFrame> {
2703        match frame {
2704            LegacyGossipFrame::AdvertiseBlock(hash) => {
2705                (!self.contains(InventoryKey::Block(*hash)).await)
2706                    .then_some(LegacyGossipFrame::AdvertiseBlock(*hash))
2707            }
2708            LegacyGossipFrame::AdvertiseTransactionIds(ids) => {
2709                let unseen = self.unrecorded_transaction_ids(ids.iter().copied()).await;
2710                (!unseen.is_empty()).then_some(LegacyGossipFrame::AdvertiseTransactionIds(unseen))
2711            }
2712        }
2713    }
2714
2715    async fn record_seen(&self, frame: &LegacyGossipFrame) {
2716        match frame {
2717            LegacyGossipFrame::AdvertiseBlock(hash) => {
2718                self.record_unseen([InventoryKey::Block(*hash)]).await;
2719            }
2720            LegacyGossipFrame::AdvertiseTransactionIds(ids) => {
2721                self.record_unseen(ids.iter().copied().map(InventoryKey::Transaction))
2722                    .await;
2723            }
2724        }
2725    }
2726
2727    async fn contains(&self, key: InventoryKey) -> bool {
2728        let mut inner = self.inner.lock().await;
2729        let now = Instant::now();
2730        inner.prune(now);
2731        inner.entries.contains_key(&key)
2732    }
2733
2734    async fn unrecorded_transaction_ids(
2735        &self,
2736        ids: impl IntoIterator<Item = UnminedTxId>,
2737    ) -> Vec<UnminedTxId> {
2738        let mut inner = self.inner.lock().await;
2739        inner.prune(Instant::now());
2740        let mut unseen = Vec::new();
2741        for tx_id in ids {
2742            if !inner
2743                .entries
2744                .contains_key(&InventoryKey::Transaction(tx_id))
2745            {
2746                unseen.push(tx_id);
2747            }
2748        }
2749        unseen
2750    }
2751}
2752
2753#[derive(Debug)]
2754struct FirstSeenCacheInner {
2755    capacity: usize,
2756    ttl: Duration,
2757    entries: HashMap<InventoryKey, Instant>,
2758    order: VecDeque<InventoryKey>,
2759}
2760
2761impl FirstSeenCacheInner {
2762    fn insert(&mut self, key: InventoryKey, now: Instant) -> bool {
2763        if self.entries.contains_key(&key) {
2764            return false;
2765        }
2766        self.entries.insert(key, now);
2767        self.order.push_back(key);
2768        while self.entries.len() > self.capacity {
2769            if let Some(oldest) = self.order.pop_front() {
2770                self.entries.remove(&oldest);
2771            }
2772        }
2773        true
2774    }
2775
2776    fn prune(&mut self, now: Instant) {
2777        while let Some(key) = self.order.front().copied() {
2778            let Some(seen_at) = self.entries.get(&key).copied() else {
2779                self.order.pop_front();
2780                continue;
2781            };
2782            if now.saturating_duration_since(seen_at) < self.ttl {
2783                break;
2784            }
2785            self.order.pop_front();
2786            self.entries.remove(&key);
2787        }
2788    }
2789}
2790
2791#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]
2792enum InventoryKey {
2793    Block(block::Hash),
2794    Transaction(UnminedTxId),
2795}
2796
2797/// Errors produced by the legacy gossip adapter.
2798#[derive(Debug, Error)]
2799pub enum LegacyGossipError {
2800    /// The request is intentionally outside this phase's gossip-only scope.
2801    #[error("unsupported legacy gossip request: {0}")]
2802    UnsupportedRequest(&'static str),
2803    /// A peer used nonzero flags before any flags are defined.
2804    #[error("unsupported legacy gossip flags: {0}")]
2805    UnsupportedFlags(u16),
2806    /// A peer sent an unknown gossip message type.
2807    #[error("unknown legacy gossip message type: {0}")]
2808    UnknownMessageType(u16),
2809    /// Transaction advertisements must not be empty.
2810    #[error("empty transaction-id advertisement")]
2811    EmptyTransactionAdvertisement,
2812    /// A peer requested or responded with too many inventory items.
2813    #[error("too many inventory items in legacy request/response: {0}")]
2814    TooManyInventoryItems(usize),
2815    /// A peer requested too many block locator hashes.
2816    #[error("too many block locator hashes in legacy request: {0}")]
2817    TooManyBlockLocatorHashes(usize),
2818    /// A peer responded with too many block headers.
2819    #[error("too many block headers in legacy response: {0}")]
2820    TooManyHeaders(usize),
2821    /// Transaction gossip payload included non-transaction inventory.
2822    #[error("transaction-id advertisement contained non-transaction inventory")]
2823    NonTransactionInventory,
2824    /// Payload had bytes after the decoded message.
2825    #[error("trailing bytes after legacy gossip payload")]
2826    TrailingBytes,
2827    /// A response frame echoed a different request id.
2828    #[error("wrong legacy request id in response: expected {expected}, got {actual}")]
2829    WrongRequestId {
2830        /// Expected request id.
2831        expected: u64,
2832        /// Actual request id in the response frame.
2833        actual: u64,
2834    },
2835    /// A chunked response ended before its final chunk.
2836    #[error("incomplete legacy response chunk")]
2837    IncompleteResponseChunk,
2838    /// A response payload was too short to contain its fixed header.
2839    #[error("truncated legacy response")]
2840    TruncatedResponse,
2841    /// A response exceeded the protocol message size.
2842    #[error("oversized legacy response: {0} bytes")]
2843    OversizedResponse(usize),
2844    /// The encoded response would exceed the responder-side aggregate budget.
2845    #[error("legacy response exceeded responder aggregate budget: {0} bytes")]
2846    ResponseAggregateBudget(usize),
2847    /// The legacy service returned an unexpected response variant.
2848    #[error("unexpected legacy response: {0}")]
2849    UnexpectedResponse(&'static str),
2850    /// A request that requires an acknowledgement received none.
2851    #[error("missing legacy response: {0}")]
2852    MissingResponse(&'static str),
2853    /// The peer returned a block we did not request, so the response is not bound
2854    /// to the requested hash. Treated as a peer fault.
2855    #[error("legacy block response contained an unrequested block: {0:?}")]
2856    UnsolicitedBlock(block::Hash),
2857    /// Zcash serialization failed.
2858    #[error(transparent)]
2859    Serialization(#[from] SerializationError),
2860    /// Local buffer serialization failed.
2861    #[error(transparent)]
2862    Io(#[from] std::io::Error),
2863    /// Integer conversion failed while checking a peer-controlled bound.
2864    #[error(transparent)]
2865    Integer(#[from] std::num::TryFromIntError),
2866}
2867
2868impl fmt::Display for LegacyGossipFrame {
2869    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2870        match self {
2871            Self::AdvertiseBlock(_) => f.write_str("AdvertiseBlock"),
2872            Self::AdvertiseTransactionIds(ids) => {
2873                write!(f, "AdvertiseTransactionIds({})", ids.len())
2874            }
2875        }
2876    }
2877}
2878
2879impl fmt::Display for LegacyRequestFrame {
2880    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2881        match self {
2882            Self::BlocksByHash(hashes) => write!(f, "BlocksByHash({})", hashes.len()),
2883            Self::TransactionsById(ids) => write!(f, "TransactionsById({})", ids.len()),
2884            Self::FindBlocks { known_blocks, stop } => write!(
2885                f,
2886                "FindBlocks {{ known_blocks: {}, stop: {} }}",
2887                known_blocks.len(),
2888                stop.is_some()
2889            ),
2890            Self::FindHeaders { known_blocks, stop } => write!(
2891                f,
2892                "FindHeaders {{ known_blocks: {}, stop: {} }}",
2893                known_blocks.len(),
2894                stop.is_some()
2895            ),
2896            Self::MempoolTransactionIds => f.write_str("MempoolTransactionIds"),
2897            Self::Ping => f.write_str("Ping"),
2898            Self::PushTransaction(_) => f.write_str("PushTransaction"),
2899        }
2900    }
2901}
2902
2903#[cfg(test)]
2904mod tests {
2905    use super::*;
2906    use std::{
2907        collections::HashSet,
2908        sync::{
2909            atomic::{AtomicUsize, Ordering},
2910            Arc,
2911        },
2912        task::{Context, Poll},
2913    };
2914
2915    use futures::FutureExt;
2916    use tokio::sync::mpsc::UnboundedReceiver;
2917    use tower::ServiceExt;
2918    use zakura_chain::{
2919        parameters::NetworkUpgrade,
2920        serialization::{ZcashDeserialize, ZcashSerialize},
2921        transaction::{self, LockTime, Transaction, WtxId},
2922    };
2923    use zakura_test::vectors::BLOCK_TESTNET_141042_BYTES;
2924
2925    use crate::zakura::{
2926        framed_channel,
2927        testkit::{HostilePeer, ZakuraTestNode, TEST_NET_TIMEOUT},
2928        Peer, ServicePeerDirection, ZAKURA_CAP_LEGACY_GOSSIP,
2929    };
2930
2931    fn block_hash(byte: u8) -> block::Hash {
2932        block::Hash([byte; 32])
2933    }
2934
2935    fn legacy_tx_id(byte: u8) -> UnminedTxId {
2936        UnminedTxId::from_legacy_id(transaction::Hash([byte; 32]))
2937    }
2938
2939    fn witnessed_tx_id(byte: u8) -> UnminedTxId {
2940        UnminedTxId::from(WtxId {
2941            id: transaction::Hash([byte; 32]),
2942            auth_digest: transaction::AuthDigest::from([byte.wrapping_add(1); 32]),
2943        })
2944    }
2945
2946    fn empty_v5_transaction(byte: u8) -> Transaction {
2947        Transaction::V5 {
2948            network_upgrade: NetworkUpgrade::Nu5,
2949            lock_time: LockTime::min_lock_time_timestamp(),
2950            expiry_height: block::Height(u32::from(byte)),
2951            inputs: Vec::new(),
2952            outputs: Vec::new(),
2953            sapling_shielded_data: None,
2954            orchard_shielded_data: None,
2955        }
2956    }
2957
2958    fn encoded_count(count: usize) -> Vec<u8> {
2959        CompactSizeMessage::try_from(count)
2960            .expect("test count is within CompactSizeMessage bounds")
2961            .zcash_serialize_to_vec()
2962            .expect("compact size serializes")
2963    }
2964
2965    #[derive(Clone, Debug)]
2966    struct RequestRecorder {
2967        tx: tokio::sync::mpsc::UnboundedSender<Request>,
2968    }
2969
2970    impl Service<Request> for RequestRecorder {
2971        type Response = Response;
2972        type Error = BoxError;
2973        type Future = std::future::Ready<Result<Response, BoxError>>;
2974
2975        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2976            Poll::Ready(Ok(()))
2977        }
2978
2979        fn call(&mut self, request: Request) -> Self::Future {
2980            match self.tx.send(request) {
2981                Ok(()) => std::future::ready(Ok(Response::Nil)),
2982                Err(_) => std::future::ready(Err("request recorder receiver dropped".into())),
2983            }
2984        }
2985    }
2986
2987    #[derive(Clone, Debug)]
2988    struct FailsOnceRecorder {
2989        attempts: Arc<AtomicUsize>,
2990        tx: tokio::sync::mpsc::UnboundedSender<Request>,
2991    }
2992
2993    impl Service<Request> for FailsOnceRecorder {
2994        type Response = Response;
2995        type Error = BoxError;
2996        type Future = std::future::Ready<Result<Response, BoxError>>;
2997
2998        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2999            Poll::Ready(Ok(()))
3000        }
3001
3002        fn call(&mut self, request: Request) -> Self::Future {
3003            if self.attempts.fetch_add(1, Ordering::SeqCst) == 0 {
3004                return std::future::ready(Err("intentional transient inbound failure".into()));
3005            }
3006
3007            match self.tx.send(request) {
3008                Ok(()) => std::future::ready(Ok(Response::Nil)),
3009                Err(_) => std::future::ready(Err("request recorder receiver dropped".into())),
3010            }
3011        }
3012    }
3013
3014    #[derive(Clone, Debug)]
3015    struct InventoryResponder {
3016        transaction: UnminedTx,
3017    }
3018
3019    impl Service<Request> for InventoryResponder {
3020        type Response = Response;
3021        type Error = BoxError;
3022        type Future = std::future::Ready<Result<Response, BoxError>>;
3023
3024        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3025            Poll::Ready(Ok(()))
3026        }
3027
3028        fn call(&mut self, request: Request) -> Self::Future {
3029            let response = match request {
3030                Request::BlocksByHash(hashes) | Request::BlocksByHashFrom { hashes, .. } => {
3031                    Response::Blocks(hashes.into_iter().map(InventoryResponse::Missing).collect())
3032                }
3033                Request::TransactionsById(ids) | Request::TransactionsByIdFrom { ids, .. } => {
3034                    Response::Transactions(
3035                        ids.into_iter()
3036                            .map(|id| {
3037                                if id == self.transaction.id {
3038                                    InventoryResponse::Available((self.transaction.clone(), None))
3039                                } else {
3040                                    InventoryResponse::Missing(id)
3041                                }
3042                            })
3043                            .collect(),
3044                    )
3045                }
3046                request => {
3047                    return std::future::ready(Err(format!(
3048                        "unexpected inventory request: {request:?}"
3049                    )
3050                    .into()));
3051                }
3052            };
3053            std::future::ready(Ok(response))
3054        }
3055    }
3056
3057    #[derive(Clone, Debug)]
3058    struct BlockInventoryResponder {
3059        block: Arc<Block>,
3060    }
3061
3062    impl Service<Request> for BlockInventoryResponder {
3063        type Response = Response;
3064        type Error = BoxError;
3065        type Future = std::future::Ready<Result<Response, BoxError>>;
3066
3067        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3068            Poll::Ready(Ok(()))
3069        }
3070
3071        fn call(&mut self, request: Request) -> Self::Future {
3072            let response = match request {
3073                Request::BlocksByHash(hashes) | Request::BlocksByHashFrom { hashes, .. } => {
3074                    Response::Blocks(
3075                        hashes
3076                            .into_iter()
3077                            .map(|hash| {
3078                                if hash == self.block.hash() {
3079                                    InventoryResponse::Available((self.block.clone(), None))
3080                                } else {
3081                                    InventoryResponse::Missing(hash)
3082                                }
3083                            })
3084                            .collect(),
3085                    )
3086                }
3087                request => {
3088                    return std::future::ready(Err(format!(
3089                        "unexpected block inventory request: {request:?}"
3090                    )
3091                    .into()));
3092                }
3093            };
3094            std::future::ready(Ok(response))
3095        }
3096    }
3097
3098    #[derive(Clone, Debug)]
3099    struct SlowRequestThenRecorder {
3100        release: tokio::sync::watch::Receiver<bool>,
3101        tx: tokio::sync::mpsc::UnboundedSender<Request>,
3102    }
3103
3104    impl Service<Request> for SlowRequestThenRecorder {
3105        type Response = Response;
3106        type Error = BoxError;
3107        type Future = Pin<Box<dyn Future<Output = Result<Response, BoxError>> + Send>>;
3108
3109        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3110            Poll::Ready(Ok(()))
3111        }
3112
3113        fn call(&mut self, request: Request) -> Self::Future {
3114            match request {
3115                Request::BlocksByHash(hashes) | Request::BlocksByHashFrom { hashes, .. } => {
3116                    let mut release = self.release.clone();
3117                    async move {
3118                        while !*release.borrow() {
3119                            if release.changed().await.is_err() {
3120                                break;
3121                            }
3122                        }
3123                        Ok(Response::Blocks(
3124                            hashes.into_iter().map(InventoryResponse::Missing).collect(),
3125                        ))
3126                    }
3127                    .boxed()
3128                }
3129                request => {
3130                    let tx = self.tx.clone();
3131                    async move {
3132                        tx.send(request)?;
3133                        Ok(Response::Nil)
3134                    }
3135                    .boxed()
3136                }
3137            }
3138        }
3139    }
3140
3141    /// Always ready, but every `call` future is pending forever. Models a slow or
3142    /// backpressured inbound service whose work outlives the request-stream timeout.
3143    #[derive(Clone, Debug)]
3144    struct NeverCompletesService;
3145
3146    impl Service<Request> for NeverCompletesService {
3147        type Response = Response;
3148        type Error = BoxError;
3149        type Future = Pin<Box<dyn Future<Output = Result<Response, BoxError>> + Send>>;
3150
3151        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3152            Poll::Ready(Ok(()))
3153        }
3154
3155        fn call(&mut self, _request: Request) -> Self::Future {
3156            std::future::pending().boxed()
3157        }
3158    }
3159
3160    /// Always ready, but every `call` future is pending forever and counts
3161    /// invocations. Models a slow/backpressured inbound service whose calls hit
3162    /// `LEGACY_GOSSIP_SERVICE_TIMEOUT`, so `handle_legacy_gossip` returns without
3163    /// `mark_seen`, exposing how many times duplicate gossip frames reach the
3164    /// expensive readiness/call path.
3165    #[derive(Clone, Debug)]
3166    struct CountingPendingService {
3167        calls: Arc<AtomicUsize>,
3168    }
3169
3170    impl Service<Request> for CountingPendingService {
3171        type Response = Response;
3172        type Error = BoxError;
3173        type Future = Pin<Box<dyn Future<Output = Result<Response, BoxError>> + Send>>;
3174
3175        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3176            Poll::Ready(Ok(()))
3177        }
3178
3179        fn call(&mut self, _request: Request) -> Self::Future {
3180            self.calls.fetch_add(1, Ordering::SeqCst);
3181            std::future::pending().boxed()
3182        }
3183    }
3184
3185    #[derive(Clone, Debug)]
3186    struct RecordingInventoryResponder {
3187        transaction: Option<UnminedTx>,
3188        tx: tokio::sync::mpsc::UnboundedSender<Request>,
3189    }
3190
3191    impl Service<Request> for RecordingInventoryResponder {
3192        type Response = Response;
3193        type Error = BoxError;
3194        type Future = std::future::Ready<Result<Response, BoxError>>;
3195
3196        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3197            Poll::Ready(Ok(()))
3198        }
3199
3200        fn call(&mut self, request: Request) -> Self::Future {
3201            if let Err(error) = self.tx.send(request.clone()) {
3202                return std::future::ready(Err(Box::new(error)));
3203            }
3204
3205            let response = match request {
3206                Request::BlocksByHash(hashes) | Request::BlocksByHashFrom { hashes, .. } => {
3207                    Response::Blocks(hashes.into_iter().map(InventoryResponse::Missing).collect())
3208                }
3209                Request::TransactionsById(ids) | Request::TransactionsByIdFrom { ids, .. } => {
3210                    Response::Transactions(
3211                        ids.into_iter()
3212                            .map(|id| match &self.transaction {
3213                                Some(transaction) if id == transaction.id => {
3214                                    InventoryResponse::Available((transaction.clone(), None))
3215                                }
3216                                _ => InventoryResponse::Missing(id),
3217                            })
3218                            .collect(),
3219                    )
3220                }
3221                request => {
3222                    return std::future::ready(Err(format!(
3223                        "unexpected inventory request: {request:?}"
3224                    )
3225                    .into()));
3226                }
3227            };
3228            std::future::ready(Ok(response))
3229        }
3230    }
3231
3232    #[derive(Clone, Debug)]
3233    struct NormalNetworkResponder {
3234        block: Arc<Block>,
3235        transaction: UnminedTx,
3236        pushed_tx: tokio::sync::mpsc::UnboundedSender<UnminedTxId>,
3237    }
3238
3239    impl NormalNetworkResponder {
3240        fn header(&self) -> block::CountedHeader {
3241            block::CountedHeader {
3242                header: self.block.header.clone(),
3243            }
3244        }
3245    }
3246
3247    impl Service<Request> for NormalNetworkResponder {
3248        type Response = Response;
3249        type Error = BoxError;
3250        type Future = std::future::Ready<Result<Response, BoxError>>;
3251
3252        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3253            Poll::Ready(Ok(()))
3254        }
3255
3256        fn call(&mut self, request: Request) -> Self::Future {
3257            let response = match request {
3258                Request::FindBlocks { .. } => Response::BlockHashes(vec![self.block.hash()]),
3259                Request::FindHeaders { .. } => Response::BlockHeaders(vec![self.header()]),
3260                Request::MempoolTransactionIds => {
3261                    Response::TransactionIds(vec![self.transaction.id])
3262                }
3263                Request::BlocksByHash(hashes) | Request::BlocksByHashFrom { hashes, .. } => {
3264                    Response::Blocks(
3265                        hashes
3266                            .into_iter()
3267                            .map(|hash| {
3268                                if hash == self.block.hash() {
3269                                    InventoryResponse::Available((self.block.clone(), None))
3270                                } else {
3271                                    InventoryResponse::Missing(hash)
3272                                }
3273                            })
3274                            .collect(),
3275                    )
3276                }
3277                Request::PushTransaction(transaction, _) => {
3278                    if let Err(error) = self.pushed_tx.send(transaction.id) {
3279                        return std::future::ready(Err(Box::new(error)));
3280                    }
3281                    Response::Nil
3282                }
3283                request => {
3284                    return std::future::ready(Err(format!(
3285                        "unexpected normal-network request: {request:?}"
3286                    )
3287                    .into()));
3288                }
3289            };
3290            std::future::ready(Ok(response))
3291        }
3292    }
3293
3294    async fn legacy_node(
3295        seed: u64,
3296    ) -> Result<(ZakuraTestNode, UnboundedReceiver<Request>), BoxError> {
3297        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
3298        let node = ZakuraTestNode::builder(seed)
3299            .service_from_supervisor(move |supervisor| {
3300                Arc::new(LegacyGossipSink::spawn(RequestRecorder { tx }, supervisor))
3301            })
3302            .spawn()
3303            .await?;
3304        Ok((node, rx))
3305    }
3306
3307    async fn inventory_node(seed: u64, transaction: UnminedTx) -> Result<ZakuraTestNode, BoxError> {
3308        let node = ZakuraTestNode::builder(seed)
3309            .service_from_supervisor(move |supervisor| {
3310                Arc::new(LegacyGossipSink::spawn(
3311                    InventoryResponder { transaction },
3312                    supervisor,
3313                ))
3314            })
3315            .spawn()
3316            .await?;
3317        Ok(node)
3318    }
3319
3320    async fn block_inventory_node(
3321        seed: u64,
3322        block: Arc<Block>,
3323    ) -> Result<ZakuraTestNode, BoxError> {
3324        let node = ZakuraTestNode::builder(seed)
3325            .service_from_supervisor(move |supervisor| {
3326                Arc::new(LegacyGossipSink::spawn(
3327                    BlockInventoryResponder { block },
3328                    supervisor,
3329                ))
3330            })
3331            .spawn()
3332            .await?;
3333        Ok(node)
3334    }
3335
3336    async fn recording_inventory_node(
3337        seed: u64,
3338        transaction: Option<UnminedTx>,
3339    ) -> Result<(ZakuraTestNode, UnboundedReceiver<Request>), BoxError> {
3340        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
3341        let node = ZakuraTestNode::builder(seed)
3342            .service_from_supervisor(move |supervisor| {
3343                Arc::new(LegacyGossipSink::spawn(
3344                    RecordingInventoryResponder { transaction, tx },
3345                    supervisor,
3346                ))
3347            })
3348            .spawn()
3349            .await?;
3350        Ok((node, rx))
3351    }
3352
3353    async fn normal_network_node(
3354        seed: u64,
3355        block: Arc<Block>,
3356        transaction: UnminedTx,
3357    ) -> Result<
3358        (
3359            ZakuraTestNode,
3360            tokio::sync::mpsc::UnboundedReceiver<UnminedTxId>,
3361        ),
3362        BoxError,
3363    > {
3364        let (pushed_tx, pushed_rx) = tokio::sync::mpsc::unbounded_channel();
3365        let node = ZakuraTestNode::builder(seed)
3366            // Multi-node gossip topologies dial several loopback peers from one
3367            // node, so raise the production Zakura per-IP cap that the default
3368            // test node enforces; these tests exercise gossip routing, not the
3369            // per-IP admission gate.
3370            .max_connections_per_ip(8)
3371            .service_from_supervisor(move |supervisor| {
3372                Arc::new(LegacyGossipSink::spawn(
3373                    NormalNetworkResponder {
3374                        block,
3375                        transaction,
3376                        pushed_tx,
3377                    },
3378                    supervisor,
3379                ))
3380            })
3381            .spawn()
3382            .await?;
3383        Ok((node, pushed_rx))
3384    }
3385
3386    async fn node_peer_id(node: &ZakuraTestNode) -> Result<ZakuraPeerId, BoxError> {
3387        Ok(ZakuraPeerId::new(
3388            node.node_addr().await.node_id.as_bytes().to_vec(),
3389        )?)
3390    }
3391
3392    async fn recv_request(rx: &mut UnboundedReceiver<Request>) -> Result<Request, BoxError> {
3393        tokio::time::timeout(TEST_NET_TIMEOUT, rx.recv())
3394            .await
3395            .map_err(|_| -> BoxError { "timed out waiting for request".into() })?
3396            .ok_or_else(|| "request recorder closed".into())
3397    }
3398
3399    async fn recv_pushed_tx_id(
3400        rx: &mut tokio::sync::mpsc::UnboundedReceiver<UnminedTxId>,
3401    ) -> Result<UnminedTxId, BoxError> {
3402        tokio::time::timeout(TEST_NET_TIMEOUT, rx.recv())
3403            .await
3404            .map_err(|_| -> BoxError { "timed out waiting for pushed transaction".into() })?
3405            .ok_or_else(|| "pushed transaction recorder closed".into())
3406    }
3407
3408    fn legacy_gossip_peer(
3409        peer_id: ZakuraPeerId,
3410        cancel_token: tokio_util::sync::CancellationToken,
3411    ) -> (Peer, FramedSend) {
3412        legacy_gossip_peer_with_conn(peer_id, 0, cancel_token)
3413    }
3414
3415    fn legacy_gossip_peer_with_conn(
3416        peer_id: ZakuraPeerId,
3417        conn_id: ZakuraConnId,
3418        cancel_token: tokio_util::sync::CancellationToken,
3419    ) -> (Peer, FramedSend) {
3420        let (peer_send, service_recv) = framed_channel(8);
3421        let (service_send, _peer_recv) = framed_channel(8);
3422        let peer = Peer::new_with_conn_id_and_direction(
3423            conn_id,
3424            peer_id,
3425            None,
3426            ZAKURA_CAP_LEGACY_GOSSIP,
3427            ServicePeerDirection::Inbound,
3428            HashMap::from([(ZAKURA_STREAM_GOSSIP, (service_recv, service_send))]),
3429            cancel_token,
3430        );
3431        (peer, peer_send)
3432    }
3433
3434    async fn wait_for_legacy_gossip_panic_cleanup(
3435        outbound: &LegacyGossipOutbound,
3436        peer_id: &ZakuraPeerId,
3437        cancel_token: &tokio_util::sync::CancellationToken,
3438    ) -> Result<(), BoxError> {
3439        tokio::time::timeout(Duration::from_secs(1), async {
3440            loop {
3441                if cancel_token.is_cancelled() && !outbound.contains(peer_id) {
3442                    return;
3443                }
3444                tokio::time::sleep(Duration::from_millis(10)).await;
3445            }
3446        })
3447        .await
3448        .map_err(|_| -> BoxError { "timed out waiting for legacy gossip panic cleanup".into() })
3449    }
3450
3451    async fn wait_registered_count(node: &ZakuraTestNode, count: usize) -> Result<(), BoxError> {
3452        tokio::time::timeout(TEST_NET_TIMEOUT, async {
3453            loop {
3454                if node.supervisor().registered_ids().await.len() == count {
3455                    return;
3456                }
3457                tokio::time::sleep(Duration::from_millis(10)).await;
3458            }
3459        })
3460        .await
3461        .map_err(|_| -> BoxError { "timed out waiting for peer registration count".into() })
3462    }
3463
3464    /// Regression for `claude-legacy-request-orphaned-handler-permits`.
3465    ///
3466    /// The request-stream side (`LegacyGossipSink::request`) waits only
3467    /// `LEGACY_REQUEST_TIMEOUT` for the oneshot result and then drops the receiver;
3468    /// the peer disconnecting drops it too. The spawned `handle_legacy_request` task
3469    /// must not keep its in-flight permit (one of `LEGACY_REQUEST_IN_FLIGHT_LIMIT`)
3470    /// or its backend service work alive after that. Before the fix it stayed blocked
3471    /// in `service.call` for up to another full `LEGACY_REQUEST_TIMEOUT`, so an
3472    /// attacker driving slow inbound work could occupy all 64 permits.
3473    #[tokio::test(start_paused = true)]
3474    async fn legacy_request_handler_releases_permit_when_request_stream_drops_receiver() {
3475        let permits = Arc::new(Semaphore::new(LEGACY_REQUEST_IN_FLIGHT_LIMIT));
3476        let permit = permits
3477            .clone()
3478            .try_acquire_owned()
3479            .expect("a permit is available");
3480        assert_eq!(
3481            permits.available_permits(),
3482            LEGACY_REQUEST_IN_FLIGHT_LIMIT - 1,
3483            "one permit is held while the handler runs"
3484        );
3485
3486        let (response_tx, response_rx) = oneshot::channel();
3487        let request = LegacyRequestInbound {
3488            peer_id: ZakuraPeerId::new(vec![7u8; 32]).expect("valid peer id"),
3489            request_id: 1,
3490            // A non-Ping request so the handler drives the inbound service.
3491            frame: LegacyRequestFrame::MempoolTransactionIds,
3492            response_tx,
3493        };
3494
3495        let handler = tokio::spawn(handle_legacy_request(
3496            NeverCompletesService,
3497            request,
3498            permit,
3499            ZakuraTrace::noop(),
3500        ));
3501
3502        // Let the handler enter the (never-completing) service call, then model the
3503        // request-stream side giving up: it drops the oneshot receiver.
3504        tokio::task::yield_now().await;
3505        drop(response_rx);
3506
3507        // The handler must observe the dropped receiver, abort the service work, and
3508        // release the permit promptly. Without the fix this times out because the
3509        // handler stays blocked in `service.call` until `LEGACY_REQUEST_TIMEOUT`.
3510        tokio::time::timeout(TEST_NET_TIMEOUT, handler)
3511            .await
3512            .expect("handler aborts promptly after the request stream drops the receiver")
3513            .expect("handler task does not panic");
3514
3515        assert_eq!(
3516            permits.available_permits(),
3517            LEGACY_REQUEST_IN_FLIGHT_LIMIT,
3518            "the in-flight permit must be released once the handler aborts"
3519        );
3520    }
3521
3522    #[test]
3523    fn block_gossip_round_trips_and_rejects_trailing_bytes() {
3524        let frame = LegacyGossipFrame::AdvertiseBlock(block_hash(1));
3525        let mut encoded = frame.encode_frame().expect("frame encodes");
3526        assert_eq!(
3527            LegacyGossipFrame::decode_frame(encoded.clone()).expect("frame decodes"),
3528            frame
3529        );
3530
3531        encoded.payload.push(0);
3532        assert!(matches!(
3533            LegacyGossipFrame::decode_frame(encoded),
3534            Err(LegacyGossipError::TrailingBytes)
3535        ));
3536    }
3537
3538    #[tokio::test]
3539    async fn adapter_gossips_block_and_tx_ids_between_two_nodes() -> Result<(), BoxError> {
3540        let _guard = zakura_test::init();
3541        let (node_a, mut rx_a) = legacy_node(11).await?;
3542        let (node_b, mut rx_b) = legacy_node(12).await?;
3543        node_a.connect_native(&node_b, TEST_NET_TIMEOUT).await?;
3544        let a_peer_id = node_peer_id(&node_a).await?;
3545
3546        let mut adapter = LegacyGossipAdapter::new(node_a.supervisor());
3547        let block_hash = block_hash(9);
3548        adapter
3549            .ready()
3550            .await?
3551            .call(Request::AdvertiseBlockToAll(block_hash))
3552            .await?;
3553
3554        match recv_request(&mut rx_b).await? {
3555            Request::AdvertiseBlock(hash, Some(PeerSource::Zakura(peer_id))) => {
3556                assert_eq!(hash, block_hash);
3557                assert_eq!(peer_id, a_peer_id);
3558            }
3559            request => panic!("unexpected request: {request:?}"),
3560        }
3561        assert!(rx_a.try_recv().is_err());
3562
3563        let ids = HashSet::from([legacy_tx_id(10), witnessed_tx_id(11)]);
3564        adapter
3565            .ready()
3566            .await?
3567            .call(Request::AdvertiseTransactionIds(ids.clone(), None))
3568            .await?;
3569
3570        match recv_request(&mut rx_b).await? {
3571            Request::AdvertiseTransactionIds(received, Some(PeerSource::Zakura(peer_id))) => {
3572                assert_eq!(received, ids);
3573                assert_eq!(peer_id, a_peer_id);
3574            }
3575            request => panic!("unexpected request: {request:?}"),
3576        }
3577
3578        node_a.shutdown().await;
3579        node_b.shutdown().await;
3580        Ok(())
3581    }
3582
3583    #[tokio::test]
3584    async fn request_adapter_fetches_missing_block_from_advertiser() -> Result<(), BoxError> {
3585        let _guard = zakura_test::init();
3586        let transaction = UnminedTx::from(empty_v5_transaction(1));
3587        let node_a = inventory_node(61, transaction).await?;
3588        let node_b = ZakuraTestNode::builder(62).spawn().await?;
3589        node_b.connect_native(&node_a, TEST_NET_TIMEOUT).await?;
3590        let a_peer_id = node_peer_id(&node_a).await?;
3591        let hash = block_hash(90);
3592
3593        let adapter = LegacyRequestAdapter::new(node_b.supervisor());
3594        let response = adapter
3595            .request_from_source(
3596                Request::BlocksByHash(HashSet::from([hash])),
3597                Some(PeerSource::Zakura(a_peer_id)),
3598            )
3599            .await?;
3600
3601        match response {
3602            Response::Blocks(blocks) => {
3603                assert_eq!(blocks, vec![InventoryResponse::Missing(hash)]);
3604            }
3605            response => panic!("unexpected response: {response:?}"),
3606        }
3607
3608        node_a.shutdown().await;
3609        node_b.shutdown().await;
3610        Ok(())
3611    }
3612
3613    #[tokio::test]
3614    async fn request_adapter_fetches_available_multi_chunk_block() -> Result<(), BoxError> {
3615        let _guard = zakura_test::init();
3616        let block = Arc::new(Block::zcash_deserialize(
3617            BLOCK_TESTNET_141042_BYTES.as_slice(),
3618        )?);
3619        let serialized_len = block.zcash_serialize_to_vec()?.len();
3620        assert!(
3621            serialized_len > LEGACY_RESPONSE_CHUNK_BYTES,
3622            "test block must span multiple response chunks"
3623        );
3624
3625        let node_a = block_inventory_node(68, block.clone()).await?;
3626        let node_b = ZakuraTestNode::builder(69).spawn().await?;
3627        node_b.connect_native(&node_a, TEST_NET_TIMEOUT).await?;
3628        let a_peer_id = node_peer_id(&node_a).await?;
3629
3630        let adapter = LegacyRequestAdapter::new(node_b.supervisor());
3631        let response = adapter
3632            .request_from_source(
3633                Request::BlocksByHash(HashSet::from([block.hash()])),
3634                Some(PeerSource::Zakura(a_peer_id)),
3635            )
3636            .await?;
3637
3638        let Response::Blocks(blocks) = response else {
3639            panic!("unexpected response: {response:?}");
3640        };
3641        assert!(matches!(
3642            blocks.as_slice(),
3643            [InventoryResponse::Available((received, None))] if received.hash() == block.hash()
3644        ));
3645
3646        node_a.shutdown().await;
3647        node_b.shutdown().await;
3648        Ok(())
3649    }
3650
3651    #[tokio::test]
3652    async fn request_adapter_fetches_available_and_missing_transactions() -> Result<(), BoxError> {
3653        let _guard = zakura_test::init();
3654        let transaction = UnminedTx::from(empty_v5_transaction(2));
3655        let available_id = transaction.id;
3656        let missing_id = witnessed_tx_id(99);
3657        let node_a = inventory_node(63, transaction.clone()).await?;
3658        let node_b = ZakuraTestNode::builder(64).spawn().await?;
3659        node_b.connect_native(&node_a, TEST_NET_TIMEOUT).await?;
3660        let a_peer_id = node_peer_id(&node_a).await?;
3661
3662        let adapter = LegacyRequestAdapter::new(node_b.supervisor());
3663        let response = adapter
3664            .request_from_source(
3665                Request::TransactionsById(HashSet::from([available_id, missing_id])),
3666                Some(PeerSource::Zakura(a_peer_id)),
3667            )
3668            .await?;
3669
3670        let Response::Transactions(transactions) = response else {
3671            panic!("unexpected response: {response:?}");
3672        };
3673        assert!(transactions.iter().any(|response| {
3674            matches!(
3675                response,
3676                InventoryResponse::Available((tx, None)) if tx.id == transaction.id
3677            )
3678        }));
3679        assert!(transactions.iter().any(
3680            |response| matches!(response, InventoryResponse::Missing(id) if *id == missing_id)
3681        ));
3682
3683        node_a.shutdown().await;
3684        node_b.shutdown().await;
3685        Ok(())
3686    }
3687
3688    #[tokio::test]
3689    async fn request_adapter_drives_mock_chain_sync_progress_over_zakura() -> Result<(), BoxError> {
3690        let _guard = zakura_test::init();
3691        let block = Arc::new(Block::zcash_deserialize(
3692            BLOCK_TESTNET_141042_BYTES.as_slice(),
3693        )?);
3694        let transaction = UnminedTx::from(empty_v5_transaction(4));
3695        let (node_a, _pushed_rx) = normal_network_node(81, block.clone(), transaction).await?;
3696        let node_b = ZakuraTestNode::builder(82).spawn().await?;
3697        node_b.connect_native(&node_a, TEST_NET_TIMEOUT).await?;
3698        let a_peer_id = node_peer_id(&node_a).await?;
3699
3700        let adapter = LegacyRequestAdapter::new(node_b.supervisor());
3701        let mut local_chain = vec![block_hash(1)];
3702        let find_headers = adapter
3703            .request_from_source(
3704                Request::FindHeaders {
3705                    known_blocks: local_chain.clone(),
3706                    stop: Some(block.hash()),
3707                },
3708                Some(PeerSource::Zakura(a_peer_id.clone())),
3709            )
3710            .await?;
3711        let Response::BlockHeaders(headers) = find_headers else {
3712            panic!("unexpected FindHeaders response: {find_headers:?}");
3713        };
3714        assert!(matches!(
3715            headers.as_slice(),
3716            [header] if header.header == block.header
3717        ));
3718
3719        let find_blocks = adapter
3720            .request_from_source(
3721                Request::FindBlocks {
3722                    known_blocks: local_chain.clone(),
3723                    stop: Some(block.hash()),
3724                },
3725                Some(PeerSource::Zakura(a_peer_id.clone())),
3726            )
3727            .await?;
3728        let Response::BlockHashes(remote_hashes) = find_blocks else {
3729            panic!("unexpected FindBlocks response: {find_blocks:?}");
3730        };
3731        assert_eq!(remote_hashes, vec![block.hash()]);
3732
3733        let block_response = adapter
3734            .request_from_source(
3735                Request::BlocksByHashFrom {
3736                    hashes: remote_hashes.into_iter().collect(),
3737                    source: PeerSource::Zakura(a_peer_id),
3738                },
3739                None,
3740            )
3741            .await?;
3742        let Response::Blocks(blocks) = block_response else {
3743            panic!("unexpected block response: {block_response:?}");
3744        };
3745        for response in blocks {
3746            let received = response
3747                .available()
3748                .expect("mock sync peer serves the advertised block")
3749                .0;
3750            local_chain.push(received.hash());
3751        }
3752        assert_eq!(local_chain.last(), Some(&block.hash()));
3753        assert_eq!(local_chain.len(), 2);
3754
3755        node_a.shutdown().await;
3756        node_b.shutdown().await;
3757        Ok(())
3758    }
3759
3760    #[tokio::test]
3761    async fn controlled_three_node_network_gossips_fetches_and_advances_mock_chain(
3762    ) -> Result<(), BoxError> {
3763        let _guard = zakura_test::init();
3764        let block = Arc::new(Block::zcash_deserialize(
3765            BLOCK_TESTNET_141042_BYTES.as_slice(),
3766        )?);
3767        let transaction = UnminedTx::from(empty_v5_transaction(14));
3768        let (node_a, _pushed_rx) = normal_network_node(85, block.clone(), transaction).await?;
3769        let (node_b, mut rx_b) = legacy_node(86).await?;
3770        let (node_c, mut rx_c) = legacy_node(87).await?;
3771
3772        node_a.connect_native(&node_b, TEST_NET_TIMEOUT).await?;
3773        node_a.connect_native(&node_c, TEST_NET_TIMEOUT).await?;
3774        node_b.connect_native(&node_c, TEST_NET_TIMEOUT).await?;
3775        wait_registered_count(&node_a, 2).await?;
3776        wait_registered_count(&node_b, 2).await?;
3777        wait_registered_count(&node_c, 2).await?;
3778
3779        let a_peer_id = node_peer_id(&node_a).await?;
3780        let mut gossip = LegacyGossipAdapter::new(node_a.supervisor());
3781        gossip
3782            .ready()
3783            .await?
3784            .call(Request::AdvertiseBlockToAll(block.hash()))
3785            .await?;
3786
3787        match recv_request(&mut rx_b).await? {
3788            Request::AdvertiseBlock(hash, Some(PeerSource::Zakura(peer_id))) => {
3789                assert_eq!(hash, block.hash());
3790                assert_eq!(peer_id, a_peer_id);
3791            }
3792            request => panic!("unexpected B request: {request:?}"),
3793        }
3794        match recv_request(&mut rx_c).await? {
3795            Request::AdvertiseBlock(hash, Some(PeerSource::Zakura(_))) => {
3796                assert_eq!(hash, block.hash());
3797            }
3798            request => panic!("unexpected C request: {request:?}"),
3799        }
3800
3801        let adapter = LegacyRequestAdapter::new(node_c.supervisor());
3802        let mut local_chain = vec![block_hash(1)];
3803        let headers = adapter
3804            .request_from_source(
3805                Request::FindHeaders {
3806                    known_blocks: local_chain.clone(),
3807                    stop: Some(block.hash()),
3808                },
3809                Some(PeerSource::Zakura(a_peer_id.clone())),
3810            )
3811            .await?;
3812        assert!(matches!(
3813            headers,
3814            Response::BlockHeaders(headers) if matches!(
3815                headers.as_slice(),
3816                [header] if header.header == block.header
3817            )
3818        ));
3819
3820        let hashes = adapter
3821            .request_from_source(
3822                Request::FindBlocks {
3823                    known_blocks: local_chain.clone(),
3824                    stop: Some(block.hash()),
3825                },
3826                Some(PeerSource::Zakura(a_peer_id.clone())),
3827            )
3828            .await?;
3829        let Response::BlockHashes(hashes) = hashes else {
3830            panic!("unexpected FindBlocks response: {hashes:?}");
3831        };
3832        assert_eq!(hashes, vec![block.hash()]);
3833
3834        let blocks = adapter
3835            .request_from_source(
3836                Request::BlocksByHashFrom {
3837                    hashes: hashes.into_iter().collect(),
3838                    source: PeerSource::Zakura(a_peer_id),
3839                },
3840                None,
3841            )
3842            .await?;
3843        let Response::Blocks(blocks) = blocks else {
3844            panic!("unexpected block response: {blocks:?}");
3845        };
3846        for response in blocks {
3847            let received = response
3848                .available()
3849                .expect("mock sync peer serves the gossiped block")
3850                .0;
3851            local_chain.push(received.hash());
3852        }
3853        assert_eq!(local_chain.last(), Some(&block.hash()));
3854        assert_eq!(local_chain.len(), 2);
3855
3856        node_a.shutdown().await;
3857        node_b.shutdown().await;
3858        node_c.shutdown().await;
3859        Ok(())
3860    }
3861
3862    #[tokio::test]
3863    async fn request_adapter_supports_mempool_ping_and_push_transaction() -> Result<(), BoxError> {
3864        let _guard = zakura_test::init();
3865        let block = Arc::new(Block::zcash_deserialize(
3866            BLOCK_TESTNET_141042_BYTES.as_slice(),
3867        )?);
3868        let transaction = UnminedTx::from(empty_v5_transaction(5));
3869        let pushed_transaction = UnminedTx::from(empty_v5_transaction(6));
3870        let pushed_id = pushed_transaction.id;
3871        let (node_a, mut pushed_rx) = normal_network_node(83, block, transaction.clone()).await?;
3872        let node_b = ZakuraTestNode::builder(84).spawn().await?;
3873        node_b.connect_native(&node_a, TEST_NET_TIMEOUT).await?;
3874        let a_peer_id = node_peer_id(&node_a).await?;
3875
3876        let adapter = LegacyRequestAdapter::new(node_b.supervisor());
3877        let mempool = adapter
3878            .request_from_source(
3879                Request::MempoolTransactionIds,
3880                Some(PeerSource::Zakura(a_peer_id.clone())),
3881            )
3882            .await?;
3883        assert_eq!(mempool, Response::TransactionIds(vec![transaction.id]));
3884
3885        let ping = adapter
3886            .request_from_source(
3887                Request::Ping(Default::default()),
3888                Some(PeerSource::Zakura(a_peer_id)),
3889            )
3890            .await?;
3891        assert!(matches!(ping, Response::Pong(_)));
3892
3893        let push_response = adapter
3894            .request_from_source(Request::PushTransaction(pushed_transaction, None), None)
3895            .await?;
3896        assert_eq!(push_response, Response::Nil);
3897        assert_eq!(recv_pushed_tx_id(&mut pushed_rx).await?, pushed_id);
3898
3899        node_a.shutdown().await;
3900        node_b.shutdown().await;
3901        Ok(())
3902    }
3903
3904    #[tokio::test]
3905    async fn slow_request_does_not_block_gossip_worker() -> Result<(), BoxError> {
3906        let (request_tx, request_rx) = oneshot::channel();
3907        let (gossip_tx, mut gossip_rx) = tokio::sync::mpsc::unbounded_channel();
3908        let (release_tx, release_rx) = tokio::sync::watch::channel(false);
3909        let (inbound_tx, inbound_rx) = mpsc::channel(4);
3910        let supervisor = ZakuraSupervisorHandle::new(1);
3911        let peer_id = ZakuraPeerId::new(vec![6; 32]).expect("test peer id is within bounds");
3912
3913        let worker = tokio::spawn(legacy_gossip_worker(
3914            SlowRequestThenRecorder {
3915                release: release_rx,
3916                tx: gossip_tx,
3917            },
3918            inbound_rx,
3919            LegacyGossipForwarder::new(supervisor),
3920            ZakuraTrace::noop(),
3921        ));
3922
3923        inbound_tx
3924            .send(LegacyInboundWork::Request(LegacyRequestInbound {
3925                peer_id: peer_id.clone(),
3926                request_id: 1,
3927                frame: LegacyRequestFrame::BlocksByHash(vec![block_hash(11)]),
3928                response_tx: request_tx,
3929            }))
3930            .await?;
3931        inbound_tx
3932            .send(LegacyInboundWork::Gossip(LegacyGossipInbound {
3933                peer_id: peer_id.clone(),
3934                frame: LegacyGossipFrame::AdvertiseBlock(block_hash(12)),
3935            }))
3936            .await?;
3937
3938        match recv_request(&mut gossip_rx).await? {
3939            Request::AdvertiseBlock(hash, Some(PeerSource::Zakura(source))) => {
3940                assert_eq!(hash, block_hash(12));
3941                assert_eq!(source, peer_id);
3942            }
3943            request => panic!("unexpected request: {request:?}"),
3944        }
3945
3946        release_tx.send(true)?;
3947        match tokio::time::timeout(Duration::from_secs(1), request_rx).await??? {
3948            Response::Blocks(blocks) => {
3949                assert_eq!(blocks, vec![InventoryResponse::Missing(block_hash(11))]);
3950            }
3951            response => panic!("unexpected request response: {response:?}"),
3952        }
3953
3954        drop(inbound_tx);
3955        worker.await?;
3956        Ok(())
3957    }
3958
3959    /// Regression: while the legacy inbound service is slow/timing out,
3960    /// `handle_legacy_gossip` returns without `mark_seen`, so an authenticated peer
3961    /// could replay the same valid advertisement and make the serial worker re-pay
3962    /// the full readiness/call timeout for every queued duplicate. After one
3963    /// expensive failed attempt the cooldown must suppress identical duplicates.
3964    ///
3965    /// Paused time auto-advances the `LEGACY_GOSSIP_SERVICE_TIMEOUT` so the first
3966    /// call's timeout fires without a real wall-clock wait.
3967    #[tokio::test(start_paused = true)]
3968    async fn duplicate_gossip_does_not_repeat_expensive_attempts_while_service_is_slow(
3969    ) -> Result<(), BoxError> {
3970        let calls = Arc::new(AtomicUsize::new(0));
3971        let (inbound_tx, inbound_rx) = mpsc::channel(16);
3972        let supervisor = ZakuraSupervisorHandle::new(1);
3973        let peer_id = ZakuraPeerId::new(vec![7; 32]).expect("test peer id is within bounds");
3974
3975        let worker = tokio::spawn(legacy_gossip_worker(
3976            CountingPendingService {
3977                calls: calls.clone(),
3978            },
3979            inbound_rx,
3980            LegacyGossipForwarder::new(supervisor),
3981            ZakuraTrace::noop(),
3982        ));
3983
3984        // The call never completes, so the permanent first-seen cache is never
3985        // updated; only the post-timeout cooldown can suppress these duplicates.
3986        let frame = LegacyGossipFrame::AdvertiseBlock(block_hash(99));
3987        for _ in 0..4 {
3988            inbound_tx
3989                .send(LegacyInboundWork::Gossip(LegacyGossipInbound {
3990                    peer_id: peer_id.clone(),
3991                    frame: frame.clone(),
3992                }))
3993                .await?;
3994        }
3995
3996        drop(inbound_tx);
3997        worker.await?;
3998
3999        assert_eq!(
4000            calls.load(Ordering::SeqCst),
4001            1,
4002            "duplicate gossip frames must not each re-pay the inbound readiness/call \
4003             timeout while the service is slow; expected one expensive attempt with \
4004             the rest suppressed by the cooldown"
4005        );
4006
4007        Ok(())
4008    }
4009
4010    #[tokio::test]
4011    async fn service_request_routes_to_advertiser_then_fallback_after_missing(
4012    ) -> Result<(), BoxError> {
4013        let _guard = zakura_test::init();
4014        let transaction = UnminedTx::from(empty_v5_transaction(3));
4015        let txid = transaction.id;
4016        let (advertiser, mut advertiser_rx) = recording_inventory_node(65, None).await?;
4017        let (fallback, mut fallback_rx) =
4018            recording_inventory_node(66, Some(transaction.clone())).await?;
4019        // The requester dials two loopback peers (advertiser + fallback), so it
4020        // raises the production Zakura per-IP cap that the default test node
4021        // enforces; this test exercises service routing, not per-IP admission.
4022        let requester = ZakuraTestNode::builder(67)
4023            .max_connections_per_ip(8)
4024            .spawn()
4025            .await?;
4026        requester
4027            .connect_native(&advertiser, TEST_NET_TIMEOUT)
4028            .await?;
4029        requester
4030            .connect_native(&fallback, TEST_NET_TIMEOUT)
4031            .await?;
4032        wait_registered_count(&requester, 2).await?;
4033        let advertiser_id = node_peer_id(&advertiser).await?;
4034
4035        let mut adapter = LegacyRequestAdapter::new(requester.supervisor());
4036        let response = adapter
4037            .ready()
4038            .await?
4039            .call(Request::TransactionsByIdFrom {
4040                ids: HashSet::from([txid]),
4041                source: PeerSource::Zakura(advertiser_id),
4042            })
4043            .await?;
4044
4045        match recv_request(&mut advertiser_rx).await? {
4046            Request::TransactionsById(ids) => assert_eq!(ids, HashSet::from([txid])),
4047            request => panic!("unexpected advertiser request: {request:?}"),
4048        }
4049        match recv_request(&mut fallback_rx).await? {
4050            Request::TransactionsById(ids) => assert_eq!(ids, HashSet::from([txid])),
4051            request => panic!("unexpected fallback request: {request:?}"),
4052        }
4053
4054        let Response::Transactions(transactions) = response else {
4055            panic!("unexpected response: {response:?}");
4056        };
4057        assert!(matches!(
4058            transactions.as_slice(),
4059            [InventoryResponse::Available((tx, None))] if tx.id == transaction.id
4060        ));
4061
4062        advertiser.shutdown().await;
4063        fallback.shutdown().await;
4064        requester.shutdown().await;
4065        Ok(())
4066    }
4067
4068    #[tokio::test]
4069    async fn first_seen_gossip_forwards_across_line_and_drops_duplicates() -> Result<(), BoxError> {
4070        let _guard = zakura_test::init();
4071        let (node_a, mut rx_a) = legacy_node(21).await?;
4072        let (node_b, mut rx_b) = legacy_node(22).await?;
4073        let (node_c, mut rx_c) = legacy_node(23).await?;
4074
4075        node_a.connect_native(&node_b, TEST_NET_TIMEOUT).await?;
4076        node_b.connect_native(&node_c, TEST_NET_TIMEOUT).await?;
4077
4078        wait_registered_count(&node_b, 2).await?;
4079
4080        let a_peer_id = node_peer_id(&node_a).await?;
4081        let b_peer_id = node_peer_id(&node_b).await?;
4082        let block_hash = block_hash(42);
4083        let mut adapter = LegacyGossipAdapter::new(node_a.supervisor());
4084        adapter
4085            .ready()
4086            .await?
4087            .call(Request::AdvertiseBlockToAll(block_hash))
4088            .await?;
4089
4090        match recv_request(&mut rx_b).await? {
4091            Request::AdvertiseBlock(hash, Some(PeerSource::Zakura(peer_id))) => {
4092                assert_eq!(hash, block_hash);
4093                assert_eq!(peer_id, a_peer_id);
4094            }
4095            request => panic!("unexpected B request: {request:?}"),
4096        }
4097        match recv_request(&mut rx_c).await? {
4098            Request::AdvertiseBlock(hash, Some(PeerSource::Zakura(peer_id))) => {
4099                assert_eq!(hash, block_hash);
4100                assert_eq!(peer_id, b_peer_id);
4101            }
4102            request => panic!("unexpected C request: {request:?}"),
4103        }
4104        assert!(rx_a.try_recv().is_err());
4105
4106        adapter
4107            .ready()
4108            .await?
4109            .call(Request::AdvertiseBlockToAll(block_hash))
4110            .await?;
4111        tokio::time::sleep(Duration::from_millis(100)).await;
4112        assert!(rx_c.try_recv().is_err());
4113
4114        node_a.shutdown().await;
4115        node_b.shutdown().await;
4116        node_c.shutdown().await;
4117        Ok(())
4118    }
4119
4120    #[tokio::test]
4121    async fn local_origin_echo_is_dropped_by_shared_first_seen_cache() -> Result<(), BoxError> {
4122        let _guard = zakura_test::init();
4123        let (node_a, mut rx_a) = legacy_node(51).await?;
4124        let (node_b, mut rx_b) = legacy_node(52).await?;
4125        node_a.connect_native(&node_b, TEST_NET_TIMEOUT).await?;
4126        let hostile = HostilePeer::connect_native(&node_a, 53).await?;
4127        wait_registered_count(&node_a, 2).await?;
4128
4129        let block_hash = block_hash(88);
4130        let mut adapter = LegacyGossipAdapter::new(node_a.supervisor());
4131        adapter
4132            .ready()
4133            .await?
4134            .call(Request::AdvertiseBlockToAll(block_hash))
4135            .await?;
4136        let _ = recv_request(&mut rx_b).await?;
4137
4138        let echo_payload = LegacyGossipFrame::AdvertiseBlock(block_hash)
4139            .encode_frame()?
4140            .payload;
4141        hostile
4142            .send_frame(ZAKURA_STREAM_GOSSIP, echo_payload)
4143            .await?;
4144        tokio::time::sleep(Duration::from_millis(100)).await;
4145
4146        assert!(
4147            rx_a.try_recv().is_err(),
4148            "origin must not accept its own echo"
4149        );
4150        assert!(
4151            rx_b.try_recv().is_err(),
4152            "origin must not re-forward its own echo"
4153        );
4154
4155        hostile.shutdown().await;
4156        node_a.shutdown().await;
4157        node_b.shutdown().await;
4158        Ok(())
4159    }
4160
4161    #[tokio::test]
4162    async fn saturated_peer_does_not_block_honest_fanout() -> Result<(), BoxError> {
4163        let saturated_peer = ZakuraPeerId::new(vec![1; 32]).expect("test peer id is within bounds");
4164        let honest_peer = ZakuraPeerId::new(vec![2; 32]).expect("test peer id is within bounds");
4165        let (saturated, _saturated_rx) = framed_channel(1);
4166        saturated.try_send(Frame {
4167            message_type: MSG_ADVERTISE_BLOCK,
4168            flags: 0,
4169            payload: vec![1],
4170        })?;
4171
4172        let (honest, mut honest_rx) = framed_channel(1);
4173        let block_hash = block_hash(7);
4174
4175        let result = send_to_sessions(
4176            vec![
4177                LegacyGossipPeerSession::new(saturated_peer, 0, saturated),
4178                LegacyGossipPeerSession::new(honest_peer, 0, honest),
4179            ],
4180            LegacyGossipFrame::AdvertiseBlock(block_hash),
4181        );
4182        let outbound = tokio::time::timeout(Duration::from_secs(1), honest_rx.recv())
4183            .await?
4184            .expect("honest peer receives fanout");
4185        assert_eq!(
4186            LegacyGossipFrame::decode_frame(outbound)?,
4187            LegacyGossipFrame::AdvertiseBlock(block_hash)
4188        );
4189
4190        assert!(
4191            result.is_err(),
4192            "saturated peer failure is reported after honest peer is attempted"
4193        );
4194        Ok(())
4195    }
4196
4197    #[tokio::test]
4198    async fn new_peer_receives_latest_block_advertised_before_gossip_stream_ready(
4199    ) -> Result<(), BoxError> {
4200        let supervisor = ZakuraSupervisorHandle::new(991);
4201        let broadcast = ZakuraGossipBroadcast::new(supervisor);
4202        let block_hash = block_hash(91);
4203
4204        broadcast
4205            .broadcast(LegacyGossipFrame::AdvertiseBlock(block_hash), None)
4206            .await?;
4207
4208        let peer_id = ZakuraPeerId::new(vec![91; 32]).expect("test peer id is within bounds");
4209        let (sender, mut receiver) = framed_channel(1);
4210        let session = LegacyGossipPeerSession::new(peer_id, 0, sender);
4211        broadcast.outbound.insert(session.clone());
4212        broadcast
4213            .outbound
4214            .replay_latest_block_to_peer(session)
4215            .await?;
4216
4217        let replayed = tokio::time::timeout(Duration::from_secs(1), receiver.recv())
4218            .await?
4219            .expect("new peer receives latest block replay");
4220        assert_eq!(
4221            LegacyGossipFrame::decode_frame(replayed)?,
4222            LegacyGossipFrame::AdvertiseBlock(block_hash)
4223        );
4224        Ok(())
4225    }
4226
4227    #[tokio::test]
4228    async fn legacy_gossip_replay_panic_cancels_peer_and_removes_outbound_session(
4229    ) -> Result<(), BoxError> {
4230        let (inbound_tx, _inbound_rx) = mpsc::channel(1);
4231        let outbound = LegacyGossipOutbound::default();
4232        let sink = LegacyGossipSink {
4233            inbound_tx,
4234            outbound: outbound.clone(),
4235            trace: ZakuraTrace::noop(),
4236        };
4237        let peer_id = ZakuraPeerId::new(vec![92; 32]).expect("test peer id is within bounds");
4238        let cancel_token = tokio_util::sync::CancellationToken::new();
4239        let (peer, _peer_send) = legacy_gossip_peer(peer_id.clone(), cancel_token.clone());
4240
4241        let poisoned_latest_block = outbound.latest_block.clone();
4242        let _ = std::panic::catch_unwind(move || {
4243            let _guard = poisoned_latest_block
4244                .lock()
4245                .expect("latest-block mutex starts unpoisoned");
4246            panic!("poison latest-block mutex before replay");
4247        });
4248
4249        sink.add_peer(peer);
4250        wait_for_legacy_gossip_panic_cleanup(&outbound, &peer_id, &cancel_token).await
4251    }
4252
4253    #[tokio::test]
4254    async fn legacy_gossip_recv_loop_panic_cancels_peer_and_removes_outbound_session(
4255    ) -> Result<(), BoxError> {
4256        let (inbound_tx, _inbound_rx) = mpsc::channel(1);
4257        let outbound = LegacyGossipOutbound::default();
4258        let sink = LegacyGossipSink {
4259            inbound_tx,
4260            outbound: outbound.clone(),
4261            trace: ZakuraTrace::noop(),
4262        };
4263        let peer_id = ZakuraPeerId::new(vec![93; 32]).expect("test peer id is within bounds");
4264        let cancel_token = tokio_util::sync::CancellationToken::new();
4265        let (peer, peer_send) = legacy_gossip_peer(peer_id.clone(), cancel_token.clone());
4266
4267        arm_legacy_gossip_recv_loop_panic(peer_id.clone());
4268        sink.add_peer(peer);
4269        peer_send
4270            .send(LegacyGossipFrame::AdvertiseBlock(block_hash(93)).encode_frame()?)
4271            .await
4272            .map_err(|_| -> BoxError { "failed to send test gossip frame".into() })?;
4273
4274        wait_for_legacy_gossip_panic_cleanup(&outbound, &peer_id, &cancel_token).await
4275    }
4276
4277    #[tokio::test]
4278    async fn stale_legacy_gossip_teardown_keeps_replacement_session() {
4279        let (inbound_tx, _inbound_rx) = mpsc::channel(1);
4280        let outbound = LegacyGossipOutbound::default();
4281        let sink = LegacyGossipSink {
4282            inbound_tx,
4283            outbound: outbound.clone(),
4284            trace: ZakuraTrace::noop(),
4285        };
4286        let peer_id = ZakuraPeerId::new(vec![94; 32]).expect("test peer id is within bounds");
4287        let old_conn_id = 1;
4288        let new_conn_id = 2;
4289        let old_cancel = tokio_util::sync::CancellationToken::new();
4290        let new_cancel = tokio_util::sync::CancellationToken::new();
4291        let (old_peer, _old_peer_send) =
4292            legacy_gossip_peer_with_conn(peer_id.clone(), old_conn_id, old_cancel);
4293        let (new_peer, _new_peer_send) =
4294            legacy_gossip_peer_with_conn(peer_id.clone(), new_conn_id, new_cancel);
4295
4296        sink.add_peer(old_peer);
4297        assert!(
4298            outbound.contains(&peer_id),
4299            "old legacy gossip session is registered",
4300        );
4301        sink.add_peer(new_peer);
4302        assert!(
4303            outbound.contains(&peer_id),
4304            "replacement legacy gossip session is registered",
4305        );
4306
4307        let (stale_peer, _stale_peer_send) = legacy_gossip_peer_with_conn(
4308            peer_id.clone(),
4309            old_conn_id,
4310            tokio_util::sync::CancellationToken::new(),
4311        );
4312        sink.add_peer(stale_peer);
4313
4314        sink.remove_peer(&peer_id, old_conn_id);
4315        assert!(
4316            outbound.contains(&peer_id),
4317            "stale cleanup must not remove the replacement legacy gossip session",
4318        );
4319
4320        sink.remove_peer(&peer_id, new_conn_id);
4321        assert!(
4322            !outbound.contains(&peer_id),
4323            "live cleanup removes the replacement legacy gossip session",
4324        );
4325    }
4326
4327    #[tokio::test]
4328    async fn disconnected_peer_send_returns_error() -> Result<(), BoxError> {
4329        let peer_id = ZakuraPeerId::new(vec![9; 32]).expect("test peer id is within bounds");
4330        let (disconnected, rx) = framed_channel(1);
4331        drop(rx);
4332
4333        let error = send_to_sessions(
4334            vec![LegacyGossipPeerSession::new(peer_id, 0, disconnected)],
4335            LegacyGossipFrame::AdvertiseBlock(block_hash(8)),
4336        )
4337        .expect_err("closed outbound queue reports an adapter error");
4338        assert!(
4339            error
4340                .to_string()
4341                .contains("ordered stream send queue is closed"),
4342            "unexpected error: {error}"
4343        );
4344        Ok(())
4345    }
4346
4347    #[tokio::test]
4348    async fn transient_inbound_failure_does_not_poison_first_seen_cache() -> Result<(), BoxError> {
4349        let (request_tx, mut request_rx) = tokio::sync::mpsc::unbounded_channel();
4350        let attempts = Arc::new(AtomicUsize::new(0));
4351        let (inbound_tx, inbound_rx) = mpsc::channel(4);
4352        let supervisor = ZakuraSupervisorHandle::new(1);
4353        let peer_id = ZakuraPeerId::new(vec![3; 32]).expect("test peer id is within bounds");
4354        let frame = LegacyGossipFrame::AdvertiseBlock(block_hash(5));
4355
4356        let worker = tokio::spawn(legacy_gossip_worker(
4357            FailsOnceRecorder {
4358                attempts: attempts.clone(),
4359                tx: request_tx,
4360            },
4361            inbound_rx,
4362            LegacyGossipForwarder::new(supervisor),
4363            ZakuraTrace::noop(),
4364        ));
4365
4366        inbound_tx
4367            .send(LegacyInboundWork::Gossip(LegacyGossipInbound {
4368                peer_id: peer_id.clone(),
4369                frame: frame.clone(),
4370            }))
4371            .await?;
4372        inbound_tx
4373            .send(LegacyInboundWork::Gossip(LegacyGossipInbound {
4374                peer_id: peer_id.clone(),
4375                frame: frame.clone(),
4376            }))
4377            .await?;
4378
4379        match recv_request(&mut request_rx).await? {
4380            Request::AdvertiseBlock(hash, Some(PeerSource::Zakura(source))) => {
4381                assert_eq!(hash, block_hash(5));
4382                assert_eq!(source, peer_id);
4383            }
4384            request => panic!("unexpected request: {request:?}"),
4385        }
4386        assert_eq!(attempts.load(Ordering::SeqCst), 2);
4387
4388        inbound_tx
4389            .send(LegacyInboundWork::Gossip(LegacyGossipInbound {
4390                peer_id,
4391                frame,
4392            }))
4393            .await?;
4394        tokio::time::sleep(Duration::from_millis(100)).await;
4395        assert_eq!(
4396            attempts.load(Ordering::SeqCst),
4397            2,
4398            "successful delivery marks the inventory seen"
4399        );
4400
4401        drop(inbound_tx);
4402        worker.await?;
4403        Ok(())
4404    }
4405
4406    #[test]
4407    fn inbound_queue_full_drops_without_rejecting_peer_but_malformed_rejects() {
4408        let (inbound_tx, _inbound_rx) = mpsc::channel(1);
4409        let sink = LegacyGossipSink {
4410            inbound_tx,
4411            outbound: LegacyGossipOutbound::default(),
4412            trace: ZakuraTrace::noop(),
4413        };
4414        let peer_id = ZakuraPeerId::new(vec![4; 32]).expect("test peer id is within bounds");
4415        let frame = LegacyGossipFrame::AdvertiseBlock(block_hash(6))
4416            .encode_frame()
4417            .expect("frame encodes");
4418
4419        assert!(sink
4420            .deliver(peer_id.clone(), ZAKURA_STREAM_GOSSIP, frame.clone())
4421            .is_ok());
4422        assert!(
4423            sink.deliver(peer_id.clone(), ZAKURA_STREAM_GOSSIP, frame)
4424                .is_ok(),
4425            "queue-full overload is shed without disconnecting the peer"
4426        );
4427        assert!(sink
4428            .deliver(
4429                peer_id,
4430                ZAKURA_STREAM_GOSSIP,
4431                Frame {
4432                    message_type: MSG_ADVERTISE_BLOCK,
4433                    flags: 1,
4434                    payload: Vec::new(),
4435                },
4436            )
4437            .is_err());
4438    }
4439
4440    #[test]
4441    fn tx_gossip_round_trips_legacy_and_witnessed_ids() {
4442        let frame =
4443            LegacyGossipFrame::AdvertiseTransactionIds(vec![legacy_tx_id(2), witnessed_tx_id(3)]);
4444
4445        assert_eq!(
4446            LegacyGossipFrame::decode_frame(frame.encode_frame().expect("frame encodes"))
4447                .expect("frame decodes"),
4448            frame
4449        );
4450    }
4451
4452    #[test]
4453    fn inventory_request_frames_round_trip_and_enforce_bounds() {
4454        let block_request = LegacyRequestFrame::BlocksByHash(vec![block_hash(1), block_hash(2)]);
4455        assert_eq!(
4456            LegacyRequestFrame::decode_frame(block_request.encode_frame().expect("frame encodes"))
4457                .expect("frame decodes"),
4458            block_request
4459        );
4460
4461        let tx_request =
4462            LegacyRequestFrame::TransactionsById(vec![legacy_tx_id(3), witnessed_tx_id(4)]);
4463        assert_eq!(
4464            LegacyRequestFrame::decode_frame(tx_request.encode_frame().expect("frame encodes"))
4465                .expect("frame decodes"),
4466            tx_request
4467        );
4468
4469        let find_blocks = LegacyRequestFrame::FindBlocks {
4470            known_blocks: vec![block_hash(5)],
4471            stop: Some(block_hash(6)),
4472        };
4473        assert_eq!(
4474            LegacyRequestFrame::decode_frame(find_blocks.encode_frame().expect("frame encodes"))
4475                .expect("frame decodes"),
4476            find_blocks
4477        );
4478
4479        let find_headers = LegacyRequestFrame::FindHeaders {
4480            known_blocks: vec![block_hash(7)],
4481            stop: None,
4482        };
4483        assert_eq!(
4484            LegacyRequestFrame::decode_frame(find_headers.encode_frame().expect("frame encodes"))
4485                .expect("frame decodes"),
4486            find_headers
4487        );
4488
4489        for request in [
4490            LegacyRequestFrame::MempoolTransactionIds,
4491            LegacyRequestFrame::Ping,
4492            LegacyRequestFrame::PushTransaction(UnminedTx::from(empty_v5_transaction(8))),
4493        ] {
4494            assert_eq!(
4495                LegacyRequestFrame::decode_frame(request.encode_frame().expect("frame encodes"))
4496                    .expect("frame decodes"),
4497                request
4498            );
4499        }
4500
4501        let max = usize::try_from(MAX_TX_INV_IN_SENT_MESSAGE).expect("test cap fits usize");
4502        let oversized = Frame {
4503            message_type: MSG_REQUEST_BLOCKS_BY_HASH,
4504            flags: 0,
4505            payload: encoded_count(max + 1),
4506        };
4507        assert!(matches!(
4508            LegacyRequestFrame::decode_frame(oversized),
4509            Err(LegacyGossipError::TooManyInventoryItems(count)) if count == max + 1
4510        ));
4511
4512        let max_locator =
4513            usize::try_from(MAX_BLOCK_LOCATOR_LENGTH).expect("test locator cap fits usize");
4514        let oversized_locator = Frame {
4515            message_type: MSG_REQUEST_FIND_HEADERS,
4516            flags: 0,
4517            payload: encoded_count(max_locator + 1),
4518        };
4519        assert!(matches!(
4520            LegacyRequestFrame::decode_frame(oversized_locator),
4521            Err(LegacyGossipError::TooManyBlockLocatorHashes(count)) if count == max_locator + 1
4522        ));
4523    }
4524
4525    #[test]
4526    fn response_codec_rejects_wrong_request_id_and_oversized_chunks() {
4527        let mut wrong_id_payload = Vec::new();
4528        wrong_id_payload.extend_from_slice(&2_u64.to_le_bytes());
4529        wrong_id_payload.push(1);
4530        let wrong_id = Frame {
4531            message_type: MSG_RESPONSE_BLOCK,
4532            flags: 0,
4533            payload: wrong_id_payload,
4534        };
4535        assert!(matches!(
4536            LegacyResponseCodec::decode_response(
4537                1,
4538                LegacyRequestKind::Blocks,
4539                vec![wrong_id],
4540                None
4541            ),
4542            Err(LegacyGossipError::WrongRequestId {
4543                expected: 1,
4544                actual: 2
4545            })
4546        ));
4547
4548        let mut oversized_payload = Vec::new();
4549        oversized_payload.extend_from_slice(&1_u64.to_le_bytes());
4550        oversized_payload.push(0);
4551        oversized_payload.resize(
4552            MAX_PROTOCOL_MESSAGE_LEN + RESPONSE_CHUNK_HEADER_BYTES + 1,
4553            0,
4554        );
4555        let oversized = Frame {
4556            message_type: MSG_RESPONSE_BLOCK,
4557            flags: 0,
4558            payload: oversized_payload,
4559        };
4560        assert!(matches!(
4561            LegacyResponseCodec::decode_response(
4562                1,
4563                LegacyRequestKind::Blocks,
4564                vec![oversized],
4565                None
4566            ),
4567            Err(LegacyGossipError::OversizedResponse(_))
4568        ));
4569    }
4570
4571    /// The inbound service returns `Response::Nil` (a lone nil frame) for an empty
4572    /// FindBlocks/FindHeaders/MempoolTransactionIds result, so the codec must
4573    /// decode that into each kind's empty response rather than rejecting it.
4574    #[test]
4575    fn nil_response_decodes_as_each_kinds_empty_result() {
4576        let request_id = 7;
4577        let nil = || id_only_frame(MSG_RESPONSE_NIL, request_id);
4578
4579        assert_eq!(
4580            LegacyResponseCodec::decode_response(
4581                request_id,
4582                LegacyRequestKind::FindBlocks,
4583                vec![nil()],
4584                None,
4585            )
4586            .expect("nil is a valid empty FindBlocks response"),
4587            Response::BlockHashes(vec![]),
4588        );
4589        assert_eq!(
4590            LegacyResponseCodec::decode_response(
4591                request_id,
4592                LegacyRequestKind::FindHeaders,
4593                vec![nil()],
4594                None,
4595            )
4596            .expect("nil is a valid empty FindHeaders response"),
4597            Response::BlockHeaders(vec![]),
4598        );
4599        assert_eq!(
4600            LegacyResponseCodec::decode_response(
4601                request_id,
4602                LegacyRequestKind::MempoolTransactionIds,
4603                vec![nil()],
4604                None,
4605            )
4606            .expect("nil is a valid empty mempool response"),
4607            Response::TransactionIds(vec![]),
4608        );
4609        assert_eq!(
4610            LegacyResponseCodec::decode_response(
4611                request_id,
4612                LegacyRequestKind::PushTransaction,
4613                vec![nil()],
4614                None,
4615            )
4616            .expect("nil acknowledges a pushed transaction"),
4617            Response::Nil,
4618        );
4619
4620        // Inventory fetches and Ping must NOT accept a bare nil: for those it
4621        // means the peer has nothing, so the codec rejects it and the caller
4622        // falls back to another peer instead of returning an empty fetch.
4623        for kind in [
4624            LegacyRequestKind::Blocks,
4625            LegacyRequestKind::Transactions,
4626            LegacyRequestKind::Ping,
4627        ] {
4628            assert!(
4629                matches!(
4630                    LegacyResponseCodec::decode_response(request_id, kind, vec![nil()], None),
4631                    Err(LegacyGossipError::UnexpectedResponse(_)),
4632                ),
4633                "nil must be rejected for {kind:?} so the caller can fall back",
4634            );
4635        }
4636    }
4637
4638    #[test]
4639    fn response_codec_chunks_to_negotiated_frame_limit() -> Result<(), BoxError> {
4640        let block = Arc::new(Block::zcash_deserialize(
4641            BLOCK_TESTNET_141042_BYTES.as_slice(),
4642        )?);
4643        let max_frame_bytes =
4644            u32::try_from(FRAME_HEADER_BYTES + RESPONSE_CHUNK_HEADER_BYTES + 256)?;
4645        let frames = LegacyResponseCodec::encode_response(
4646            7,
4647            Response::Blocks(vec![InventoryResponse::Available((block.clone(), None))]),
4648            max_frame_bytes,
4649            max_frame_bytes,
4650        )?;
4651
4652        assert!(frames.len() > 1, "large block response must be chunked");
4653        for frame in &frames {
4654            frame.encode(max_frame_bytes)?;
4655        }
4656
4657        let response =
4658            LegacyResponseCodec::decode_response(7, LegacyRequestKind::Blocks, frames, None)?;
4659        let Response::Blocks(blocks) = response else {
4660            panic!("unexpected response: {response:?}");
4661        };
4662        assert!(matches!(
4663            blocks.as_slice(),
4664            [InventoryResponse::Available((received, None))] if received.hash() == block.hash()
4665        ));
4666
4667        Ok(())
4668    }
4669
4670    /// Regression test for `claude-outbound-write-ignores-message-cap` (legacy
4671    /// response chunking facet).
4672    ///
4673    /// The handshake clamps `max_frame_bytes` and `max_message_bytes`
4674    /// independently, so a peer can negotiate a message cap well below the frame
4675    /// cap. `push_chunked_response` sized chunk frames against the frame cap
4676    /// alone, so each chunk frame's payload could exceed the peer's accepted
4677    /// `max_message_bytes`. The request-response writer (`write_response_frame`)
4678    /// and the peer both reject such a frame as oversize, wasting the encode and
4679    /// causing avoidable disconnects/interop loss. Chunking must size against the
4680    /// effective cap `min(frame cap, message cap)` so every emitted chunk frame
4681    /// fits the negotiated message cap while still round-tripping.
4682    #[test]
4683    fn encode_response_chunks_respect_message_cap() -> Result<(), BoxError> {
4684        let block = Arc::new(Block::zcash_deserialize(
4685            BLOCK_TESTNET_141042_BYTES.as_slice(),
4686        )?);
4687
4688        // Large frame cap, small message cap: the divergence the handshake
4689        // permits. The message cap allows a 256-byte chunk payload plus the
4690        // per-chunk response header.
4691        let max_frame_bytes = u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?;
4692        let max_message_bytes = u32::try_from(RESPONSE_CHUNK_HEADER_BYTES + 256)?;
4693
4694        let frames = LegacyResponseCodec::encode_response(
4695            7,
4696            Response::Blocks(vec![InventoryResponse::Available((block.clone(), None))]),
4697            max_frame_bytes,
4698            max_message_bytes,
4699        )?;
4700
4701        assert!(
4702            frames.len() > 1,
4703            "a block larger than the negotiated message cap must be chunked, not emitted as one \
4704             over-cap frame"
4705        );
4706        for frame in &frames {
4707            assert!(
4708                frame.payload.len() <= max_message_bytes as usize,
4709                "chunk frame payload {} exceeds the negotiated max_message_bytes {}; the peer \
4710                 (and write_response_frame) would reject it as oversize",
4711                frame.payload.len(),
4712                max_message_bytes,
4713            );
4714            // Each chunk must still fit the frame cap so the transport encodes it.
4715            frame.encode(max_frame_bytes)?;
4716        }
4717
4718        // The smaller chunking must still round-trip back to the original block.
4719        let response =
4720            LegacyResponseCodec::decode_response(7, LegacyRequestKind::Blocks, frames, None)?;
4721        let Response::Blocks(blocks) = response else {
4722            panic!("unexpected response: {response:?}");
4723        };
4724        assert!(matches!(
4725            blocks.as_slice(),
4726            [InventoryResponse::Available((received, None))] if received.hash() == block.hash()
4727        ));
4728
4729        Ok(())
4730    }
4731
4732    /// A legacy block response must be bound to a hash we actually requested.
4733    ///
4734    /// Block responses are correlated only by request id and request kind, so
4735    /// without binding, a peer can substitute any other valid block for the one
4736    /// requested. The decoder must reject a delivered block whose hash is not in
4737    /// the requested set, and accept it when it is.
4738    #[test]
4739    fn decode_response_binds_blocks_to_requested_hashes() -> Result<(), BoxError> {
4740        let block = Arc::new(Block::zcash_deserialize(
4741            BLOCK_TESTNET_141042_BYTES.as_slice(),
4742        )?);
4743        let frame_cap = u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?;
4744        let frames = LegacyResponseCodec::encode_response(
4745            7,
4746            Response::Blocks(vec![InventoryResponse::Available((block.clone(), None))]),
4747            frame_cap,
4748            frame_cap,
4749        )?;
4750
4751        // A peer substitutes a real block we never asked for. Bound against a
4752        // requested-hash set that does not contain it, the codec rejects it
4753        // instead of correlating the response by request id and kind alone.
4754        let unrelated: HashSet<block::Hash> = std::iter::once(block_hash(99)).collect();
4755        assert!(
4756            matches!(
4757                LegacyResponseCodec::decode_response(
4758                    7,
4759                    LegacyRequestKind::Blocks,
4760                    frames.clone(),
4761                    Some(&unrelated),
4762                ),
4763                Err(LegacyGossipError::UnsolicitedBlock(_)),
4764            ),
4765            "a block whose hash was not requested must be rejected",
4766        );
4767
4768        // The same response is accepted when its hash is among those requested.
4769        let requested: HashSet<block::Hash> = std::iter::once(block.hash()).collect();
4770        let response = LegacyResponseCodec::decode_response(
4771            7,
4772            LegacyRequestKind::Blocks,
4773            frames,
4774            Some(&requested),
4775        )?;
4776        assert!(matches!(
4777            response,
4778            Response::Blocks(blocks)
4779                if matches!(
4780                    blocks.as_slice(),
4781                    [InventoryResponse::Available((received, None))]
4782                        if received.hash() == block.hash()
4783                )
4784        ));
4785
4786        Ok(())
4787    }
4788
4789    /// Regression test for `claude-legacy-responder-response-aggregation-unbounded`.
4790    ///
4791    /// An authenticated peer can name up to `MAX_TX_INV_IN_SENT_MESSAGE`
4792    /// block/transaction hashes on one request. Without a responder-side
4793    /// aggregate budget, `encode_response` serializes and retains the entire
4794    /// multi-frame `Vec<Frame>` for every available item before the first byte
4795    /// is written (worst case `MAX_TX_INV_IN_SENT_MESSAGE *
4796    /// MAX_PROTOCOL_MESSAGE_LEN`, tens of GiB). The outbound reader already
4797    /// enforces a symmetric `LegacyResponseBudget`; the responder must too, and
4798    /// must abort encoding early rather than buffering an over-budget response.
4799    #[test]
4800    fn encode_response_aborts_when_aggregation_exceeds_budget() -> Result<(), BoxError> {
4801        let block = Arc::new(Block::zcash_deserialize(
4802            BLOCK_TESTNET_141042_BYTES.as_slice(),
4803        )?);
4804        let block_bytes = block.zcash_serialize_to_vec()?.len();
4805        let frame_cap = u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?;
4806
4807        // A single available block is well within budget and still encodes.
4808        LegacyResponseCodec::encode_response(
4809            1,
4810            Response::Blocks(vec![InventoryResponse::Available((block.clone(), None))]),
4811            frame_cap,
4812            frame_cap,
4813        )?;
4814
4815        // Enough available blocks to overflow the cumulative byte budget must be
4816        // rejected, not fully materialized as a `Vec<Frame>`.
4817        let block_copies = LEGACY_RESPONSE_MAX_AGGREGATE_BYTES / block_bytes + 2;
4818        let many_blocks = Response::Blocks(
4819            (0..block_copies)
4820                .map(|_| InventoryResponse::Available((block.clone(), None)))
4821                .collect(),
4822        );
4823        assert!(
4824            matches!(
4825                LegacyResponseCodec::encode_response(2, many_blocks, frame_cap, frame_cap),
4826                Err(LegacyGossipError::ResponseAggregateBudget(_)),
4827            ),
4828            "an over-budget BlocksByHash response must abort encoding early",
4829        );
4830
4831        // The same cumulative budget guards the transaction responder path,
4832        // which shares `push_chunked_response`.
4833        let tx_copies = 2 * LEGACY_RESPONSE_MAX_AGGREGATE_BYTES / block_bytes + 2;
4834        let many_transactions = Response::Transactions(
4835            (0..tx_copies)
4836                .flat_map(|_| {
4837                    block
4838                        .transactions
4839                        .iter()
4840                        .map(|tx| InventoryResponse::Available((UnminedTx::from(tx.clone()), None)))
4841                })
4842                .collect(),
4843        );
4844        assert!(
4845            matches!(
4846                LegacyResponseCodec::encode_response(3, many_transactions, frame_cap, frame_cap),
4847                Err(LegacyGossipError::ResponseAggregateBudget(_)),
4848            ),
4849            "an over-budget TransactionsById response must abort encoding early",
4850        );
4851
4852        Ok(())
4853    }
4854
4855    #[test]
4856    fn response_codec_round_trips_chain_sync_and_mempool_responses() -> Result<(), BoxError> {
4857        let block = Arc::new(Block::zcash_deserialize(
4858            BLOCK_TESTNET_141042_BYTES.as_slice(),
4859        )?);
4860        let header = block::CountedHeader {
4861            header: block.header.clone(),
4862        };
4863
4864        let block_hash_response = Response::BlockHashes(vec![block.hash(), block_hash(10)]);
4865        let frames = LegacyResponseCodec::encode_response(
4866            8,
4867            block_hash_response.clone(),
4868            u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?,
4869            u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?,
4870        )?;
4871        assert_eq!(
4872            LegacyResponseCodec::decode_response(8, LegacyRequestKind::FindBlocks, frames, None)?,
4873            block_hash_response
4874        );
4875
4876        let header_response = Response::BlockHeaders(vec![header]);
4877        let frames = LegacyResponseCodec::encode_response(
4878            9,
4879            header_response.clone(),
4880            u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?,
4881            u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?,
4882        )?;
4883        assert_eq!(
4884            LegacyResponseCodec::decode_response(9, LegacyRequestKind::FindHeaders, frames, None)?,
4885            header_response
4886        );
4887
4888        let tx_ids_response = Response::TransactionIds(vec![legacy_tx_id(11), witnessed_tx_id(12)]);
4889        let frames = LegacyResponseCodec::encode_response(
4890            10,
4891            tx_ids_response.clone(),
4892            u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?,
4893            u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?,
4894        )?;
4895        assert_eq!(
4896            LegacyResponseCodec::decode_response(
4897                10,
4898                LegacyRequestKind::MempoolTransactionIds,
4899                frames,
4900                None,
4901            )?,
4902            tx_ids_response
4903        );
4904
4905        let frames = LegacyResponseCodec::encode_response(
4906            11,
4907            Response::Pong(Duration::ZERO),
4908            u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?,
4909            u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?,
4910        )?;
4911        assert!(matches!(
4912            LegacyResponseCodec::decode_response(11, LegacyRequestKind::Ping, frames, None)?,
4913            Response::Pong(_)
4914        ));
4915
4916        let frames = LegacyResponseCodec::encode_response(
4917            12,
4918            Response::Nil,
4919            u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?,
4920            u32::try_from(MAX_PROTOCOL_MESSAGE_LEN)?,
4921        )?;
4922        assert_eq!(
4923            LegacyResponseCodec::decode_response(
4924                12,
4925                LegacyRequestKind::PushTransaction,
4926                frames,
4927                None
4928            )?,
4929            Response::Nil
4930        );
4931
4932        Ok(())
4933    }
4934
4935    #[test]
4936    fn malformed_frames_are_rejected() {
4937        assert!(matches!(
4938            LegacyGossipFrame::decode_frame(Frame {
4939                message_type: MSG_ADVERTISE_BLOCK,
4940                flags: 1,
4941                payload: Vec::new(),
4942            }),
4943            Err(LegacyGossipError::UnsupportedFlags(1))
4944        ));
4945        assert!(matches!(
4946            LegacyGossipFrame::decode_frame(Frame {
4947                message_type: 99,
4948                flags: 0,
4949                payload: Vec::new(),
4950            }),
4951            Err(LegacyGossipError::UnknownMessageType(99))
4952        ));
4953        assert!(matches!(
4954            LegacyGossipFrame::decode_frame(Frame {
4955                message_type: MSG_ADVERTISE_TX_IDS,
4956                flags: 0,
4957                payload: vec![0],
4958            }),
4959            Err(LegacyGossipError::EmptyTransactionAdvertisement)
4960        ));
4961    }
4962
4963    #[test]
4964    fn tx_id_limits_are_enforced_before_allocation() {
4965        let max = usize::try_from(MAX_TX_INV_IN_SENT_MESSAGE).expect("test cap fits usize");
4966        let oversized = Frame {
4967            message_type: MSG_ADVERTISE_TX_IDS,
4968            flags: 0,
4969            payload: encoded_count(max + 1),
4970        };
4971        assert!(matches!(
4972            LegacyGossipFrame::decode_frame(oversized),
4973            Err(LegacyGossipError::TooManyInventoryItems(count)) if count == max + 1
4974        ));
4975
4976        let ids: HashSet<_> = (0..max + 3)
4977            .map(|index| {
4978                let mut bytes = [0; 32];
4979                bytes[..8].copy_from_slice(
4980                    &u64::try_from(index)
4981                        .expect("test index fits u64")
4982                        .to_le_bytes(),
4983                );
4984                UnminedTxId::from_legacy_id(transaction::Hash(bytes))
4985            })
4986            .collect();
4987        let frame = LegacyGossipFrame::from_request(Request::AdvertiseTransactionIds(ids, None))
4988            .expect("non-empty tx gossip converts");
4989        let LegacyGossipFrame::AdvertiseTransactionIds(ids) = frame else {
4990            panic!("expected tx id frame");
4991        };
4992        assert!(ids.len() <= max);
4993    }
4994
4995    #[test]
4996    fn unsupported_requests_fail_loudly() {
4997        let unsupported = [
4998            (
4999                Request::BlocksByHash(HashSet::from([block_hash(1)])),
5000                "BlocksByHash",
5001            ),
5002            (
5003                Request::TransactionsById(HashSet::from([legacy_tx_id(1)])),
5004                "TransactionsById",
5005            ),
5006            (
5007                Request::FindBlocks {
5008                    known_blocks: vec![block_hash(2)],
5009                    stop: None,
5010                },
5011                "FindBlocks",
5012            ),
5013            (
5014                Request::FindHeaders {
5015                    known_blocks: vec![block_hash(3)],
5016                    stop: None,
5017                },
5018                "FindHeaders",
5019            ),
5020            (
5021                Request::PushTransaction(
5022                    Transaction::V5 {
5023                        network_upgrade: NetworkUpgrade::Nu5,
5024                        lock_time: LockTime::min_lock_time_timestamp(),
5025                        expiry_height: block::Height(0),
5026                        inputs: Vec::new(),
5027                        outputs: Vec::new(),
5028                        sapling_shielded_data: None,
5029                        orchard_shielded_data: None,
5030                    }
5031                    .into(),
5032                    None,
5033                ),
5034                "PushTransaction",
5035            ),
5036            (Request::MempoolTransactionIds, "MempoolTransactionIds"),
5037            (Request::Peers, "Peers"),
5038        ];
5039
5040        for (request, command) in unsupported {
5041            let error =
5042                LegacyGossipFrame::from_request(request).expect_err("request is unsupported");
5043            assert!(matches!(
5044                error,
5045                LegacyGossipError::UnsupportedRequest(unsupported) if unsupported == command
5046            ));
5047        }
5048    }
5049
5050    #[test]
5051    fn legacy_peers_request_is_deferred_to_native_bootstrap_configuration() {
5052        let error =
5053            LegacyRequestFrame::from_request(Request::Peers).expect_err("Peers is deferred");
5054        assert!(matches!(
5055            error,
5056            LegacyGossipError::UnsupportedRequest("Peers")
5057        ));
5058        assert!(
5059            error
5060                .to_string()
5061                .contains("unsupported legacy gossip request"),
5062            "unexpected error: {error}"
5063        );
5064    }
5065
5066    #[tokio::test]
5067    async fn first_seen_cache_is_bounded_and_expires() {
5068        let cache = FirstSeenCache::new(1, Duration::from_millis(20));
5069        assert_eq!(
5070            cache
5071                .record_unseen([InventoryKey::Block(block_hash(1))])
5072                .await,
5073            vec![InventoryKey::Block(block_hash(1))]
5074        );
5075        assert!(cache
5076            .record_unseen([InventoryKey::Block(block_hash(1))])
5077            .await
5078            .is_empty());
5079        assert_eq!(
5080            cache
5081                .record_unseen([InventoryKey::Block(block_hash(2))])
5082                .await,
5083            vec![InventoryKey::Block(block_hash(2))]
5084        );
5085        assert_eq!(
5086            cache
5087                .record_unseen([InventoryKey::Block(block_hash(1))])
5088                .await,
5089            vec![InventoryKey::Block(block_hash(1))]
5090        );
5091
5092        let cache = FirstSeenCache::new(8, Duration::from_millis(1));
5093        assert_eq!(
5094            cache
5095                .record_unseen([InventoryKey::Block(block_hash(3))])
5096                .await,
5097            vec![InventoryKey::Block(block_hash(3))]
5098        );
5099        tokio::time::sleep(Duration::from_millis(5)).await;
5100        assert_eq!(
5101            cache
5102                .record_unseen([InventoryKey::Block(block_hash(3))])
5103                .await,
5104            vec![InventoryKey::Block(block_hash(3))]
5105        );
5106    }
5107
5108    #[tokio::test]
5109    async fn malformed_inbound_gossip_disconnects_peer() -> Result<(), BoxError> {
5110        let _guard = zakura_test::init();
5111        let (node, _rx) = legacy_node(31).await?;
5112        let hostile =
5113            HostilePeer::connect_native_with_capabilities(&node, 32, ZAKURA_CAP_LEGACY_GOSSIP)
5114                .await?;
5115        wait_registered_count(&node, 1).await?;
5116
5117        hostile.send_frame(ZAKURA_STREAM_GOSSIP, vec![1]).await?;
5118        wait_registered_count(&node, 0).await?;
5119
5120        hostile.shutdown().await;
5121        node.shutdown().await;
5122        Ok(())
5123    }
5124
5125    #[tokio::test]
5126    async fn gossip_stream_with_request_id_disconnects_peer() -> Result<(), BoxError> {
5127        let _guard = zakura_test::init();
5128        let node = ZakuraTestNode::builder(41).spawn().await?;
5129        let hostile = HostilePeer::connect_native(&node, 42).await?;
5130        wait_registered_count(&node, 1).await?;
5131
5132        let frame = LegacyGossipFrame::AdvertiseBlock(block_hash(77)).encode_frame()?;
5133        hostile
5134            .send_frame_with_request_id(ZAKURA_STREAM_GOSSIP, 99, frame)
5135            .await?;
5136        wait_registered_count(&node, 0).await?;
5137
5138        hostile.shutdown().await;
5139        node.shutdown().await;
5140        Ok(())
5141    }
5142
5143    #[tokio::test]
5144    async fn request_stream_without_request_id_disconnects_peer() -> Result<(), BoxError> {
5145        let _guard = zakura_test::init();
5146        let node = ZakuraTestNode::builder(71).spawn().await?;
5147        let hostile = HostilePeer::connect_native(&node, 72).await?;
5148        wait_registered_count(&node, 1).await?;
5149
5150        let frame = LegacyRequestFrame::BlocksByHash(vec![block_hash(1)]).encode_frame()?;
5151        hostile
5152            .send_frame(ZAKURA_STREAM_LEGACY_REQUESTS, frame.payload)
5153            .await?;
5154        wait_registered_count(&node, 0).await?;
5155
5156        hostile.shutdown().await;
5157        node.shutdown().await;
5158        Ok(())
5159    }
5160
5161    #[tokio::test]
5162    async fn malformed_outbound_response_disconnects_peer() -> Result<(), BoxError> {
5163        let _guard = zakura_test::init();
5164        let node = ZakuraTestNode::builder(73).spawn().await?;
5165        let hostile = HostilePeer::connect_native(&node, 74).await?;
5166        wait_registered_count(&node, 1).await?;
5167        let hostile_id = hostile.id()?;
5168
5169        let mut wrong_id_payload = Vec::new();
5170        wrong_id_payload.extend_from_slice(&0_u64.to_le_bytes());
5171        wrong_id_payload.push(1);
5172        let response = Frame {
5173            message_type: MSG_RESPONSE_BLOCK,
5174            flags: 0,
5175            payload: wrong_id_payload,
5176        };
5177
5178        let adapter = LegacyRequestAdapter::new(node.supervisor());
5179        let request = adapter.request_from_source(
5180            Request::BlocksByHash(HashSet::from([block_hash(1)])),
5181            Some(PeerSource::Zakura(hostile_id)),
5182        );
5183        let responder = hostile.respond_to_next_request(vec![response]);
5184        let (request_result, responder_result) = futures::join!(request, responder);
5185
5186        responder_result?;
5187        let error = request_result.expect_err("wrong request id is a request error");
5188        assert!(
5189            error
5190                .to_string()
5191                .contains("wrong legacy response request id"),
5192            "unexpected error: {error}"
5193        );
5194        wait_registered_count(&node, 0).await?;
5195
5196        hostile.shutdown().await;
5197        node.shutdown().await;
5198        Ok(())
5199    }
5200
5201    #[tokio::test]
5202    async fn outbound_request_timeout_releases_peer_for_next_request() -> Result<(), BoxError> {
5203        let _guard = zakura_test::init();
5204        let node = ZakuraTestNode::builder(79).spawn().await?;
5205        let mut hostile = HostilePeer::connect_native(&node, 80).await?;
5206        wait_registered_count(&node, 1).await?;
5207        let hostile_id = hostile.id()?;
5208
5209        let adapter =
5210            LegacyRequestAdapter::new_with_timeout(node.supervisor(), Duration::from_millis(100));
5211        let first_request = adapter.request_from_source(
5212            Request::BlocksByHash(HashSet::from([block_hash(1)])),
5213            Some(PeerSource::Zakura(hostile_id.clone())),
5214        );
5215        let hold_first = hostile.accept_next_request_without_response();
5216        let (request_result, hold_result) = futures::join!(first_request, hold_first);
5217
5218        hold_result?;
5219        let error = request_result.expect_err("silent peer should time out the request");
5220        assert!(
5221            error.to_string().contains("timed out"),
5222            "unexpected error: {error}"
5223        );
5224        wait_registered_count(&node, 1).await?;
5225
5226        let second_hash = block_hash(2);
5227        let second_request = adapter.request_from_source(
5228            Request::BlocksByHash(HashSet::from([second_hash])),
5229            Some(PeerSource::Zakura(hostile_id)),
5230        );
5231        let second_response = hostile.respond_to_next_request_with(|request_id| {
5232            vec![missing_blocks_frame(request_id, vec![second_hash])
5233                .expect("one missing block hash fits in a response frame")]
5234        });
5235        let (request_result, response_result) = futures::join!(second_request, second_response);
5236
5237        response_result?;
5238        assert_eq!(
5239            request_result?,
5240            Response::Blocks(vec![InventoryResponse::Missing(second_hash)])
5241        );
5242
5243        hostile.shutdown().await;
5244        node.shutdown().await;
5245        Ok(())
5246    }
5247
5248    #[tokio::test]
5249    async fn excessive_outbound_response_frames_disconnects_peer() -> Result<(), BoxError> {
5250        let _guard = zakura_test::init();
5251        let node = ZakuraTestNode::builder(75).spawn().await?;
5252        let hostile = HostilePeer::connect_native(&node, 76).await?;
5253        wait_registered_count(&node, 1).await?;
5254        let hostile_id = hostile.id()?;
5255
5256        let adapter = LegacyRequestAdapter::new(node.supervisor());
5257        let request = adapter.request_from_source(
5258            Request::BlocksByHash(HashSet::from([block_hash(1)])),
5259            Some(PeerSource::Zakura(hostile_id)),
5260        );
5261        let responder = hostile.respond_to_next_request_with(|request_id| {
5262            let mut frames = Vec::new();
5263            for _ in 0..10 {
5264                let mut payload = Vec::new();
5265                payload.extend_from_slice(&request_id.to_le_bytes());
5266                payload.extend_from_slice(&encoded_count(0));
5267                frames.push(Frame {
5268                    message_type: MSG_RESPONSE_MISSING_BLOCKS,
5269                    flags: 0,
5270                    payload,
5271                });
5272            }
5273            frames
5274        });
5275        let (request_result, responder_result) = futures::join!(request, responder);
5276
5277        responder_result?;
5278        let error = request_result.expect_err("excessive response frames are rejected");
5279        assert!(
5280            error
5281                .to_string()
5282                .contains("too many legacy response frames"),
5283            "unexpected error: {error}"
5284        );
5285        wait_registered_count(&node, 0).await?;
5286
5287        hostile.shutdown().await;
5288        node.shutdown().await;
5289        Ok(())
5290    }
5291
5292    #[tokio::test]
5293    async fn invalid_outbound_block_response_disconnects_peer() -> Result<(), BoxError> {
5294        let _guard = zakura_test::init();
5295        let node = ZakuraTestNode::builder(77).spawn().await?;
5296        let hostile = HostilePeer::connect_native(&node, 78).await?;
5297        wait_registered_count(&node, 1).await?;
5298        let hostile_id = hostile.id()?;
5299
5300        let adapter = LegacyRequestAdapter::new(node.supervisor());
5301        let request = adapter.request_from_source(
5302            Request::BlocksByHash(HashSet::from([block_hash(1)])),
5303            Some(PeerSource::Zakura(hostile_id)),
5304        );
5305        let responder = hostile.respond_to_next_request_with(|request_id| {
5306            let mut payload = Vec::new();
5307            payload.extend_from_slice(&request_id.to_le_bytes());
5308            payload.push(1);
5309            payload.extend_from_slice(b"not a serialized block");
5310            vec![Frame {
5311                message_type: MSG_RESPONSE_BLOCK,
5312                flags: 0,
5313                payload,
5314            }]
5315        });
5316        let (request_result, responder_result) = futures::join!(request, responder);
5317
5318        responder_result?;
5319        request_result.expect_err("invalid block bytes are rejected");
5320        wait_registered_count(&node, 0).await?;
5321
5322        hostile.shutdown().await;
5323        node.shutdown().await;
5324        Ok(())
5325    }
5326
5327    /// How the legacy stub answers inventory fetches in dual-stack tests.
5328    #[derive(Clone)]
5329    enum StubInventory {
5330        /// Return every requested id as missing (triggers Zakura fallback).
5331        Missing,
5332        /// Return the given transaction as available.
5333        Available(UnminedTx),
5334        /// Error on inventory (used to prove the legacy path is bypassed).
5335        Error,
5336    }
5337
5338    /// Stand-in for the legacy peer set inside a [`ZakuraDualStackService`]. It
5339    /// records every request it is handed and answers inventory per `inventory`.
5340    #[derive(Clone)]
5341    struct DualStackLegacyStub {
5342        tx: tokio::sync::mpsc::UnboundedSender<Request>,
5343        inventory: StubInventory,
5344    }
5345
5346    impl Service<Request> for DualStackLegacyStub {
5347        type Response = Response;
5348        type Error = BoxError;
5349        type Future = std::future::Ready<Result<Response, BoxError>>;
5350
5351        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
5352            Poll::Ready(Ok(()))
5353        }
5354
5355        fn call(&mut self, request: Request) -> Self::Future {
5356            let _ = self.tx.send(request.clone());
5357            let response = match request {
5358                Request::TransactionsById(ids) | Request::TransactionsByIdFrom { ids, .. } => {
5359                    match &self.inventory {
5360                        StubInventory::Error => {
5361                            return std::future::ready(Err("legacy inventory unavailable".into()))
5362                        }
5363                        StubInventory::Missing => Response::Transactions(
5364                            ids.into_iter().map(InventoryResponse::Missing).collect(),
5365                        ),
5366                        StubInventory::Available(tx) => Response::Transactions(
5367                            ids.into_iter()
5368                                .map(|id| {
5369                                    if id == tx.id {
5370                                        InventoryResponse::Available((tx.clone(), None))
5371                                    } else {
5372                                        InventoryResponse::Missing(id)
5373                                    }
5374                                })
5375                                .collect(),
5376                        ),
5377                    }
5378                }
5379                Request::BlocksByHash(hashes) | Request::BlocksByHashFrom { hashes, .. } => {
5380                    if let StubInventory::Error = self.inventory {
5381                        return std::future::ready(Err("legacy inventory unavailable".into()));
5382                    }
5383                    Response::Blocks(hashes.into_iter().map(InventoryResponse::Missing).collect())
5384                }
5385                _ => Response::Nil,
5386            };
5387            std::future::ready(Ok(response))
5388        }
5389    }
5390
5391    #[tokio::test]
5392    async fn dual_stack_advertise_fans_out_to_legacy_and_zakura() -> Result<(), BoxError> {
5393        let _guard = zakura_test::init();
5394        // node_a originates; node_b records gossip delivered over Zakura.
5395        let (node_a, _rx_a) = legacy_node(201).await?;
5396        let (node_b, mut rx_b) = legacy_node(202).await?;
5397        node_a.connect_native(&node_b, TEST_NET_TIMEOUT).await?;
5398        let a_peer_id = node_peer_id(&node_a).await?;
5399
5400        let (legacy_tx, mut rx_legacy) = tokio::sync::mpsc::unbounded_channel();
5401        let mut composite = ZakuraDualStackService::new(
5402            DualStackLegacyStub {
5403                tx: legacy_tx,
5404                inventory: StubInventory::Missing,
5405            },
5406            node_a.supervisor(),
5407            true,
5408        );
5409
5410        let hash = block_hash(9);
5411        composite
5412            .ready()
5413            .await?
5414            .call(Request::AdvertiseBlockToAll(hash))
5415            .await?;
5416
5417        // Legacy peer set received the advertisement verbatim.
5418        match recv_request(&mut rx_legacy).await? {
5419            Request::AdvertiseBlockToAll(received) => assert_eq!(received, hash),
5420            request => panic!("unexpected legacy request: {request:?}"),
5421        }
5422        // Zakura delivered it to node_b, attributed to node_a.
5423        match recv_request(&mut rx_b).await? {
5424            Request::AdvertiseBlock(received, Some(PeerSource::Zakura(peer_id))) => {
5425                assert_eq!(received, hash);
5426                assert_eq!(peer_id, a_peer_id);
5427            }
5428            request => panic!("unexpected zakura request: {request:?}"),
5429        }
5430
5431        node_a.shutdown().await;
5432        node_b.shutdown().await;
5433        Ok(())
5434    }
5435
5436    #[tokio::test]
5437    async fn dual_stack_inventory_falls_back_to_zakura_when_legacy_missing() -> Result<(), BoxError>
5438    {
5439        let _guard = zakura_test::init();
5440        let transaction = UnminedTx::from(empty_v5_transaction(7));
5441        let advertiser = inventory_node(203, transaction.clone()).await?;
5442        let requester = ZakuraTestNode::builder(204).spawn().await?;
5443        requester
5444            .connect_native(&advertiser, TEST_NET_TIMEOUT)
5445            .await?;
5446
5447        let (legacy_tx, _rx_legacy) = tokio::sync::mpsc::unbounded_channel();
5448        let mut composite = ZakuraDualStackService::new(
5449            DualStackLegacyStub {
5450                tx: legacy_tx,
5451                inventory: StubInventory::Missing,
5452            },
5453            requester.supervisor(),
5454            true,
5455        );
5456
5457        let response = composite
5458            .ready()
5459            .await?
5460            .call(Request::TransactionsById(HashSet::from([transaction.id])))
5461            .await?;
5462        match response {
5463            Response::Transactions(items) => {
5464                assert_eq!(items.len(), 1);
5465                assert!(matches!(items[0], InventoryResponse::Available(_)));
5466            }
5467            other => panic!("unexpected response: {other:?}"),
5468        }
5469
5470        advertiser.shutdown().await;
5471        requester.shutdown().await;
5472        Ok(())
5473    }
5474
5475    #[tokio::test]
5476    async fn dual_stack_inventory_uses_legacy_when_available() -> Result<(), BoxError> {
5477        let _guard = zakura_test::init();
5478        let transaction = UnminedTx::from(empty_v5_transaction(8));
5479        // No Zakura advertiser is connected, so any fallback would fail; the
5480        // legacy-available short-circuit is what makes this succeed.
5481        let requester = ZakuraTestNode::builder(205).spawn().await?;
5482
5483        let (legacy_tx, _rx_legacy) = tokio::sync::mpsc::unbounded_channel();
5484        let mut composite = ZakuraDualStackService::new(
5485            DualStackLegacyStub {
5486                tx: legacy_tx,
5487                inventory: StubInventory::Available(transaction.clone()),
5488            },
5489            requester.supervisor(),
5490            true,
5491        );
5492
5493        let response = composite
5494            .ready()
5495            .await?
5496            .call(Request::TransactionsById(HashSet::from([transaction.id])))
5497            .await?;
5498        match response {
5499            Response::Transactions(items) => {
5500                assert!(matches!(items[0], InventoryResponse::Available(_)));
5501            }
5502            other => panic!("unexpected response: {other:?}"),
5503        }
5504
5505        requester.shutdown().await;
5506        Ok(())
5507    }
5508
5509    #[tokio::test]
5510    async fn dual_stack_zakura_only_bypasses_legacy_for_inventory() -> Result<(), BoxError> {
5511        let _guard = zakura_test::init();
5512        let transaction = UnminedTx::from(empty_v5_transaction(9));
5513        let advertiser = inventory_node(206, transaction.clone()).await?;
5514        let requester = ZakuraTestNode::builder(207).spawn().await?;
5515        requester
5516            .connect_native(&advertiser, TEST_NET_TIMEOUT)
5517            .await?;
5518
5519        // legacy_enabled = false; the stub errors if it is ever consulted.
5520        let (legacy_tx, mut rx_legacy) = tokio::sync::mpsc::unbounded_channel();
5521        let mut composite = ZakuraDualStackService::new(
5522            DualStackLegacyStub {
5523                tx: legacy_tx,
5524                inventory: StubInventory::Error,
5525            },
5526            requester.supervisor(),
5527            false,
5528        );
5529
5530        let response = composite
5531            .ready()
5532            .await?
5533            .call(Request::TransactionsById(HashSet::from([transaction.id])))
5534            .await?;
5535        match response {
5536            Response::Transactions(items) => {
5537                assert!(matches!(items[0], InventoryResponse::Available(_)));
5538            }
5539            other => panic!("unexpected response: {other:?}"),
5540        }
5541        // The legacy peer set was never consulted in Zakura-only mode.
5542        assert!(rx_legacy.try_recv().is_err());
5543
5544        advertiser.shutdown().await;
5545        requester.shutdown().await;
5546        Ok(())
5547    }
5548
5549    #[tokio::test]
5550    async fn dual_stack_zakura_only_routes_chain_sync_and_mempool_requests() -> Result<(), BoxError>
5551    {
5552        let _guard = zakura_test::init();
5553        let block = Arc::new(Block::zcash_deserialize(
5554            BLOCK_TESTNET_141042_BYTES.as_slice(),
5555        )?);
5556        let transaction = UnminedTx::from(empty_v5_transaction(33));
5557        let (responder, mut pushed_rx) =
5558            normal_network_node(220, block.clone(), transaction.clone()).await?;
5559        let requester = ZakuraTestNode::builder(221).spawn().await?;
5560        requester
5561            .connect_native(&responder, TEST_NET_TIMEOUT)
5562            .await?;
5563
5564        // legacy_enabled = false; the stub errors if the legacy peer set is ever
5565        // consulted. Chain-sync discovery and mempool data used to be routed to
5566        // the legacy peer set (`Passthrough`), so a node whose only peer is over
5567        // Zakura could never obtain tips, fetch blocks, or push transactions.
5568        let (legacy_tx, mut rx_legacy) = tokio::sync::mpsc::unbounded_channel();
5569        let mut composite = ZakuraDualStackService::new(
5570            DualStackLegacyStub {
5571                tx: legacy_tx,
5572                inventory: StubInventory::Error,
5573            },
5574            requester.supervisor(),
5575            false,
5576        );
5577
5578        let find_blocks = composite
5579            .ready()
5580            .await?
5581            .call(Request::FindBlocks {
5582                known_blocks: vec![block_hash(1)],
5583                stop: Some(block.hash()),
5584            })
5585            .await?;
5586        match find_blocks {
5587            Response::BlockHashes(hashes) => assert_eq!(hashes, vec![block.hash()]),
5588            other => panic!("unexpected FindBlocks response: {other:?}"),
5589        }
5590
5591        let find_headers = composite
5592            .ready()
5593            .await?
5594            .call(Request::FindHeaders {
5595                known_blocks: vec![block_hash(1)],
5596                stop: Some(block.hash()),
5597            })
5598            .await?;
5599        assert!(
5600            matches!(find_headers, Response::BlockHeaders(ref headers) if headers.len() == 1),
5601            "FindHeaders should be served over Zakura, got {find_headers:?}",
5602        );
5603
5604        let mempool_ids = composite
5605            .ready()
5606            .await?
5607            .call(Request::MempoolTransactionIds)
5608            .await?;
5609        assert!(
5610            matches!(mempool_ids, Response::TransactionIds(ref ids) if *ids == vec![transaction.id]),
5611            "MempoolTransactionIds should be served over Zakura, got {mempool_ids:?}",
5612        );
5613
5614        composite
5615            .ready()
5616            .await?
5617            .call(Request::PushTransaction(transaction.clone(), None))
5618            .await?;
5619        let pushed = pushed_rx
5620            .recv()
5621            .await
5622            .expect("transaction pushed over Zakura");
5623        assert_eq!(pushed, transaction.id);
5624
5625        // None of these requests consulted the legacy peer set.
5626        assert!(
5627            rx_legacy.try_recv().is_err(),
5628            "Zakura-only mode must not consult the legacy peer set for serviceable requests",
5629        );
5630
5631        responder.shutdown().await;
5632        requester.shutdown().await;
5633        Ok(())
5634    }
5635
5636    #[tokio::test]
5637    async fn dual_stack_passthrough_request_reaches_legacy() -> Result<(), BoxError> {
5638        let _guard = zakura_test::init();
5639        let node = ZakuraTestNode::builder(208).spawn().await?;
5640        let (legacy_tx, mut rx_legacy) = tokio::sync::mpsc::unbounded_channel();
5641        let mut composite = ZakuraDualStackService::new(
5642            DualStackLegacyStub {
5643                tx: legacy_tx,
5644                inventory: StubInventory::Missing,
5645            },
5646            node.supervisor(),
5647            true,
5648        );
5649
5650        composite.ready().await?.call(Request::Peers).await?;
5651        match recv_request(&mut rx_legacy).await? {
5652            Request::Peers => {}
5653            request => panic!("unexpected passthrough request: {request:?}"),
5654        }
5655
5656        node.shutdown().await;
5657        Ok(())
5658    }
5659}