Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions crates/chain-orchestrator/src/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
use dogeos_reth_primitives::DogeosBlock;
use tokio::sync::oneshot;

/// The terminal outcome of an admitted manual block build.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildBlockOutcome {
/// The payload was finalized and queued for signing.
Sequenced(DogeosBlock),
/// The payload was empty, invalidated by a state transition, or explicitly cancelled.
Skipped,
/// Payload finalization, persistence, or signer enqueue failed after admission.
Failed(String),
}

/// A completion receiver uniquely associated with one admitted manual block build.
///
/// Dropping the ticket does not cancel the build. Call [`Self::wait`] to receive its terminal
/// outcome without relying on an uncorrelated global event stream.
#[derive(Debug)]
pub struct BuildBlockTicket {
completion: oneshot::Receiver<BuildBlockOutcome>,
}

#[derive(Debug)]
pub(crate) struct BuildBlockCompletion(oneshot::Sender<BuildBlockOutcome>);

pub(crate) fn build_block_channel() -> (BuildBlockCompletion, BuildBlockTicket) {
let (sender, receiver) = oneshot::channel();
(BuildBlockCompletion(sender), BuildBlockTicket::new(receiver))
}

impl BuildBlockCompletion {
pub(crate) fn complete(self, outcome: BuildBlockOutcome) {
let _ = self.0.send(outcome);
}
}

impl BuildBlockTicket {
pub(crate) const fn new(completion: oneshot::Receiver<BuildBlockOutcome>) -> Self {
Self { completion }
}

/// Waits for the admitted build's terminal outcome.
pub async fn wait(self) -> Result<BuildBlockOutcome, oneshot::error::RecvError> {
self.completion.await
}
}

#[cfg(test)]
mod tests {
use super::{build_block_channel, BuildBlockOutcome};

#[tokio::test]
async fn ticket_receives_only_its_correlated_terminal_outcome() {
let (first_completion, first_ticket) = build_block_channel();
let (second_completion, second_ticket) = build_block_channel();

second_completion.complete(BuildBlockOutcome::Failed("payload failed".to_string()));
first_completion.complete(BuildBlockOutcome::Skipped);

assert_eq!(first_ticket.wait().await.unwrap(), BuildBlockOutcome::Skipped);
assert_eq!(
second_ticket.wait().await.unwrap(),
BuildBlockOutcome::Failed("payload failed".to_string())
);
}
}
202 changes: 193 additions & 9 deletions crates/chain-orchestrator/src/consensus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use dogeos_reth_primitives::DogeosBlock;
use metrics::Counter;
use metrics_derive::Metrics;
use reth_primitives_traits::GotExpected;
use rollup_node_primitives::{sig_encode_hash, ConsensusUpdate};
use rollup_node_primitives::{sig_encode_hash, BlockInfo, ConsensusUpdate};
use scroll_network::ConsensusError;
use std::fmt::Debug;

Expand All @@ -19,6 +19,23 @@ pub trait Consensus: Send + Sync + Debug {
) -> Result<(), ConsensusError>;
/// Returns a boolean indicating whether the sequencer should sequence a block.
fn should_sequence_block(&self, sequencer: &Address) -> bool;
/// Returns whether an authorization barrier is currently open, i.e. a dynamic L1 head
/// transition has been observed but its authorized signer has not yet been confirmed.
///
/// While pending, the consumer must withhold sequencing, local block finalization/announcement,
/// and inbound block acceptance (fail-closed). The default is `false` for consensus
/// implementations that never participate in the head-qualified authorization protocol.
fn authorization_pending(&self) -> bool {
false
}
/// Synchronously enters a suspended authorization state (opens the barrier) with no specific
/// head.
///
/// Used on an administrative reset in dynamic mode so authorization-sensitive work is withheld
/// against the pre-reset state until the fresh watcher re-establishes and closes a
/// head-qualified barrier. The default is a no-op for consensus implementations that never
/// participate in the authorization protocol.
fn suspend_authorization(&mut self) {}
}

/// A no-op consensus instance.
Expand Down Expand Up @@ -54,6 +71,14 @@ pub(crate) struct SystemContractConsensusMetrics {
#[derive(Debug)]
pub struct SystemContractConsensus {
authorized_signer: Address,
/// The L1 head for which an authorized-signer confirmation is pending, i.e. the open
/// authorization barrier.
///
/// Set by a phase-one [`ConsensusUpdate::AuthorizationPending`] and cleared by a phase-two
/// [`ConsensusUpdate::AuthorizedSigner`] whose `head` matches. A phase two for any other head
/// is stale (for example a replaced or reorged head) and must never clear or move the
/// barrier.
pending_authorization_head: Option<BlockInfo>,

/// The metrics for the [`SystemContractConsensus`].
metrics: SystemContractConsensusMetrics,
Expand All @@ -67,19 +92,49 @@ impl SystemContractConsensus {
target: "scroll::consensus",
"Initialized system contract consensus with authorized signer: {authorized_signer}"
);
Self { authorized_signer, metrics: SystemContractConsensusMetrics::default() }
Self {
authorized_signer,
pending_authorization_head: None,
metrics: SystemContractConsensusMetrics::default(),
}
}
}

impl Consensus for SystemContractConsensus {
fn update_config(&mut self, update: &ConsensusUpdate) {
match update {
ConsensusUpdate::AuthorizedSigner(signer) => {
ConsensusUpdate::AuthorizationPending(head) => {
// Open (or move) the barrier to this head. A newer pending head supersedes any
// earlier pending head, which is valid across an A -> B (or A -> B -> A) sequence.
tracing::debug!(
target: "scroll::consensus",
number = head.number,
hash = ?head.hash,
"authorization pending for L1 head; withholding sequencing and block import"
);
self.pending_authorization_head = Some(*head);
}
ConsensusUpdate::AuthorizedSigner { head, signer } => {
// Only a signer confirmation for the currently pending head may close the barrier;
// a stale head (replaced or reorged) is ignored and leaves the barrier untouched.
if self.pending_authorization_head != Some(*head) {
tracing::debug!(
target: "scroll::consensus",
number = head.number,
hash = ?head.hash,
pending = ?self.pending_authorization_head,
"ignoring stale authorized-signer update for non-pending L1 head"
);
return;
}
tracing::info!(
target: "scroll::consensus",
number = head.number,
hash = ?head.hash,
"Authorized signer updated to: {signer}"
);
self.authorized_signer = *signer
self.authorized_signer = *signer;
self.pending_authorization_head = None;
}
};
}
Expand All @@ -89,6 +144,13 @@ impl Consensus for SystemContractConsensus {
block: &DogeosBlock,
signature: &Signature,
) -> Result<(), ConsensusError> {
// Fail-closed while the barrier is open: the authorized signer for the current L1 head is
// not yet confirmed, so no block can be validated. This is distinct from an incorrect
// signature and must not penalize the peer (see the network manager).
if self.pending_authorization_head.is_some() {
return Err(ConsensusError::AuthorizationPending)
}

let hash = sig_encode_hash(&block.header);
let signer = reth_primitives_traits::crypto::secp256k1::recover_signer(signature, hash)?;

Expand All @@ -103,7 +165,19 @@ impl Consensus for SystemContractConsensus {
}

fn should_sequence_block(&self, sequencer: &Address) -> bool {
sequencer == &self.authorized_signer
// Withhold sequencing while the barrier is open.
self.pending_authorization_head.is_none() && sequencer == &self.authorized_signer
}

fn authorization_pending(&self) -> bool {
self.pending_authorization_head.is_some()
}

fn suspend_authorization(&mut self) {
// Open the barrier with a sentinel head. The fresh watcher's `AuthorizationPending`
// overwrites this sentinel with the real head and its `AuthorizedSigner` then closes it, so
// the barrier is held open only for the reset window.
self.pending_authorization_head = Some(BlockInfo::default());
}
}

Expand All @@ -117,9 +191,10 @@ mod tests {
use std::{str::FromStr, sync::OnceLock};

#[test]
fn test_should_validate_block() {
let consensus =
SystemContractConsensus::new(address!("d83c4892bb5aa241b63d8c4c134920111e142a20"));
fn authorized_signer_update_changes_sequencing_and_validation() {
let old_signer = address!("1111111111111111111111111111111111111111");
let new_signer = address!("d83c4892bb5aa241b63d8c4c134920111e142a20");
let mut consensus = SystemContractConsensus::new(old_signer);
let signature = Signature::from_raw(&bytes!("6d2b8ef87f0956ea4dd10fb0725fa7196ad80c6d567a161f6b4367f95b5de6ec279142b540d3b248f08ed337bb962fa3fd83d21de622f7d6c8207272558fd15a00")).unwrap();

let tx_hash = OnceLock::new();
Expand Down Expand Up @@ -168,6 +243,115 @@ mod tests {
withdrawals: None,
},
};
consensus.validate_new_block(&block, &signature).unwrap()

let head = BlockInfo {
number: 100,
hash: b256!("00000000000000000000000000000000000000000000000000000000000000aa"),
};
let reorged_head = BlockInfo {
number: 100,
hash: b256!("00000000000000000000000000000000000000000000000000000000000000bb"),
};

// Initially the old signer sequences and the block (signed by the new signer) is rejected
// with a precise `IncorrectSigner` payload.
assert!(consensus.should_sequence_block(&old_signer));
assert!(!consensus.should_sequence_block(&new_signer));
assert!(!consensus.authorization_pending());
match consensus.validate_new_block(&block, &signature).unwrap_err() {
ConsensusError::IncorrectSigner(GotExpected { got, expected }) => {
assert_eq!(got, new_signer);
assert_eq!(expected, old_signer);
}
other => panic!("expected IncorrectSigner, got {other:?}"),
}

// Opening the barrier withholds sequencing and fails block validation with the distinct,
// non-penalizing `AuthorizationPending` error rather than a signature mismatch.
consensus.update_config(&ConsensusUpdate::AuthorizationPending(head));
assert!(consensus.authorization_pending());
assert!(!consensus.should_sequence_block(&old_signer));
assert!(!consensus.should_sequence_block(&new_signer));
assert!(matches!(
consensus.validate_new_block(&block, &signature).unwrap_err(),
ConsensusError::AuthorizationPending
));

// A stale phase-two update for a different (reorged) head must not close the barrier or
// move the signer.
consensus.update_config(&ConsensusUpdate::AuthorizedSigner {
head: reorged_head,
signer: new_signer,
});
assert!(consensus.authorization_pending());
assert!(!consensus.should_sequence_block(&new_signer));

// The matching phase-two update closes the barrier and rotates the signer.
consensus.update_config(&ConsensusUpdate::AuthorizedSigner { head, signer: new_signer });
assert!(!consensus.authorization_pending());
assert!(!consensus.should_sequence_block(&old_signer));
assert!(consensus.should_sequence_block(&new_signer));
consensus.validate_new_block(&block, &signature).unwrap();

// An unchanged signer still opens and closes each head's barrier.
let next_head = BlockInfo {
number: 101,
hash: b256!("00000000000000000000000000000000000000000000000000000000000000cc"),
};
consensus.update_config(&ConsensusUpdate::AuthorizationPending(next_head));
assert!(consensus.authorization_pending());
consensus.update_config(&ConsensusUpdate::AuthorizedSigner {
head: next_head,
signer: new_signer,
});
assert!(!consensus.authorization_pending());
assert!(consensus.should_sequence_block(&new_signer));
}

#[test]
fn suspend_authorization_holds_barrier_until_fresh_refresh() {
let signer = address!("1111111111111111111111111111111111111111");
let new_signer = address!("2222222222222222222222222222222222222222");
let mut consensus = SystemContractConsensus::new(signer);

// Baseline: barrier closed, the authorized signer sequences.
assert!(!consensus.authorization_pending());
assert!(consensus.should_sequence_block(&signer));

// A reorg-driven reset (`RevertToL1Block` in dynamic mode) suspends authorization: the
// barrier opens immediately with a sentinel head so nothing sequences or imports under a
// signer that may be revoked, before the fresh watcher has re-read L1 for the reset head.
consensus.suspend_authorization();
assert!(consensus.authorization_pending());
assert!(!consensus.should_sequence_block(&signer));

// A stale phase-two update left over from before the reset can never match the sentinel
// head, so it cannot close the reset barrier.
let stale_head = BlockInfo {
number: 7,
hash: b256!("00000000000000000000000000000000000000000000000000000000000000dd"),
};
consensus.update_config(&ConsensusUpdate::AuthorizedSigner {
head: stale_head,
signer: new_signer,
});
assert!(consensus.authorization_pending());
assert!(!consensus.should_sequence_block(&new_signer));

// The fresh watcher's phase one overwrites the sentinel with the real reset head; phase two
// for that same head then closes the barrier and installs the refreshed signer.
let fresh_head = BlockInfo {
number: 9,
hash: b256!("00000000000000000000000000000000000000000000000000000000000000ee"),
};
consensus.update_config(&ConsensusUpdate::AuthorizationPending(fresh_head));
assert!(consensus.authorization_pending());
consensus.update_config(&ConsensusUpdate::AuthorizedSigner {
head: fresh_head,
signer: new_signer,
});
assert!(!consensus.authorization_pending());
assert!(consensus.should_sequence_block(&new_signer));
assert!(!consensus.should_sequence_block(&signer));
}
}
Loading
Loading