1use std::{
9 fmt,
10 sync::Arc,
11 time::{Duration, SystemTime, UNIX_EPOCH},
12};
13
14use sha2::{Digest, Sha256};
15use thruput_node_rpc::{
16 BlockID, ConsensusValue, ProposalTxsHash, SchedulerCommitOutcome, SchedulerGetTipResult,
17 SchedulerProposalResult, SchedulerProposedBlock,
18 sched_coord::SchedulerCoord_SchedulerCoord1_Client,
19};
20use thruput_xdr::{
21 header::{ProposalContext, ProposalHeader},
22 types::{ConsensusValueHash, ExtensionPoint, Protocol, SHA256Hash, TimePoint},
23 validator::ValidatorID,
24};
25use tokio::{
26 sync::{Notify, mpsc, oneshot},
27 task::JoinHandle,
28 time::MissedTickBehavior,
29};
30
31use crate::config::{BlockDriverConfig, BlockDriverMode};
32
33const MANUAL_BLOCK_REQUEST_CAPACITY: usize = 1;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct ProducedBlock {
39 pub block_id: BlockID,
41 pub height: i64,
43 pub batch_xdr_bytes: usize,
45}
46
47#[derive(Debug, thiserror::Error)]
49#[non_exhaustive]
50pub enum BlockProductionError {
51 #[error("manual block producer has stopped")]
53 DriverStopped,
54 #[error("manual block production was interrupted; scheduler outcome may be unknown")]
56 OutcomeUnknown,
57 #[error("manual block production did not complete within {timeout:?}; outcome may be unknown")]
59 TimedOut {
60 timeout: Duration,
62 },
63 #[error("scheduler has no genesis block")]
65 NoGenesis,
66 #[error("scheduler RPC `{op}` failed: {source}")]
68 SchedulerRpc {
69 op: &'static str,
71 #[source]
73 source: rpc_driver::RpcError,
74 },
75 #[error("proposal outcome is unknown after RPC failure: {source}")]
77 ProposalOutcomeUnknown {
78 #[source]
80 source: rpc_driver::RpcError,
81 },
82 #[error("scheduler rejected `{op}`: {result}")]
84 SchedulerRejected {
85 op: &'static str,
87 result: String,
89 },
90 #[error("scheduler rejected commit for block {block_id} at height {height}: {result}")]
92 CommitRejected {
93 block_id: BlockID,
95 height: i64,
97 result: String,
99 },
100 #[error("unexpected scheduler tip result: {result}")]
102 UnexpectedTipResult {
103 result: String,
105 },
106 #[error(
108 "commit outcome for block {block_id} at height {height} is unknown after RPC failure: {source}; reconciliation: {reconciliation}"
109 )]
110 CommitOutcomeUnknown {
111 block_id: BlockID,
113 height: i64,
115 #[source]
117 source: rpc_driver::RpcError,
118 reconciliation: String,
120 },
121 #[error("scheduler returned malformed proposal batch `{batch}`: {detail}")]
123 MalformedSchedulerProposal {
124 batch: &'static str,
126 detail: String,
128 },
129 #[error("failed to XDR-encode {value} for consensus hashing: {source}")]
131 ConsensusHashEncoding {
132 value: &'static str,
134 #[source]
136 source: xdrs::WriteError,
137 },
138}
139
140#[derive(Debug, Clone)]
142pub struct ManualBlockProducer {
143 requests: mpsc::Sender<ManualBlockRequest>,
144}
145
146impl ManualBlockProducer {
147 pub async fn produce_block(&self) -> Result<ProducedBlock, BlockProductionError> {
165 let (reply, response) = oneshot::channel();
166 self.requests
167 .send(ManualBlockRequest { reply })
168 .await
169 .map_err(|_| BlockProductionError::DriverStopped)?;
170 response
171 .await
172 .unwrap_or(Err(BlockProductionError::DriverStopped))
173 }
174}
175
176#[derive(Debug)]
177struct ManualBlockRequest {
178 reply: oneshot::Sender<Result<ProducedBlock, BlockProductionError>>,
179}
180
181#[derive(Clone)]
183pub(crate) struct BlockDriverTrigger {
184 notify: Option<Arc<Notify>>,
185}
186
187impl fmt::Debug for BlockDriverTrigger {
188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189 f.debug_struct("BlockDriverTrigger")
190 .field("enabled", &self.notify.is_some())
191 .finish_non_exhaustive()
192 }
193}
194
195impl BlockDriverTrigger {
196 pub(crate) fn new(notify: Arc<Notify>) -> Self {
197 Self {
198 notify: Some(notify),
199 }
200 }
201
202 pub(crate) fn noop() -> Self {
203 Self { notify: None }
204 }
205
206 pub(crate) fn trigger_after_submit(&self) {
208 if let Some(notify) = &self.notify {
209 notify.notify_one();
210 }
211 }
212}
213
214#[derive(Debug)]
216pub(crate) struct BlockDriverTask {
217 trigger: BlockDriverTrigger,
218 manual_producer: Option<ManualBlockProducer>,
219 shutdown: Option<oneshot::Sender<()>>,
220 join: JoinHandle<()>,
221}
222
223impl BlockDriverTask {
224 pub(crate) fn start(
226 scheduler: SchedulerCoord_SchedulerCoord1_Client,
227 config: BlockDriverConfig,
228 ) -> Self {
229 let (shutdown_tx, shutdown_rx) = oneshot::channel();
230 let (trigger, manual_producer, join) = match config.mode {
231 BlockDriverMode::Instant => {
232 let notify = Arc::new(Notify::new());
233 let trigger = BlockDriverTrigger::new(Arc::clone(¬ify));
234 let join = tokio::spawn(run_instant_driver(scheduler, notify, shutdown_rx));
235 (trigger, None, join)
236 }
237 BlockDriverMode::Interval => {
238 let join = tokio::spawn(run_interval_driver(
239 scheduler,
240 config.effective_interval(),
241 shutdown_rx,
242 ));
243 (BlockDriverTrigger::noop(), None, join)
244 }
245 BlockDriverMode::Manual => {
246 let (requests, receiver) = mpsc::channel(MANUAL_BLOCK_REQUEST_CAPACITY);
247 let manual_producer = ManualBlockProducer { requests };
248 let join = tokio::spawn(run_manual_driver(
249 scheduler,
250 receiver,
251 config.effective_manual_request_timeout(),
252 shutdown_rx,
253 ));
254 (BlockDriverTrigger::noop(), Some(manual_producer), join)
255 }
256 };
257 Self {
258 trigger,
259 manual_producer,
260 shutdown: Some(shutdown_tx),
261 join,
262 }
263 }
264
265 pub(crate) fn trigger(&self) -> BlockDriverTrigger {
267 self.trigger.clone()
268 }
269
270 pub(crate) fn manual_producer(&self) -> Option<ManualBlockProducer> {
272 self.manual_producer.clone()
273 }
274
275 pub(crate) async fn stop(mut self) {
277 if let Some(shutdown) = self.shutdown.take() {
278 let _ = shutdown.send(());
279 }
280 if let Err(error) = self.join.await {
281 tracing::warn!(%error, "hogd-dev block driver task failed to join");
282 }
283 }
284}
285
286async fn run_manual_driver(
287 scheduler: SchedulerCoord_SchedulerCoord1_Client,
288 mut requests: mpsc::Receiver<ManualBlockRequest>,
289 request_timeout: Duration,
290 mut shutdown: oneshot::Receiver<()>,
291) {
292 loop {
293 tokio::select! {
294 biased;
295 _ = &mut shutdown => {
296 tracing::debug!("hogd-dev manual block driver stopping");
297 return;
298 }
299 request = requests.recv() => {
300 let Some(request) = request else {
301 return;
302 };
303 if request.reply.is_closed() {
304 continue;
305 }
306 tokio::select! {
307 biased;
308 _ = &mut shutdown => {
309 let _ = request.reply.send(Err(BlockProductionError::OutcomeUnknown));
310 tracing::debug!("hogd-dev manual block driver stopping");
311 return;
312 }
313 result = tokio::time::timeout(request_timeout, drive_one_block(&scheduler)) => {
314 let result = match result {
315 Ok(result) => manual_result(result),
316 Err(_) => Err(BlockProductionError::TimedOut {
317 timeout: request_timeout,
318 }),
319 };
320 if let Err(error) = &result {
321 tracing::warn!(%error, "manual hogd-dev block production failed");
322 }
323 let terminal = matches!(
324 &result,
325 Err(
326 BlockProductionError::TimedOut { .. }
327 | BlockProductionError::ProposalOutcomeUnknown { .. }
328 | BlockProductionError::CommitOutcomeUnknown { .. }
329 | BlockProductionError::CommitRejected { .. }
330 | BlockProductionError::MalformedSchedulerProposal { .. }
331 | BlockProductionError::ConsensusHashEncoding { .. }
332 )
333 );
334 let _ = request.reply.send(result);
335 if terminal {
336 tracing::warn!(
337 "manual block producer stopping after indeterminate outcome"
338 );
339 return;
340 }
341 }
342 }
343 }
344 }
345 }
346}
347
348fn manual_result(
349 result: Result<DriveOutcome, BlockProductionError>,
350) -> Result<ProducedBlock, BlockProductionError> {
351 match result {
352 Ok(DriveOutcome::Committed {
353 block_id,
354 height,
355 batch_xdr_bytes,
356 }) => Ok(ProducedBlock {
357 block_id,
358 height,
359 batch_xdr_bytes,
360 }),
361 Ok(DriveOutcome::NoGenesis) => Err(BlockProductionError::NoGenesis),
362 Err(error) => Err(error),
363 }
364}
365
366async fn run_instant_driver(
367 scheduler: SchedulerCoord_SchedulerCoord1_Client,
368 notify: Arc<Notify>,
369 mut shutdown: oneshot::Receiver<()>,
370) {
371 loop {
372 tokio::select! {
373 () = notify.notified() => {
374 tokio::select! {
375 result = drive_one_block(&scheduler) => {
376 log_drive_result(result);
377 }
378 _ = &mut shutdown => {
379 tracing::debug!("hogd-dev block driver stopping");
380 return;
381 }
382 }
383 }
384 _ = &mut shutdown => {
385 tracing::debug!("hogd-dev block driver stopping");
386 return;
387 }
388 }
389 }
390}
391
392async fn run_interval_driver(
393 scheduler: SchedulerCoord_SchedulerCoord1_Client,
394 interval: std::time::Duration,
395 mut shutdown: oneshot::Receiver<()>,
396) {
397 let mut ticker = tokio::time::interval(interval);
398 ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
399 ticker.tick().await;
402
403 loop {
404 tokio::select! {
405 _ = ticker.tick() => {
406 tokio::select! {
407 result = drive_one_block(&scheduler) => {
408 log_drive_result(result);
409 }
410 _ = &mut shutdown => {
411 tracing::debug!("hogd-dev interval block driver stopping");
412 return;
413 }
414 }
415 }
416 _ = &mut shutdown => {
417 tracing::debug!("hogd-dev interval block driver stopping");
418 return;
419 }
420 }
421 }
422}
423
424fn log_drive_result(result: Result<DriveOutcome, BlockProductionError>) {
425 match result {
426 Ok(DriveOutcome::Committed {
427 block_id,
428 height,
429 batch_xdr_bytes,
430 }) => {
431 tracing::info!(
432 block_id,
433 height,
434 batch_xdr_bytes,
435 "hogd-dev block driver committed block"
436 );
437 }
438 Ok(DriveOutcome::NoGenesis) => {
439 tracing::warn!("hogd-dev block driver woke before genesis was loaded");
440 }
441 Err(error) => {
442 tracing::warn!(%error, "hogd-dev block driver failed to commit block");
443 }
444 }
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448enum DriveOutcome {
449 Committed {
450 block_id: BlockID,
451 height: i64,
452 batch_xdr_bytes: usize,
453 },
454 NoGenesis,
455}
456
457async fn drive_one_block(
458 scheduler: &SchedulerCoord_SchedulerCoord1_Client,
459) -> Result<DriveOutcome, BlockProductionError> {
460 let tip = scheduler.schedcoord_get_tip().await.map_err(|source| {
461 BlockProductionError::SchedulerRpc {
462 op: "schedcoord_get_tip",
463 source,
464 }
465 })?;
466
467 let db_tip = match tip {
468 SchedulerGetTipResult::SchedulerGetTipOK { db_tip } => db_tip,
469 SchedulerGetTipResult::SchedulerGetTipNoGenesisBlock => return Ok(DriveOutcome::NoGenesis),
470 other => {
471 return Err(BlockProductionError::UnexpectedTipResult {
472 result: format!("{other:?}"),
473 });
474 }
475 };
476
477 let proposal_context = ProposalContext {
478 protocol: Protocol::new(0),
479 proposer: ValidatorID::default(),
480 block_timepoint: next_block_timepoint(
481 &db_tip.value.proposal_header.block_timepoint,
482 SystemTime::now(),
483 ),
484 };
485 let proposed = scheduler
486 .schedulercoord_propose_block(&db_tip.id, &proposal_context)
487 .await
488 .map_err(|source| BlockProductionError::ProposalOutcomeUnknown { source })?;
489
490 let proposed_block = match proposed {
491 SchedulerProposalResult::SchedulerProposalOK { proposed_block } => proposed_block,
492 SchedulerProposalResult::SchedulerProposalError => {
493 return Err(BlockProductionError::SchedulerRejected {
494 op: "schedulercoord_propose_block",
495 result: "SchedulerProposalError".into(),
496 });
497 }
498 other => {
499 return Err(BlockProductionError::SchedulerRejected {
500 op: "schedulercoord_propose_block",
501 result: format!("{other:?}"),
502 });
503 }
504 };
505
506 commit_proposed_block(scheduler, db_tip.value, proposed_block).await
507}
508
509fn next_block_timepoint(parent: &TimePoint, now: SystemTime) -> TimePoint {
514 let now = now.duration_since(UNIX_EPOCH).map_or(0, |since| {
515 u64::try_from(since.as_nanos()).unwrap_or(u64::MAX)
516 });
517 TimePoint::new(now.max(**parent))
518}
519
520async fn commit_proposed_block(
521 scheduler: &SchedulerCoord_SchedulerCoord1_Client,
522 parent_value: ConsensusValue,
523 proposed_block: SchedulerProposedBlock,
524) -> Result<DriveOutcome, BlockProductionError> {
525 let block_id = proposed_block.block_id;
526 let batch_xdr_bytes = proposed_block.txs.xdr.0.len();
527 let height = proposed_block.proposal.header.block_height.clone();
528 let value = consensus_value(
529 &parent_value,
530 proposed_block.proposal.header,
531 &proposed_block.proposal.transactions,
532 )?;
533
534 let outcome = match scheduler
535 .schedulercoord_commit_block(&block_id, &value)
536 .await
537 {
538 Ok(outcome) => outcome,
539 Err(source) => {
540 return reconcile_commit(scheduler, block_id, *height, value, batch_xdr_bytes, source)
541 .await;
542 }
543 };
544 if outcome == SchedulerCommitOutcome::SchedulerCommitOK {
545 return Ok(DriveOutcome::Committed {
546 block_id,
547 height: *height,
548 batch_xdr_bytes,
549 });
550 }
551
552 Err(BlockProductionError::CommitRejected {
553 block_id,
554 height: *height,
555 result: format!(
556 "{outcome}; previous height={}",
557 *parent_value.proposal_header.block_height
558 ),
559 })
560}
561
562fn consensus_value(
563 parent: &ConsensusValue,
564 proposal_header: ProposalHeader,
565 proposal_txs: &thruput_node_rpc::ProposalTxs,
566) -> Result<ConsensusValue, BlockProductionError> {
567 Ok(ConsensusValue {
568 protocol: Protocol::new(0),
569 ext: ExtensionPoint::_0,
570 prev_hash: hash_consensus_value(parent)?,
571 proposal_header,
572 proposal_txs_hash: proposal_txs_compatibility_hash(proposal_txs)?,
573 upgrades: xdrs::VecM::default(),
574 })
575}
576
577fn proposal_txs_compatibility_hash(
580 proposal_txs: &thruput_node_rpc::ProposalTxs,
581) -> Result<ProposalTxsHash, BlockProductionError> {
582 let mut base = proposal_txs.clone();
583 base.signed_hash.segment_tokens.0.clear();
584 base.signed_hash.segment_bytes.0.clear();
585 base.signed_hash.xdr.0.clear();
586 base.nondeterministic_data.segment_tokens.0.clear();
587 base.nondeterministic_data.segment_bytes.0.clear();
588 base.nondeterministic_data.xdr.0.clear();
589
590 let mut result = hash_xdr(&base)
591 .map_err(|source| BlockProductionError::ConsensusHashEncoding {
592 value: "proposal transactions base",
593 source,
594 })?
595 .into_inner();
596 xor_hash(
597 &mut result,
598 hash_parallel_batch_compatibility("signed_hash", &proposal_txs.signed_hash)?,
599 );
600 xor_hash(
601 &mut result,
602 hash_parallel_batch_compatibility(
603 "nondeterministic_data",
604 &proposal_txs.nondeterministic_data,
605 )?,
606 );
607 Ok(ProposalTxsHash::new(SHA256Hash::from(result)))
608}
609
610fn hash_parallel_batch_compatibility(
611 batch_name: &'static str,
612 batch: &thruput_node_rpc::ParallelBatch,
613) -> Result<[u8; 32], BlockProductionError> {
614 batch
615 .validate()
616 .map_err(|error| BlockProductionError::MalformedSchedulerProposal {
617 batch: batch_name,
618 detail: error.to_string(),
619 })?;
620
621 let mut result = [0u8; 32];
622 let mut offset = 0usize;
623 for &segment_size in &batch.segment_bytes.0 {
624 let end = offset.checked_add(segment_size as usize).ok_or_else(|| {
625 BlockProductionError::MalformedSchedulerProposal {
626 batch: batch_name,
627 detail: "segment byte offset overflowed usize".into(),
628 }
629 })?;
630 let segment = batch.xdr.0.get(offset..end).ok_or_else(|| {
631 BlockProductionError::MalformedSchedulerProposal {
632 batch: batch_name,
633 detail: format!(
634 "segment range {offset}..{end} exceeds {}-byte payload",
635 batch.xdr.len()
636 ),
637 }
638 })?;
639 xor_hash(&mut result, Sha256::digest(segment).into());
640 offset = end;
641 }
642 if offset != batch.xdr.len() {
643 return Err(BlockProductionError::MalformedSchedulerProposal {
644 batch: batch_name,
645 detail: format!(
646 "segment lengths cover {offset} bytes, payload has {} bytes",
647 batch.xdr.len()
648 ),
649 });
650 }
651
652 let mut index = batch.clone();
653 index.xdr.0.clear();
654 let index_hash =
655 hash_xdr(&index).map_err(|source| BlockProductionError::ConsensusHashEncoding {
656 value: batch_name,
657 source,
658 })?;
659 xor_hash(&mut result, index_hash.into_inner());
660 Ok(result)
661}
662
663fn xor_hash(result: &mut [u8; 32], hash: [u8; 32]) {
664 for (result_byte, hash_byte) in result.iter_mut().zip(hash) {
665 *result_byte ^= hash_byte;
666 }
667}
668
669fn hash_consensus_value(
670 value: &ConsensusValue,
671) -> Result<ConsensusValueHash, BlockProductionError> {
672 hash_xdr(value)
673 .map(ConsensusValueHash::new)
674 .map_err(|source| BlockProductionError::ConsensusHashEncoding {
675 value: "parent consensus value",
676 source,
677 })
678}
679
680fn hash_xdr<T: xdrs::WriteXDR>(value: &T) -> Result<SHA256Hash, xdrs::WriteError> {
681 xdrs::txs::encode(value)
682 .map(|encoded| SHA256Hash::from(<[u8; 32]>::from(Sha256::digest(encoded))))
683}
684
685async fn reconcile_commit(
686 scheduler: &SchedulerCoord_SchedulerCoord1_Client,
687 block_id: BlockID,
688 height: i64,
689 value: ConsensusValue,
690 batch_xdr_bytes: usize,
691 source: rpc_driver::RpcError,
692) -> Result<DriveOutcome, BlockProductionError> {
693 let reconciliation = match scheduler.schedcoord_get_tip().await {
694 Ok(SchedulerGetTipResult::SchedulerGetTipOK { db_tip })
695 if *db_tip.value.proposal_header.block_height == height && db_tip.value == value =>
696 {
697 tracing::warn!(
698 block_id,
699 height,
700 error = %source,
701 "commit RPC failed but scheduler tip confirms the proposed block"
702 );
703 return Ok(DriveOutcome::Committed {
704 block_id,
705 height,
706 batch_xdr_bytes,
707 });
708 }
709 Ok(result) => format!("scheduler tip did not confirm the proposal: {result:?}"),
710 Err(error) => format!("tip lookup failed: {error}"),
711 };
712 Err(BlockProductionError::CommitOutcomeUnknown {
713 block_id,
714 height,
715 source,
716 reconciliation,
717 })
718}
719
720#[cfg(test)]
721mod timepoint_tests {
722 use std::time::{Duration, UNIX_EPOCH};
723
724 use thruput_xdr::types::TimePoint;
725
726 use super::next_block_timepoint;
727
728 #[test]
729 fn takes_the_wall_clock_in_unix_nanoseconds() {
730 let now = UNIX_EPOCH + Duration::from_nanos(1_760_000_000_123_456_789);
731 assert_eq!(
732 *next_block_timepoint(&TimePoint::new(0), now),
733 1_760_000_000_123_456_789
734 );
735 }
736
737 #[test]
738 fn never_moves_behind_the_parent() {
739 let parent = TimePoint::new(1_760_000_000_000_000_000);
740 let earlier = UNIX_EPOCH + Duration::from_nanos(*parent - 1);
741 assert_eq!(*next_block_timepoint(&parent, earlier), *parent);
742 }
743
744 #[test]
745 fn a_clock_before_the_epoch_keeps_the_parent() {
746 let parent = TimePoint::new(42);
747 let before_epoch = UNIX_EPOCH - Duration::from_secs(1);
748 assert_eq!(*next_block_timepoint(&parent, before_epoch), 42);
749 }
750}
751
752#[cfg(test)]
753mod hash_tests {
754 use thruput_node_rpc::{
755 ParallelBatch, ParallelNondeterministicProposalDataBatch,
756 ParallelSignedTransactionHashBatch, ProposalTxs,
757 };
758 use thruput_xdr::types::{ExtensionPoint, SHA256Hash};
759 use xdrs::{BytesM, VecM};
760
761 use super::{BlockProductionError, hash_xdr, proposal_txs_compatibility_hash};
762
763 #[test]
764 fn proposal_txs_compatibility_hash_matches_hogd_golden_vector() {
765 let proposal_txs = ProposalTxs {
766 ext: ExtensionPoint::_0,
767 signed_hash: ParallelSignedTransactionHashBatch::new(ParallelBatch {
768 segment_tokens: VecM(vec![1, 2]),
769 segment_bytes: VecM(vec![4, 8]),
770 xdr: BytesM(vec![0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3]),
771 }),
772 nondeterministic_data: ParallelNondeterministicProposalDataBatch::new(ParallelBatch {
773 segment_tokens: VecM(vec![1]),
774 segment_bytes: VecM(vec![4]),
775 xdr: BytesM(vec![0xde, 0xad, 0xbe, 0xef]),
776 }),
777 };
778
779 assert_eq!(
780 proposal_txs_compatibility_hash(&proposal_txs)
781 .expect("bug: valid proposal transactions should hash")
782 .into_inner(),
783 SHA256Hash::from([
784 0x3a, 0x3c, 0xe7, 0x82, 0x83, 0x94, 0x38, 0x26, 0xac, 0x43, 0x29, 0x1f, 0xab, 0x79,
785 0xbd, 0x17, 0xc4, 0xad, 0x1d, 0x1e, 0xf9, 0xe2, 0x2d, 0x8c, 0x72, 0x31, 0xa9, 0x7c,
786 0xb6, 0x68, 0xdc, 0xd9,
787 ])
788 );
789 }
790
791 #[test]
792 fn proposal_txs_hash_rejects_segment_count_mismatch() {
793 let proposal_txs = ProposalTxs {
794 ext: ExtensionPoint::_0,
795 signed_hash: ParallelSignedTransactionHashBatch::new(ParallelBatch {
796 segment_tokens: VecM(vec![1]),
797 segment_bytes: VecM(vec![]),
798 xdr: BytesM(vec![]),
799 }),
800 nondeterministic_data: ParallelNondeterministicProposalDataBatch::new(
801 ParallelBatch::empty(),
802 ),
803 };
804
805 let error = proposal_txs_compatibility_hash(&proposal_txs)
806 .expect_err("bug: mismatched segment metadata should fail");
807 assert!(matches!(
808 error,
809 BlockProductionError::MalformedSchedulerProposal {
810 batch: "signed_hash",
811 ..
812 }
813 ));
814 }
815
816 #[test]
817 fn proposal_txs_hash_rejects_segment_byte_mismatch() {
818 let proposal_txs = ProposalTxs {
819 ext: ExtensionPoint::_0,
820 signed_hash: ParallelSignedTransactionHashBatch::new(ParallelBatch {
821 segment_tokens: VecM(vec![1]),
822 segment_bytes: VecM(vec![2]),
823 xdr: BytesM(vec![0]),
824 }),
825 nondeterministic_data: ParallelNondeterministicProposalDataBatch::new(
826 ParallelBatch::empty(),
827 ),
828 };
829
830 let error = proposal_txs_compatibility_hash(&proposal_txs)
831 .expect_err("bug: segment lengths outside the payload should fail");
832 assert!(matches!(
833 error,
834 BlockProductionError::MalformedSchedulerProposal {
835 batch: "signed_hash",
836 ..
837 }
838 ));
839 }
840
841 #[test]
842 fn xdr_hashing_returns_encoding_errors() {
843 let over_bound = BytesM::<1>(vec![0, 1]);
844 let error = hash_xdr(&over_bound).expect_err("bug: over-bound XDR should fail to encode");
845 assert!(matches!(error, xdrs::WriteError::InvalidArrayLength));
846 }
847}