Skip to content
Merged
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
362 changes: 231 additions & 131 deletions Cargo.lock

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ anyhow = "1.0.95"
async-trait = "0.1.83"
async-compression = { version = "0.4.24", features = ["tokio", "gzip"] }
bech32 = "0.11.0"
cbor4ii = { version = "1.0.0", features = ["serde1", "use_std"] }
clap = { version = "4.5.38", features = ["derive", "env"] }
reqwest = { version = "0.11.24", default-features = false, features = ["json", "stream", "rustls-tls"] }
either = "1.15"
Expand Down Expand Up @@ -66,6 +67,7 @@ tracing-subscriber = { version = "0.3.18", features = [
"std",
"json",
] }
typetag = "0.2.20"

amaru-consensus = { path = "crates/amaru-consensus" }
amaru-kernel = { path = "crates/amaru-kernel" }
Expand All @@ -84,12 +86,13 @@ vrf_dalek = { git = "https://github.com/txpipe/vrf", rev = "044b45a1a919ba9d9c24
kes-summed-ed25519 = { git = "https://github.com/txpipe/kes", rev = "f69fb357d46f6a18925543d785850059569d7e78" }

# dev-dependencies
criterion = "0.6.0"
ctor = "0.4.1"
tempfile = "3.20.0"
rand = "0.9.1"
proptest = { version = "1.5.0", default-features = false, features = ["alloc"] }
insta = "1.41.1"
criterion = "0.6.0"
pretty_assertions = "1.4.1"
proptest = { version = "1.5.0", default-features = false, features = ["alloc"] }
rand = "0.9.1"
tempfile = "3.20.0"
test-case = "3.3.1"

# build-dependencies
Expand Down
1 change: 1 addition & 0 deletions crates/amaru-consensus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pallas-codec.workspace = true
pallas-crypto.workspace = true
pallas-math.workspace = true
rayon.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
Expand Down
4 changes: 3 additions & 1 deletion crates/amaru-consensus/src/consensus/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,20 @@ impl fmt::Debug for ChainSyncEvent {
}
}

#[derive(Clone, PartialEq)]
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[allow(clippy::large_enum_variant)]
pub enum DecodedChainSyncEvent {
RollForward {
peer: Peer,
point: Point,
header: Header,
#[serde(skip, default = "Span::none")]
span: Span,
},
Rollback {
peer: Peer,
rollback_point: Point,
#[serde(skip, default = "Span::none")]
span: Span,
},
}
Expand Down
89 changes: 46 additions & 43 deletions crates/amaru-consensus/src/consensus/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@
// See the License for the specific language governing permissions and
// limitations under the License.

use amaru_kernel::{protocol_parameters::GlobalParameters, EraHistory, Nonce, Point, RawBlock};
use amaru_kernel::{
network::NetworkName, protocol_parameters::GlobalParameters, EraHistory, Header, Nonce, Point,
RawBlock,
};
use amaru_ouroboros::{praos::nonce, Nonces};
use amaru_ouroboros_traits::{IsHeader, Praos};
use pallas_crypto::hash::Hash;
use slot_arithmetic::TimeHorizonError;
use std::fmt::Display;
use std::{collections::BTreeMap, fmt::Display};
use thiserror::Error;

#[derive(Error, PartialEq, Debug)]
#[derive(Error, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
pub enum StoreError {
WriteError { error: String },
ReadError { error: String },
Expand Down Expand Up @@ -86,7 +89,7 @@ impl<H: IsHeader> ChainStore<H> for Box<dyn ChainStore<H>> {
}
}

#[derive(Error, Debug, PartialEq)]
#[derive(Error, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum NoncesError {
#[error("cannot find nonces: unknown parent {parent} from header {header}")]
UnknownParent { header: Hash<32>, parent: Hash<32> },
Expand Down Expand Up @@ -189,6 +192,44 @@ impl<H: IsHeader> Praos<H> for dyn ChainStore<H> {
}
}

#[derive(Default)]
pub struct FakeStore {
headers: BTreeMap<Hash<32>, Header>,
nonces: BTreeMap<Hash<32>, Nonces>,
}

impl ChainStore<Header> for FakeStore {
fn load_header(&self, hash: &Hash<32>) -> Option<Header> {
self.headers.get(hash).cloned()
}

fn store_header(&mut self, hash: &Hash<32>, header: &Header) -> Result<(), StoreError> {
self.headers.insert(*hash, header.clone());
Ok(())
}

fn get_nonces(&self, header: &Hash<32>) -> Option<Nonces> {
self.nonces.get(header).cloned()
}

fn put_nonces(&mut self, header: &Hash<32>, nonces: &Nonces) -> Result<(), StoreError> {
self.nonces.insert(*header, nonces.clone());
Ok(())
}

fn era_history(&self) -> &EraHistory {
NetworkName::Preprod.into()
}

Comment on lines +220 to +223

@coderabbitai coderabbitai Bot Jun 14, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Dangling reference returned from era_history

NetworkName::Preprod.into() yields a fresh EraHistory value. Returning & to that temporary is UB bait – the borrow checker should already be yelling. Fix by storing the EraHistory inside FakeStore (or use a 'static constant) and return a reference to that.

 #[derive(Default)]
 pub struct FakeStore {
     headers: BTreeMap<Hash<32>, Header>,
     nonces: BTreeMap<Hash<32>, Nonces>,
+    era_history: EraHistory,
 }
 
 impl Default for FakeStore {
     fn default() -> Self {
-        Self { headers: BTreeMap::new(), nonces: BTreeMap::new() }
+        Self {
+            headers: BTreeMap::new(),
+            nonces: BTreeMap::new(),
+            era_history: NetworkName::Preprod.into(),
+        }
     }
 }
 
 fn era_history(&self) -> &EraHistory {
-    NetworkName::Preprod.into()
+    &self.era_history
 }
🤖 Prompt for AI Agents
In crates/amaru-consensus/src/consensus/store.rs around lines 220 to 223, the
era_history function returns a reference to a temporary EraHistory created by
NetworkName::Preprod.into(), which leads to a dangling reference. Fix this by
storing the EraHistory instance as a field inside the FakeStore struct or by
defining a static constant EraHistory, then return a reference to that stored
value instead of a temporary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That particular From implementation does indeed return a static reference to a constant value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rkuhn Ah, fair dinkum! Thanks for the clarification, mate. That's a neat implementation detail - having NetworkName::Preprod.into() return a static reference to a constant EraHistory is quite clever. Apologies for the false alarm - I was thinking in general terms about temporaries rather than the specific implementation. Cheers for setting the record straight! 🍺


✏️ Learnings added
Learnt from: rkuhn
PR: pragma-org/amaru#263
File: crates/amaru-consensus/src/consensus/store.rs:220-223
Timestamp: 2025-06-14T16:38:35.424Z
Learning: In `NetworkName::Preprod.into()` when converting to `&EraHistory`, the From implementation returns a static reference to a constant value, not a temporary. This makes it safe to return directly from functions expecting `&EraHistory` without storing it in a struct field.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

fn load_block(&self, _hash: &Hash<32>) -> Result<RawBlock, StoreError> {
unimplemented!()
}

fn store_block(&mut self, _hash: &Hash<32>, _block: &RawBlock) -> Result<(), StoreError> {
unimplemented!()
}
}

#[cfg(test)]
mod test {
use super::*;
Expand All @@ -197,7 +238,7 @@ mod test {
use amaru_ouroboros_traits::{IsHeader, Praos};
use proptest::{prelude::*, prop_compose, proptest};
use slot_arithmetic::Epoch;
use std::{collections::BTreeMap, sync::LazyLock};
use std::sync::LazyLock;

// Epoch 164's last header
include_header!(PREPROD_HEADER_69638382, 69638382);
Expand Down Expand Up @@ -242,44 +283,6 @@ mod test {
tail: hash!("d6fe6439aed8bddc10eec22c1575bf0648e4a76125387d9e985e9a3f8342870d"),
});

#[derive(Default)]
struct FakeStore {
headers: BTreeMap<Hash<32>, Header>,
nonces: BTreeMap<Hash<32>, Nonces>,
}

impl ChainStore<Header> for FakeStore {
fn load_header(&self, hash: &Hash<32>) -> Option<Header> {
self.headers.get(hash).cloned()
}

fn store_header(&mut self, hash: &Hash<32>, header: &Header) -> Result<(), StoreError> {
self.headers.insert(*hash, header.clone());
Ok(())
}

fn get_nonces(&self, header: &Hash<32>) -> Option<Nonces> {
self.nonces.get(header).cloned()
}

fn put_nonces(&mut self, header: &Hash<32>, nonces: &Nonces) -> Result<(), StoreError> {
self.nonces.insert(*header, nonces.clone());
Ok(())
}

fn era_history(&self) -> &EraHistory {
NetworkName::Preprod.into()
}

fn load_block(&self, _hash: &Hash<32>) -> Result<RawBlock, StoreError> {
unimplemented!()
}

fn store_block(&mut self, _hash: &Hash<32>, _block: &RawBlock) -> Result<(), StoreError> {
unimplemented!()
}
}

fn evolve_nonce(
last_header_last_epoch: &Header,
parent: (&Header, &Nonces),
Expand Down
11 changes: 11 additions & 0 deletions crates/amaru-consensus/src/consensus/store_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,20 @@ use tokio::sync::Mutex;

use super::DecodedChainSyncEvent;

#[derive(serde::Serialize, serde::Deserialize)]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@abailly @stevana We need to get rid of all these active pieces of state within the pure stages, all effects need to be moved to ExternalEffect. This will then mean that we only need to deal with skipping this store when persisting the ExternalEffect — deserialization is optional for those because it is not needed for replaying a trace.

pub struct StoreHeader {
#[serde(skip, default = "default_store")]
pub store: Arc<Mutex<dyn ChainStore<Header>>>,
}
fn default_store() -> Arc<Mutex<dyn ChainStore<Header>>> {
Arc::new(Mutex::new(super::store::FakeStore::default()))
}
Comment on lines +23 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Serialising drops the actual store – replay may load with an empty ledger

#[serde(skip, default = "default_store")] swaps the real Arc<Mutex<dyn ChainStore>> for a fresh in-memory FakeStore on deserialise.
That’s grand for unit tests, but in a live replay you’ll silently lose all persisted headers and later lookups will come up blank.

Consider emitting a stub handle that can re-attach to the real store (e.g. via a registry key) or, at minimum, loudly logging the downgrade so folks don’t wonder why the chain’s vanished.

🤖 Prompt for AI Agents
In crates/amaru-consensus/src/consensus/store_header.rs around lines 23 to 30,
the current serde attribute skips serializing the actual store and replaces it
with a default FakeStore on deserialization, causing loss of the real store data
during replay. To fix this, modify the serialization logic to emit a stub or
identifier that can be used to re-attach to the real store on deserialization,
such as a registry key or handle. Additionally, add explicit logging when
falling back to the FakeStore to alert users that the real store was not
restored, preventing silent data loss.


impl PartialEq for StoreHeader {
fn eq(&self, _other: &Self) -> bool {
true
}
}
Comment on lines +32 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

PartialEq always returns true – this can bite

Two completely different StoreHeaders will compare equal.
If somebody sticks them in a Vec::dedup() or uses them as map keys, chaos will ensue faster than Mario on a Rainbow Road shortcut.

Either remove the impl or compare on an identity (e.g. Arc::as_ptr(&store)).

🤖 Prompt for AI Agents
In crates/amaru-consensus/src/consensus/store_header.rs around lines 32 to 36,
the PartialEq implementation for StoreHeader always returns true, causing all
instances to be considered equal incorrectly. Fix this by either removing the
custom PartialEq impl to use the derived or default behavior, or implement a
proper equality check based on a unique identity such as comparing pointer
addresses with Arc::as_ptr(&store) to ensure only truly identical StoreHeader
instances compare equal.


impl fmt::Debug for StoreHeader {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Expand Down
61 changes: 49 additions & 12 deletions crates/amaru-consensus/src/consensus/validate_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,46 @@ pub fn header_is_valid(
.map_err(|e| ConsensusError::InvalidHeader(point.clone(), e))
}

#[derive(Clone)]
#[derive(Clone, serde::Serialize, serde::Deserialize)]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is polluted because it is used a “state” in a pure stage, which is of course not how it is supposed to be.

pub struct ValidateHeader {
#[serde(skip, default = "default_ledger")]
pub ledger: Arc<dyn HasStakeDistribution>,
#[serde(skip, default = "default_store")]
pub store: Arc<Mutex<dyn ChainStore<Header>>>,
}

impl PartialEq for ValidateHeader {
fn eq(&self, _other: &Self) -> bool {
true
}
}
Comment on lines +67 to +71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Equality implementation always returns true – steer clear of accidental foot-guns

PartialEq that unconditionally yields true can mask logic errors and derail HashMap/Set behaviour faster than a choc-top on a summer day.
Consider either removing PartialEq or providing a meaningful comparison of the members you actually care about.

🤖 Prompt for AI Agents
In crates/amaru-consensus/src/consensus/validate_header.rs around lines 67 to
71, the PartialEq implementation for ValidateHeader always returns true, which
can cause incorrect equality checks and break collections relying on it. Fix
this by either removing the PartialEq implementation if not needed or
implementing eq to compare the relevant fields of ValidateHeader that determine
equality meaningfully.


fn default_ledger() -> Arc<dyn HasStakeDistribution> {
struct Fake;
impl HasStakeDistribution for Fake {
fn get_pool(
&self,
_slot: amaru_kernel::Slot,
_pool: &amaru_kernel::PoolId,
) -> Option<amaru_ouroboros::PoolSummary> {
unimplemented!()
}

fn slot_to_kes_period(&self, _slot: amaru_kernel::Slot) -> u64 {
unimplemented!()
}

fn max_kes_evolutions(&self) -> u64 {
unimplemented!()
}

fn latest_opcert_sequence_number(&self, _pool: &amaru_kernel::PoolId) -> Option<u64> {
unimplemented!()
}
}
Arc::new(Fake)
}
Comment on lines +73 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

default_ledger panics on every method – supply a harmless stub instead

These unimplemented!() calls will blow up at replay time if any code path touches the fake ledger.
Returning sensible fall-backs (e.g. None, 0) keeps replays crash-free while still signalling “not a real ledger”.

🤖 Prompt for AI Agents
In crates/amaru-consensus/src/consensus/validate_header.rs between lines 73 and
97, the default_ledger function's Fake struct methods use unimplemented!() which
causes panics if called. Replace these with harmless stub implementations that
return safe default values like None for Option returns and 0 for u64 returns to
avoid crashes during replay while indicating this is a non-functional stub.


impl fmt::Debug for ValidateHeader {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ValidateHeader")
Expand All @@ -71,12 +105,24 @@ impl fmt::Debug for ValidateHeader {
}
}

#[derive(serde::Serialize, serde::Deserialize)]
struct EvolveNonceEffect {
#[serde(skip, default = "default_store")]
store: Arc<Mutex<dyn ChainStore<Header>>>,
header: Header,
global_parameters: GlobalParameters,
}

impl PartialEq for EvolveNonceEffect {
fn eq(&self, other: &Self) -> bool {
self.header == other.header && self.global_parameters == other.global_parameters
}
}

fn default_store() -> Arc<Mutex<dyn ChainStore<Header>>> {
Arc::new(Mutex::new(super::store::FakeStore::default()))
}

impl EvolveNonceEffect {
fn new(
store: Arc<Mutex<dyn ChainStore<Header>>>,
Expand All @@ -101,25 +147,16 @@ impl fmt::Debug for EvolveNonceEffect {
}

impl ExternalEffect for EvolveNonceEffect {
fn run(self: Box<Self>) -> pure_stage::BoxFuture<'static, Box<dyn pure_stage::Message>> {
fn run(self: Box<Self>) -> pure_stage::BoxFuture<'static, Box<dyn pure_stage::SendData>> {
Box::pin(async move {
let result = self
.store
.lock()
.await
.evolve_nonce(&self.header, &self.global_parameters);
Box::new(result) as Box<dyn pure_stage::Message>
Box::new(result) as Box<dyn pure_stage::SendData>
})
}

fn test_eq(&self, other: &dyn ExternalEffect) -> bool {
other
.cast_ref::<Self>()
.map(|other| {
self.header == other.header && self.global_parameters == other.global_parameters
})
.unwrap_or(false)
}
}

impl ExternalEffectAPI for EvolveNonceEffect {
Expand Down
4 changes: 3 additions & 1 deletion crates/amaru-consensus/src/peer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
use std::fmt::Display;

/// A single peer in the network, with a unique identifier.
#[derive(Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)]
#[derive(
Debug, PartialEq, Eq, Hash, Clone, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct Peer {
pub name: String,
}
Expand Down
2 changes: 1 addition & 1 deletion crates/amaru-kernel/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ impl From<&RequiredScript> for RedeemersKey {
}
}

#[derive(Clone, Eq, PartialEq, Hash, Debug)]
#[derive(Clone, Eq, PartialEq, Hash, Debug, serde::Serialize, serde::Deserialize)]
pub enum Point {
Origin,
Specific(u64, Vec<u8>),
Expand Down
2 changes: 1 addition & 1 deletion crates/amaru-kernel/src/protocol_parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ impl<C> cbor::encode::Encode<C> for ProtocolParameters {
}
}

#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct GlobalParameters {
/// The maximum depth of a rollback, also known as the security parameter 'k'.
/// This translates down to the length of our volatile storage, containing states of the ledger
Expand Down
4 changes: 2 additions & 2 deletions crates/amaru/src/stages/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@ impl<S: Store + Send, HS: HistoricalStores + Send> ValidateBlockStage<S, HS> {
let mut context = self.create_validation_context(&block)?;
let protocol_version = block.header.header_body.protocol_version;
match rules::validate_block(&mut context, self.state.protocol_parameters(), &block) {
BlockValidation::Err(err) => return Err(err),
BlockValidation::Err(err) => Err(err),
BlockValidation::Invalid(err) => {
error!("Block invalid: {}", err);
return Ok(Some(err));
Ok(Some(err))
}
BlockValidation::Valid(()) => {
let state: VolatileState = context.into();
Expand Down
1 change: 1 addition & 0 deletions crates/ouroboros-traits/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ rust-version.workspace = true

[dependencies]
amaru-kernel.workspace = true
serde.workspace = true
slot-arithmetic.workspace = true
2 changes: 1 addition & 1 deletion crates/ouroboros-traits/src/praos/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ use crate::is_header::IsHeader;
use amaru_kernel::{cbor, protocol_parameters::GlobalParameters, Hash, Nonce};
use slot_arithmetic::Epoch;

#[derive(Debug, PartialEq, Clone)]
#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
pub struct Nonces {
pub active: Nonce,
pub evolving: Nonce,
Expand Down
Loading