Skip to main content

thruput_node_supervisor/
block_driver.rs

1//! Local hogd-dev block production driver.
2//!
3//! Production deployments rely on an external coordinator to ask the scheduler
4//! to nominate and commit blocks. `thruput node` deliberately avoids running
5//! chuckd for the localhost topology, so the supervisor owns that small piece
6//! of coordination policy for hogd-dev.
7
8use 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/// Metadata returned after the scheduler commits a manually-produced block.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct ProducedBlock {
39    /// Scheduler-local identifier used to commit the proposed block.
40    pub block_id: BlockID,
41    /// Height reported by the committed proposal.
42    pub height: i64,
43    /// Encoded transaction-batch size reported by the proposal.
44    pub batch_xdr_bytes: usize,
45}
46
47/// Errors returned while manually producing a local block.
48#[derive(Debug, thiserror::Error)]
49#[non_exhaustive]
50pub enum BlockProductionError {
51    /// The block driver stopped before it could acknowledge the request.
52    #[error("manual block producer has stopped")]
53    DriverStopped,
54    /// Shutdown interrupted a request after scheduler work may have begun.
55    #[error("manual block production was interrupted; scheduler outcome may be unknown")]
56    OutcomeUnknown,
57    /// The scheduler did not complete a request before its deadline.
58    #[error("manual block production did not complete within {timeout:?}; outcome may be unknown")]
59    TimedOut {
60        /// Configured request deadline.
61        timeout: Duration,
62    },
63    /// The scheduler had no genesis block to build on.
64    #[error("scheduler has no genesis block")]
65    NoGenesis,
66    /// A scheduler RPC failed.
67    #[error("scheduler RPC `{op}` failed: {source}")]
68    SchedulerRpc {
69        /// Scheduler operation that failed.
70        op: &'static str,
71        /// Underlying x23 RPC error.
72        #[source]
73        source: rpc_driver::RpcError,
74    },
75    /// Proposal RPC failed after the scheduler may have retained a candidate.
76    #[error("proposal outcome is unknown after RPC failure: {source}")]
77    ProposalOutcomeUnknown {
78        /// Failed proposal RPC.
79        #[source]
80        source: rpc_driver::RpcError,
81    },
82    /// The scheduler rejected proposal or commit.
83    #[error("scheduler rejected `{op}`: {result}")]
84    SchedulerRejected {
85        /// Scheduler operation that was rejected.
86        op: &'static str,
87        /// Scheduler result value.
88        result: String,
89    },
90    /// The scheduler rejected commit and may still retain the proposed candidate.
91    #[error("scheduler rejected commit for block {block_id} at height {height}: {result}")]
92    CommitRejected {
93        /// Scheduler-local proposed block identifier.
94        block_id: BlockID,
95        /// Proposed block height.
96        height: i64,
97        /// Scheduler commit result value.
98        result: String,
99    },
100    /// The scheduler returned an unexpected tip result.
101    #[error("unexpected scheduler tip result: {result}")]
102    UnexpectedTipResult {
103        /// Debug representation of the unexpected result.
104        result: String,
105    },
106    /// Commit transport failed and the current tip did not confirm the proposal.
107    #[error(
108        "commit outcome for block {block_id} at height {height} is unknown after RPC failure: {source}; reconciliation: {reconciliation}"
109    )]
110    CommitOutcomeUnknown {
111        /// Scheduler-local proposed block identifier.
112        block_id: BlockID,
113        /// Nominated block height.
114        height: i64,
115        /// Failed commit RPC.
116        #[source]
117        source: rpc_driver::RpcError,
118        /// Result of the best-effort tip reconciliation.
119        reconciliation: String,
120    },
121    /// The scheduler returned internally inconsistent proposal batch metadata.
122    #[error("scheduler returned malformed proposal batch `{batch}`: {detail}")]
123    MalformedSchedulerProposal {
124        /// Proposal batch whose metadata was inconsistent.
125        batch: &'static str,
126        /// Validation or range failure details.
127        detail: String,
128    },
129    /// A scheduler protocol value could not be encoded for consensus hashing.
130    #[error("failed to XDR-encode {value} for consensus hashing: {source}")]
131    ConsensusHashEncoding {
132        /// Protocol value being hashed.
133        value: &'static str,
134        /// XDR encoding failure.
135        #[source]
136        source: xdrs::WriteError,
137    },
138}
139
140/// Cloneable in-process control for producing blocks in manual mode.
141#[derive(Debug, Clone)]
142pub struct ManualBlockProducer {
143    requests: mpsc::Sender<ManualBlockRequest>,
144}
145
146impl ManualBlockProducer {
147    /// Run one scheduler proposal/commit cycle and await its outcome.
148    ///
149    /// Dropping the returned future before the driver starts the request cancels
150    /// it. Once scheduler work begins, dropping the future does not cancel the
151    /// cycle. [`BlockProductionError::TimedOut`],
152    /// [`BlockProductionError::OutcomeUnknown`],
153    /// [`BlockProductionError::ProposalOutcomeUnknown`], and
154    /// [`BlockProductionError::CommitOutcomeUnknown`] mean callers must inspect
155    /// chain state before deciding whether to request another block. Malformed
156    /// proposal, commit rejection, and consensus-hash errors are also terminal
157    /// because the scheduler may have retained the proposed candidate.
158    /// The producer becomes terminal after an indeterminate result so queued
159    /// requests cannot overlap scheduler work that may still be running.
160    ///
161    /// The current scheduler protocol reports an empty or otherwise
162    /// non-nominatable mempool as [`BlockProductionError::SchedulerRejected`];
163    /// it does not expose a distinct empty-mempool outcome.
164    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/// Cheap wake handle held by submit-plane adapters.
182#[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    /// Wake the block driver after the scheduler submit RPC succeeds.
207    pub(crate) fn trigger_after_submit(&self) {
208        if let Some(notify) = &self.notify {
209            notify.notify_one();
210        }
211    }
212}
213
214/// Background automine task for the hogd-dev scheduler.
215#[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    /// Start a coalescing block driver for the supplied scheduler client.
225    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(&notify));
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    /// Wake handle for scheduler submit adapters.
266    pub(crate) fn trigger(&self) -> BlockDriverTrigger {
267        self.trigger.clone()
268    }
269
270    /// Manual producer when the task was started in manual mode.
271    pub(crate) fn manual_producer(&self) -> Option<ManualBlockProducer> {
272        self.manual_producer.clone()
273    }
274
275    /// Stop the driver task and wait for it to exit.
276    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    // `interval` ticks immediately on creation. Consume that tick so the
400    // local node's first block opportunity reflects the configured cadence.
401    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
509/// The timepoint for a block following one at `parent`: `now` as Unix
510/// nanoseconds, but never behind `parent`, because hogd refuses to nominate a
511/// block older than its committed tip and a wall-clock step back would
512/// otherwise stall production.
513fn 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
577// Matches hogd's temporary compatibility hash until the protocol moves to
578// canonical XDR hashing for ProposalTxs.
579fn 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}