zakura 1.6.0

Zakura, an independent, consensus-compatible implementation of a Zcash node
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Integration tests for block hash gossip.

#![allow(clippy::unwrap_in_result)]

use std::{sync::Arc, time::Duration};

use tokio::{task::JoinHandle, time::timeout};
use tower::{builder::ServiceBuilder, util::BoxService, Service, ServiceExt};
use tracing::Instrument;

use zakura_chain::{
    block::{Block, Height},
    fmt::humantime_seconds,
    parameters::Network::Mainnet,
    serialization::ZcashDeserializeInto,
};
use zakura_network::{Request, Response};
use zakura_rpc::{MinedBlockEvent, PendingBlockSignal, SubmitBlockChannel};
use zakura_state::{Config as StateConfig, CHAIN_TIP_UPDATE_WAIT_LIMIT};
use zakura_test::mock_service::{MockService, PanicAssertion};

use crate::components::sync::{self, BlockGossipError, SyncStatus, TIPS_RESPONSE_TIMEOUT};

const MAX_PEER_SET_REQUEST_DELAY: Duration = Duration::from_secs(30);

struct GossipTestSetup {
    peer_set: MockService<Request, Response, PanicAssertion>,
    submitblock_sender: tokio::sync::mpsc::UnboundedSender<MinedBlockEvent>,
    state_service: BoxService<zakura_state::Request, zakura_state::Response, crate::BoxError>,
    gossip_task_handle: JoinHandle<Result<(), BlockGossipError>>,
}

async fn setup_gossip_test() -> GossipTestSetup {
    let _init_guard = zakura_test::init();

    let network = Mainnet;
    let state_config = StateConfig::ephemeral();
    let (state, _read_only_state, _latest_chain_tip, mut chain_tip_change) =
        zakura_state::init(state_config, &network, Height::MAX, 0)
            .await
            .expect("ephemeral state initialization succeeds");

    let mut state_service = ServiceBuilder::new().buffer(1).service(state);

    let genesis_block: Arc<Block> = zakura_test::vectors::BLOCK_MAINNET_GENESIS_BYTES
        .zcash_deserialize_into()
        .unwrap();
    state_service
        .ready()
        .await
        .unwrap()
        .call(zakura_state::Request::CommitCheckpointVerifiedBlock(
            genesis_block.into(),
        ))
        .await
        .unwrap();

    if let Err(timeout_error) = timeout(
        CHAIN_TIP_UPDATE_WAIT_LIMIT,
        chain_tip_change.wait_for_tip_change(),
    )
    .await
    .map(|change_result| change_result.expect("unexpected chain tip update failure"))
    {
        panic!(
            "timeout waiting for genesis chain tip change after {}: {timeout_error:?}",
            humantime_seconds(CHAIN_TIP_UPDATE_WAIT_LIMIT),
        );
    }

    let block_one: Arc<Block> = zakura_test::vectors::BLOCK_MAINNET_1_BYTES
        .zcash_deserialize_into()
        .unwrap();
    state_service
        .clone()
        .oneshot(zakura_state::Request::CommitCheckpointVerifiedBlock(
            block_one.clone().into(),
        ))
        .await
        .unwrap();

    let (sync_status, mut recent_syncs) = SyncStatus::new();
    SyncStatus::sync_close_to_tip(&mut recent_syncs);

    let mut peer_set = MockService::build()
        .with_max_request_delay(MAX_PEER_SET_REQUEST_DELAY)
        .for_unit_tests();

    let submitblock_channel = SubmitBlockChannel::new();
    let submitblock_sender = submitblock_channel.sender();
    let gossip_task_handle = tokio::spawn(
        sync::gossip_best_tip_block_hashes(
            sync_status,
            chain_tip_change,
            peer_set.clone(),
            Some(submitblock_channel.receiver()),
        )
        .in_current_span(),
    );

    // The genesis block gossip is skipped because block 1 is committed before the task starts.
    peer_set
        .expect_request(Request::AdvertiseBlock(block_one.hash(), None))
        .await
        .respond(Response::Nil);

    GossipTestSetup {
        peer_set,
        submitblock_sender,
        state_service: BoxService::new(state_service),
        gossip_task_handle,
    }
}

/// After a successful mined block broadcast, the gossip task marks the tip as seen and does not
/// send a duplicate committed-tip gossip for the same hash.
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn mined_block_marks_tip_after_successful_broadcast() {
    let GossipTestSetup {
        mut peer_set,
        submitblock_sender,
        mut state_service,
        gossip_task_handle: _gossip_task_handle,
    } = setup_gossip_test().await;

    let block_two: Arc<Block> = zakura_test::vectors::BLOCK_MAINNET_2_BYTES
        .zcash_deserialize_into()
        .unwrap();

    state_service
        .ready()
        .await
        .unwrap()
        .call(zakura_state::Request::CommitCheckpointVerifiedBlock(
            block_two.clone().into(),
        ))
        .await
        .unwrap();

    submitblock_sender
        .send(MinedBlockEvent::Committed {
            hash: block_two.hash(),
            height: block_two.coinbase_height().unwrap(),
        })
        .expect("mined block notification should be accepted");

    peer_set
        .expect_request(Request::AdvertiseBlockToAll(block_two.hash()))
        .await
        .respond(Response::Nil);

    // The committed tip gossip path should not advertise the same hash again.
    peer_set.expect_no_requests().await;
}

/// A successful mined-block broadcast still suppresses the committed-tip fallback for that hash
/// even when another mined-block notification is already queued.
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn mined_block_mark_survives_pending_submit_queue() {
    let GossipTestSetup {
        mut peer_set,
        submitblock_sender,
        mut state_service,
        gossip_task_handle: _gossip_task_handle,
    } = setup_gossip_test().await;

    let block_two: Arc<Block> = zakura_test::vectors::BLOCK_MAINNET_2_BYTES
        .zcash_deserialize_into()
        .unwrap();
    let height = block_two.coinbase_height().unwrap();
    let hash = block_two.hash();

    state_service
        .ready()
        .await
        .unwrap()
        .call(zakura_state::Request::CommitCheckpointVerifiedBlock(
            block_two.clone().into(),
        ))
        .await
        .unwrap();

    // First mined notification — start AdvertiseBlockToAll but hold the response open.
    submitblock_sender
        .send(MinedBlockEvent::Committed { hash, height })
        .expect("mined block notification should be accepted");

    let first_broadcast = peer_set
        .expect_request(Request::AdvertiseBlockToAll(hash))
        .await;

    // Queue a second notification while the first broadcast is still in flight so the
    // submit-block channel is nonempty when the first mark arrives.
    submitblock_sender
        .send(MinedBlockEvent::Committed { hash, height })
        .expect("second mined block notification should be accepted");

    first_broadcast.respond(Response::Nil);

    // Second mined path also fires AdvertiseBlockToAll for the queued notification.
    peer_set
        .expect_request(Request::AdvertiseBlockToAll(hash))
        .await
        .respond(Response::Nil);

    // Without unconditional marking, the first mark would be dropped while the queue was
    // nonempty and the committed-tip path could still AdvertiseBlock(hash).
    peer_set.expect_no_requests().await;
}

/// If a mined block broadcast times out, the committed tip gossip path should still advertise the
/// hash as a fallback.
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn mined_block_broadcast_timeout_uses_committed_tip_fallback() {
    let GossipTestSetup {
        mut peer_set,
        submitblock_sender,
        mut state_service,
        gossip_task_handle: _gossip_task_handle,
    } = setup_gossip_test().await;

    let block_two: Arc<Block> = zakura_test::vectors::BLOCK_MAINNET_2_BYTES
        .zcash_deserialize_into()
        .unwrap();

    state_service
        .ready()
        .await
        .unwrap()
        .call(zakura_state::Request::CommitCheckpointVerifiedBlock(
            block_two.clone().into(),
        ))
        .await
        .unwrap();

    submitblock_sender
        .send(MinedBlockEvent::Committed {
            hash: block_two.hash(),
            height: block_two.coinbase_height().unwrap(),
        })
        .expect("mined block notification should be accepted");

    let slow_broadcast = peer_set
        .expect_request(Request::AdvertiseBlockToAll(block_two.hash()))
        .await;

    // Hold the mined block broadcast open past the gossip timeout so it fails without marking.
    tokio::time::sleep(TIPS_RESPONSE_TIMEOUT + Duration::from_secs(1)).await;
    drop(slow_broadcast);

    // The committed tip gossip path should advertise the same hash as a fallback.
    peer_set
        .expect_request(Request::AdvertiseBlock(block_two.hash(), None))
        .await
        .respond(Response::Nil);
}

/// An early broadcast advertises a hash whose body the node cannot serve yet, so it must not
/// suppress the committed-tip fallback.
///
/// A peer can follow the early inventory, exhaust `PENDING_BLOCK_WAIT` waiting for the body, and
/// give up. If the later committed broadcast then fails, the committed-tip gossip is the only
/// thing left that prompts that peer to ask again.
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn early_broadcast_does_not_suppress_the_committed_tip_fallback() {
    let GossipTestSetup {
        mut peer_set,
        submitblock_sender,
        mut state_service,
        gossip_task_handle: _gossip_task_handle,
    } = setup_gossip_test().await;

    let block_two: Arc<Block> = zakura_test::vectors::BLOCK_MAINNET_2_BYTES
        .zcash_deserialize_into()
        .unwrap();
    let hash = block_two.hash();
    let height = block_two.coinbase_height().unwrap();

    // The early broadcast succeeds, which is what would mark the tip as already gossiped.
    submitblock_sender
        .send(MinedBlockEvent::Early {
            hash,
            height,
            submitted_at: tokio::time::Instant::now().into_std(),
            pending: PendingBlockSignal::valid_for_tests(),
        })
        .expect("the early mined block notification is accepted");

    peer_set
        .expect_request(Request::AdvertiseBlockToAll(hash))
        .await
        .respond(Response::Nil);

    // Let the spawned early broadcast finish before the block commits.
    tokio::time::sleep(Duration::from_millis(200)).await;

    state_service
        .ready()
        .await
        .unwrap()
        .call(zakura_state::Request::CommitCheckpointVerifiedBlock(
            block_two.clone().into(),
        ))
        .await
        .unwrap();

    submitblock_sender
        .send(MinedBlockEvent::Committed { hash, height })
        .expect("the committed mined block notification is accepted");

    // Hold the committed broadcast open past the gossip timeout so it fails without marking.
    let slow_broadcast = peer_set
        .expect_request(Request::AdvertiseBlockToAll(hash))
        .await;
    tokio::time::sleep(TIPS_RESPONSE_TIMEOUT + Duration::from_secs(1)).await;
    drop(slow_broadcast);

    // Nothing has advertised a body this node can serve, so the fallback must still run.
    peer_set
        .expect_request(Request::AdvertiseBlock(hash, None))
        .await
        .respond(Response::Nil);
}

/// Consecutive committed blocks are gossiped as soon as they commit, with no delay between them.
///
/// This test uses real time: with a paused clock, the state commit auto-advances time by minutes,
/// which would hide a gossip delay.
#[tokio::test(flavor = "current_thread")]
async fn consecutive_committed_blocks_are_gossiped_without_delay() {
    /// Well below the removed 7 second delay, and far above the expected latency.
    const MAX_GOSSIP_LATENCY: Duration = Duration::from_secs(3);

    let GossipTestSetup {
        mut peer_set,
        submitblock_sender: _submitblock_sender,
        mut state_service,
        gossip_task_handle: _gossip_task_handle,
    } = setup_gossip_test().await;

    for block_bytes in [
        &*zakura_test::vectors::BLOCK_MAINNET_2_BYTES,
        &*zakura_test::vectors::BLOCK_MAINNET_3_BYTES,
    ] {
        let block: Arc<Block> = block_bytes.zcash_deserialize_into().unwrap();

        state_service
            .ready()
            .await
            .unwrap()
            .call(zakura_state::Request::CommitCheckpointVerifiedBlock(
                block.clone().into(),
            ))
            .await
            .unwrap();
        let committed_at = tokio::time::Instant::now();

        peer_set
            .expect_request(Request::AdvertiseBlock(block.hash(), None))
            .await
            .respond(Response::Nil);

        assert!(
            committed_at.elapsed() < MAX_GOSSIP_LATENCY,
            "block gossip waited {:?} after the commit",
            committed_at.elapsed(),
        );
    }
}

/// While a mined block broadcast is in flight, the committed-tip path does not advertise the same
/// hash.
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn in_flight_mined_block_broadcast_suppresses_committed_tip_gossip() {
    let GossipTestSetup {
        mut peer_set,
        submitblock_sender,
        mut state_service,
        gossip_task_handle: _gossip_task_handle,
    } = setup_gossip_test().await;

    let block_two: Arc<Block> = zakura_test::vectors::BLOCK_MAINNET_2_BYTES
        .zcash_deserialize_into()
        .unwrap();

    state_service
        .ready()
        .await
        .unwrap()
        .call(zakura_state::Request::CommitCheckpointVerifiedBlock(
            block_two.clone().into(),
        ))
        .await
        .unwrap();

    submitblock_sender
        .send(MinedBlockEvent::Committed {
            hash: block_two.hash(),
            height: block_two.coinbase_height().unwrap(),
        })
        .expect("mined block notification should be accepted");

    let in_flight_broadcast = peer_set
        .expect_request(Request::AdvertiseBlockToAll(block_two.hash()))
        .await;

    // Hold the broadcast open for less than its timeout, so it is still in flight when the
    // committed-tip path sees the tip change.
    tokio::time::sleep(TIPS_RESPONSE_TIMEOUT / 2).await;
    in_flight_broadcast.respond(Response::Nil);

    peer_set.expect_no_requests().await;
}