diff --git a/crates/cardano/src/eras.rs b/crates/cardano/src/eras.rs index b5d4bd2e..c349b9dc 100644 --- a/crates/cardano/src/eras.rs +++ b/crates/cardano/src/eras.rs @@ -212,6 +212,22 @@ impl ChainSummary { } 0 } + + /// Epoch at which the chain entered Conway — the first era with + /// protocol >= 9 (a hard fork can jump over 9, mirroring + /// `EraTransition::entering_conway`). `None` when no known era has + /// reached Conway. + pub fn first_conway_epoch(&self) -> Option { + for (protocol, era) in self.iter_past_with_protocol() { + if *protocol >= 9 { + return Some(era.start.epoch); + } + } + match self.protocols.last() { + Some(last) if *last >= 9 => Some(self.edge().start.epoch), + _ => None, + } + } } pub fn load_era_summary(state: &D::State) -> Result { @@ -311,4 +327,44 @@ mod tests { assert_eq!(sc.zero_slot, 4_492_800); assert_eq!(sc.zero_time, 1_596_059_091_000); } + + fn era(protocol: u16, start_epoch: u64) -> EraSummary { + EraSummary { + start: EraBoundary { + epoch: start_epoch, + slot: start_epoch * 100, + timestamp: 0, + }, + end: None, + epoch_length: 100, + slot_length: 1, + protocol, + } + } + + #[test] + fn first_conway_epoch_finds_conway_entry() { + // empty summary + assert_eq!(ChainSummary::default().first_conway_epoch(), None); + + // no era reached Conway + let mut summary = ChainSummary::default(); + summary.append_era(2, era(2, 0)); + summary.append_era(8, era(8, 300)); + assert_eq!(summary.first_conway_epoch(), None); + + // Conway at the edge + summary.append_era(9, era(9, 507)); + assert_eq!(summary.first_conway_epoch(), Some(507)); + + // Conway in the past with a later intra-Conway edge + summary.append_era(10, era(10, 600)); + assert_eq!(summary.first_conway_epoch(), Some(507)); + + // a fork that jumps over protocol 9 still enters Conway + let mut jumped = ChainSummary::default(); + jumped.append_era(8, era(8, 300)); + jumped.append_era(10, era(10, 480)); + assert_eq!(jumped.first_conway_epoch(), Some(480)); + } } diff --git a/crates/cardano/src/estart/commit.rs b/crates/cardano/src/estart/commit.rs index f2f0e91f..36ae6b8d 100644 --- a/crates/cardano/src/estart/commit.rs +++ b/crates/cardano/src/estart/commit.rs @@ -17,7 +17,7 @@ use tracing::{debug, instrument, trace, warn}; use crate::{ forks, AccountState, CardanoEntity, DRepState, EpochState, EraSummary, FixedNamespace, - PoolState, ProposalState, + GovState, PoolState, ProposalState, }; /// Era transition data collected from state. @@ -142,8 +142,9 @@ impl super::WorkContext { self.stream_and_apply_namespace::(state, &writer, Some(range))?; } - // EpochState gets the EStartProgress delta (single entity). - self.stream_and_apply_namespace::(state, &writer, None)?; + // EpochState gets the EStartProgress delta. + self.deltas + .apply_singleton::(state, &writer)?; // Archive logs — share the start-of-epoch temporal key across shards. let start_of_epoch = self.chain_summary.epoch_start(self.starting_epoch_no()); @@ -222,8 +223,13 @@ impl super::WorkContext { debug!("streaming proposal entities"); self.stream_and_apply_namespace::(state, &writer, None)?; - debug!("streaming epoch entities"); - self.stream_and_apply_namespace::(state, &writer, None)?; + debug!("applying singleton deltas"); + self.deltas + .apply_singleton::(state, &writer)?; + + // Gov isn't streamed by namespace; its boundary deltas (e.g. the + // Conway-boundary `GovGenesisInit`) go through the singleton path. + self.deltas.apply_singleton::(state, &writer)?; // Write era transition if needed (only 2 entities) if let Some(transition) = era_transition { @@ -258,3 +264,94 @@ impl super::WorkContext { Ok(()) } } + +#[cfg(test)] +mod tests { + use dolos_core::{Domain as _, StateStore as _}; + use dolos_testing::toy_domain::ToyDomain; + + use crate::{ + gov_from_conway_genesis, ChainSummary, EraProtocol, GovGenesisInit, SingletonEntity as _, + }; + + use super::*; + + fn empty_context(domain: &ToyDomain) -> super::super::WorkContext { + super::super::WorkContext { + ended_state: Default::default(), + active_protocol: EraProtocol::from(9), + chain_summary: ChainSummary::default(), + genesis: domain.genesis(), + avvm_reclamation: 0, + deltas: Default::default(), + logs: Default::default(), + } + } + + fn read_gov(domain: &ToyDomain) -> Option { + domain + .state() + .read_entity_typed::(GovState::NS, &GovState::singleton_key()) + .unwrap() + } + + /// `GovGenesisInit` applied through the singleton path activates the + /// existing (inactive) row with the genesis enact-state. + #[test] + fn gov_genesis_init_activates_existing_singleton() { + let domain = ToyDomain::new(None, None); + let state = domain.state(); + + // the devnet bootstrap activates the row; reset it to the + // inactive state a chain crossing Chang would carry + let writer = state.start_writer().unwrap(); + writer + .write_entity_typed(&GovState::singleton_key(), &GovState::default()) + .unwrap(); + writer.commit().unwrap(); + assert_eq!(read_gov(&domain), Some(GovState::default())); + + let (constitution, committee) = gov_from_conway_genesis(&domain.genesis().conway).unwrap(); + + let mut ctx = empty_context(&domain); + ctx.add_delta(GovGenesisInit::new( + constitution.clone(), + committee.clone(), + 507, + )); + + let writer = state.start_writer().unwrap(); + ctx.deltas + .apply_singleton::(state, &writer) + .unwrap(); + writer.commit().unwrap(); + + let gov = read_gov(&domain).expect("singleton exists"); + assert_eq!(gov.constitution, Some(constitution)); + assert_eq!(gov.committee, Some(committee)); + assert_eq!(gov.active_since, Some(507)); + + // the queue entry was drained — no double apply possible + assert!(ctx.deltas.entities.is_empty()); + } + + /// With no queued gov deltas the pass is a no-op that leaves the + /// existing entity untouched. + #[test] + fn gov_apply_without_deltas_is_noop() { + let domain = ToyDomain::new(None, None); + let state = domain.state(); + + let before = read_gov(&domain).expect("devnet bootstrap seeds the entity"); + + let mut ctx = empty_context(&domain); + + let writer = state.start_writer().unwrap(); + ctx.deltas + .apply_singleton::(state, &writer) + .unwrap(); + writer.commit().unwrap(); + + assert_eq!(read_gov(&domain), Some(before)); + } +} diff --git a/crates/cardano/src/estart/loading.rs b/crates/cardano/src/estart/loading.rs index f4be6a99..a5cd1530 100644 --- a/crates/cardano/src/estart/loading.rs +++ b/crates/cardano/src/estart/loading.rs @@ -14,10 +14,10 @@ use dolos_core::{ChainError, Domain, EntityKey, Genesis, StateStore, TxoRef}; use crate::{ estart::{BoundaryVisitor as _, WorkContext}, - load_era_summary, + gov_from_conway_genesis, load_era_summary, roll::WorkDeltas, - AccountState, DRepState, EStartProgress, EraProtocol, FixedNamespace as _, PoolState, - ProposalState, + AccountState, DRepState, EStartProgress, EraProtocol, FixedNamespace as _, GovGenesisInit, + PoolState, ProposalState, }; impl WorkContext { @@ -67,6 +67,23 @@ impl WorkContext { // all per-entity transitions have been queued. super::reset::emit_epoch_transition(self); + // Entering Conway (the Chang hard fork) activates the governance + // singleton with the genesis constitution + committee — the initial + // enact-state every later governance action evolves from. The row + // itself exists on every store (bootstrap / migration invariant); + // stores upgraded in place past the boundary never see this event, + // so their enact-state stays unset until a fresh sync. + if let Some(transition) = self.ended_state().pparams.era_transition() { + if transition.entering_conway() { + let (constitution, committee) = gov_from_conway_genesis(&self.genesis.conway)?; + self.add_delta(GovGenesisInit::new( + constitution, + committee, + self.starting_epoch_no(), + )); + } + } + Ok(()) } diff --git a/crates/cardano/src/ewrap/commit.rs b/crates/cardano/src/ewrap/commit.rs index 8012d86d..e0ff5c35 100644 --- a/crates/cardano/src/ewrap/commit.rs +++ b/crates/cardano/src/ewrap/commit.rs @@ -62,49 +62,6 @@ impl BoundaryWork { Ok(()) } - /// EpochState-specific variant of `stream_and_apply_namespace` that - /// returns the post-apply singleton so callers can refresh - /// `self.ending_state` (and pass the finalised state to archive - /// writes that would otherwise carry the stale pre-commit snapshot). - fn apply_epoch_state_deltas( - &mut self, - state: &D::State, - writer: &::Writer, - ) -> Result, ChainError> - where - D: Domain, - { - let records = state.iter_entities_typed::(EpochState::NS, None)?; - let mut applied: Option = None; - - for record in records { - let (entity_id, entity) = record?; - - let to_apply = self - .deltas - .entities - .remove(&NsKey::from((EpochState::NS, entity_id.clone()))); - - if let Some(to_apply) = to_apply { - let mut value: Option = Some(entity.into()); - - for mut delta in to_apply { - delta.apply(&mut value); - } - - writer.save_entity_typed(EpochState::NS, &entity_id, value.as_ref())?; - - if let Some(CardanoEntity::EpochState(boxed)) = value { - applied = Some(*boxed); - } - } else { - trace!(ns = EpochState::NS, key = %entity_id, "no deltas for entity"); - } - } - - Ok(applied) - } - /// Commit a single per-shard run: apply per-account deltas (rewards + /// drops) and the `EWrapProgress` delta against `EpochState`, /// flush archive logs (`{Leader,Member}RewardLog`), and delete applied @@ -129,8 +86,9 @@ impl BoundaryWork { self.stream_and_apply_namespace::(state, &writer, Some(range))?; } - // EpochState gets the EWrapProgress delta (single entity). - self.stream_and_apply_namespace::(state, &writer, None)?; + // EpochState gets the EWrapProgress delta. + self.deltas + .apply_singleton::(state, &writer)?; // Delete applied pending rewards. debug!( @@ -215,7 +173,10 @@ impl BoundaryWork { // Capture the post-apply state so the archive write below sees // the finalised EpochState rather than the pre-commit snapshot // still cached on `self.ending_state`. - if let Some(applied) = self.apply_epoch_state_deltas::(state, &writer)? { + if let Some(applied) = self + .deltas + .apply_singleton::(state, &writer)? + { self.ending_state = applied; } diff --git a/crates/cardano/src/ewrap/loading.rs b/crates/cardano/src/ewrap/loading.rs index 38a9995e..8aa87e11 100644 --- a/crates/cardano/src/ewrap/loading.rs +++ b/crates/cardano/src/ewrap/loading.rs @@ -16,7 +16,7 @@ use pallas::ledger::primitives::StakeCredential; use crate::{ ewrap::{BoundaryVisitor as _, BoundaryWork}, - load_era_summary, pallas_extras, + load_era_summary, load_gov, pallas_extras, rewards::{Reward, RewardMap}, roll::WorkDeltas, rupd::credential_to_key, @@ -37,6 +37,8 @@ impl BoundaryWork { let active_protocol = EraProtocol::from(chain_summary.edge().protocol); let incentives = ending_state.incentives.clone().unwrap_or_default(); + let num_dormant_epochs = load_gov::(state)?.num_dormant_epochs; + Ok(BoundaryWork { ending_state, chain_summary, @@ -48,6 +50,7 @@ impl BoundaryWork { expiring_dreps: Default::default(), retiring_dreps: Default::default(), reregistrating_dreps: Default::default(), + num_dormant_epochs, enacting_proposals: Default::default(), dropping_proposals: Default::default(), deltas: WorkDeltas::default(), @@ -266,6 +269,19 @@ impl BoundaryWork { return Ok(false); } + // Epoch-based expiry, Haskell-style: the stored value carries no + // dormancy credit, so add the counter back. A DRep is active while + // `epoch <= expiry + dormant`, i.e. it expires entering the first + // epoch strictly greater. + if let Some(expiry) = &drep.expiry { + let actual = expiry.current + self.num_dormant_epochs; + + return Ok(actual < self.starting_epoch_no()); + } + + // Legacy fallback for rows written before the epoch-based expiry + // field existed: the old slot-arithmetic heuristic. Self-heals as + // activity repopulates the field. let last_activity_slot = drep .last_active_slot .unwrap_or(drep.registered_at.map(|x| x.0).unwrap_or_default()); diff --git a/crates/cardano/src/ewrap/mod.rs b/crates/cardano/src/ewrap/mod.rs index d244ac31..d8b0ad25 100644 --- a/crates/cardano/src/ewrap/mod.rs +++ b/crates/cardano/src/ewrap/mod.rs @@ -154,6 +154,11 @@ pub struct BoundaryWork { pub retiring_dreps: Vec, pub reregistrating_dreps: Vec<(DRep, (BlockSlot, TxOrder))>, + /// `GovState::num_dormant_epochs` at the boundary. Stored DRep expiries + /// carry no dormancy credit, so the expiry check adds the counter back + /// (`actual = stored + dormant`, Haskell-style). + pub num_dormant_epochs: u64, + // computed via visitors pub deltas: WorkDeltas, pub logs: Vec<(EntityKey, CardanoEntity)>, diff --git a/crates/cardano/src/genesis/mod.rs b/crates/cardano/src/genesis/mod.rs index 0f93715c..e2f967a0 100644 --- a/crates/cardano/src/genesis/mod.rs +++ b/crates/cardano/src/genesis/mod.rs @@ -4,9 +4,9 @@ use dolos_core::{ }; use crate::{ - indexes::index_delta_from_utxo_delta, pots::Pots, utils::nonce_stability_window, EndStats, - EpochState, EpochValue, EraBoundary, EraSummary, Lovelace, Nonces, PParamsSet, RollingStats, - CURRENT_EPOCH_KEY, + gov_from_conway_genesis, indexes::index_delta_from_utxo_delta, pots::Pots, + utils::nonce_stability_window, EndStats, EpochState, EpochValue, EraBoundary, EraSummary, + GovState, Lovelace, Nonces, PParamsSet, RollingStats, SingletonEntity as _, }; mod staking; @@ -89,7 +89,7 @@ pub fn bootstrap_epoch( }; let writer = state.start_writer()?; - writer.write_entity_typed(&EntityKey::from(CURRENT_EPOCH_KEY), &epoch)?; + writer.write_entity_typed(&EpochState::singleton_key(), &epoch)?; writer.commit()?; Ok(epoch) @@ -124,6 +124,26 @@ pub fn bootstrap_eras(state: &D::State, epoch: &EpochState) -> Result Ok(()) } +/// Create the governance singleton. Its existence is an invariant that +/// starts here: every store carries the row regardless of era. Networks +/// that force-start at Conway (protocol >= 9, e.g. devnets) get it +/// activated with the genesis enact-state; everyone else gets the +/// inactive row, activated later at the Chang boundary (`GovGenesisInit`). +pub fn bootstrap_gov(state: &D::State, genesis: &Genesis) -> Result<(), ChainError> { + let mut gov = GovState::default(); + + if genesis.force_protocol.is_some_and(|protocol| protocol >= 9) { + let (constitution, committee) = gov_from_conway_genesis(&genesis.conway)?; + gov.seed_genesis(constitution, committee, 0); + } + + let writer = state.start_writer()?; + writer.write_entity_typed(&GovState::singleton_key(), &gov)?; + writer.commit()?; + + Ok(()) +} + pub fn bootstrap_utxos( state: &D::State, indexes: &D::Indexes, @@ -163,6 +183,8 @@ pub fn execute( bootstrap_eras::(state, &epoch)?; + bootstrap_gov::(state, genesis)?; + bootstrap_utxos::(state, indexes, genesis, config)?; staking::bootstrap::(state, genesis)?; @@ -178,3 +200,49 @@ pub fn execute( Ok(()) } + +#[cfg(test)] +mod tests { + use dolos_core::{Domain as _, StateStore as _}; + use dolos_testing::toy_domain::ToyDomain; + use std::sync::Arc; + + use crate::{FixedNamespace as _, GovState}; + + use super::*; + + fn read_gov(domain: &ToyDomain) -> Option { + domain + .state() + .read_entity_typed::(GovState::NS, &GovState::singleton_key()) + .unwrap() + } + + #[test] + fn bootstrap_seeds_gov_singleton_for_forced_conway() { + // the devnet genesis force-starts at protocol 9 + let domain = ToyDomain::new(None, None); + + let (constitution, committee) = gov_from_conway_genesis(&domain.genesis().conway).unwrap(); + + let gov = read_gov(&domain).expect("gov singleton seeded at bootstrap"); + + assert_eq!(gov.constitution, Some(constitution)); + assert_eq!(gov.committee, Some(committee)); + assert_eq!(gov.num_dormant_epochs, 0); + assert_eq!(gov.active_since, Some(0)); + } + + #[test] + fn bootstrap_seeds_inactive_gov_singleton_before_conway() { + let mut genesis = crate::include::devnet::load(); + genesis.force_protocol = Some(8); + + let domain = ToyDomain::new_with_genesis(Arc::new(genesis), None, None); + + // the row exists on every store — governance just isn't active yet + let gov = read_gov(&domain).expect("gov singleton exists pre-Conway"); + assert_eq!(gov, GovState::default()); + assert_eq!(gov.active_since, None); + } +} diff --git a/crates/cardano/src/lib.rs b/crates/cardano/src/lib.rs index 225eab46..dfdbd33d 100644 --- a/crates/cardano/src/lib.rs +++ b/crates/cardano/src/lib.rs @@ -6,9 +6,9 @@ use tracing::info; pub use pallas; use dolos_core::{ - config::CardanoConfig, BlockSlot, ChainError, ChainPoint, Domain, DomainError, EntityKey, - EraCbor, Genesis, MempoolAwareUtxoStore, MempoolTx, MempoolUpdate, RawBlock, StateStore, - TipEvent, WorkUnit, + config::CardanoConfig, BlockSlot, ChainError, ChainPoint, Domain, DomainError, EraCbor, + Genesis, MempoolAwareUtxoStore, MempoolTx, MempoolUpdate, RawBlock, StateStore, TipEvent, + WorkUnit, }; use crate::{ @@ -41,6 +41,7 @@ pub mod utxoset; pub mod estart; pub mod ewrap; pub mod genesis; +mod migrate; pub mod roll; pub mod rupd; mod work; @@ -321,6 +322,11 @@ impl dolos_core::ChainLogic for CardanoLogic { ) -> Result { info!("initializing"); + // One-time self-heal migrations. Must run before anything reads + // governance state and before WAL catch-up replays deltas that + // expect the singleton to exist. + migrate::ensure_gov_singleton::(state)?; + let cursor = state.read_cursor()?; let work = match cursor { @@ -575,11 +581,22 @@ pub fn load_effective_pparams(state: &D::State) -> Result(state: &D::State) -> Result { - let epoch = state - .read_entity_typed::(EpochState::NS, &EntityKey::from(CURRENT_EPOCH_KEY))? - .ok_or(ChainError::NoActiveEpoch)?; + read_singleton::(state)?.ok_or(ChainError::NoActiveEpoch) +} + +/// Load the governance singleton, whose existence is an invariant +/// (seeded at bootstrap or by the CARDANO-006 startup migration). +pub fn load_gov(state: &D::State) -> Result { + read_singleton::(state)?.ok_or(ChainError::MissingGovState) +} - Ok(epoch) +/// Read a singleton entity directly at its fixed key. `None` means the +/// row was never created — for the known singletons that's a broken or +/// pre-migration store, which the `load_*` wrappers turn into errors. +pub fn read_singleton( + state: &D::State, +) -> Result, ChainError> { + Ok(state.read_entity_typed::(E::NS, &E::singleton_key())?) } #[cfg(test)] diff --git a/crates/cardano/src/migrate.rs b/crates/cardano/src/migrate.rs new file mode 100644 index 00000000..760e5ecd --- /dev/null +++ b/crates/cardano/src/migrate.rs @@ -0,0 +1,136 @@ +//! One-time state migrations applied at node initialization. +//! +//! These self-heal stores bootstrapped before an invariant existed, the +//! same way `check_wal_in_sync_with_state` self-heals an empty WAL. Each +//! migration is idempotent and keyed on the data shape itself (no store +//! version stamp exists), so running them on every startup is a no-op +//! after the first time. + +use dolos_core::{ChainError, Domain, StateStore as _, StateWriter as _}; +use tracing::warn; + +use crate::{load_era_summary, read_singleton, GovState, SingletonEntity as _}; + +/// CARDANO-006: create the governance singleton on stores bootstrapped +/// before its existence invariant (genesis now seeds the row; older +/// stores and published snapshots miss it). +/// +/// The enact-state fields stay unset — for a store already past the +/// Chang boundary the constitution, committee and cert history since the +/// fork were never recorded, and only a fresh sync recovers them. Only +/// `active_since` is derived, from the era summary. +/// +/// Must run before WAL catch-up: replayed governance deltas expect the +/// row to exist. No-op on fresh stores (no cursor yet — genesis +/// bootstrap seeds the row) and on stores that already carry the row. +/// Returns whether a migration write happened. +pub(crate) fn ensure_gov_singleton(state: &D::State) -> Result { + if state.read_cursor()?.is_none() { + return Ok(false); + } + + if read_singleton::(state)?.is_some() { + return Ok(false); + } + + let summary = load_era_summary::(state)?; + + let gov = GovState { + active_since: summary.first_conway_epoch(), + ..Default::default() + }; + + warn!( + active_since = ?gov.active_since, + "CARDANO-006: governance singleton missing on bootstrapped store; creating it (enact-state unknown)" + ); + + let writer = state.start_writer()?; + writer.write_entity_typed(&GovState::singleton_key(), &gov)?; + writer.commit()?; + + Ok(true) +} + +#[cfg(test)] +mod tests { + use dolos_core::{Domain as _, StateStore as _}; + use dolos_testing::toy_domain::ToyDomain; + use std::sync::Arc; + + use crate::FixedNamespace as _; + + use super::*; + + fn read_gov(domain: &ToyDomain) -> Option { + domain + .state() + .read_entity_typed::(GovState::NS, &GovState::singleton_key()) + .unwrap() + } + + fn delete_gov(domain: &ToyDomain) { + let writer = domain.state().start_writer().unwrap(); + writer + .delete_entity(GovState::NS, &GovState::singleton_key()) + .unwrap(); + writer.commit().unwrap(); + } + + #[test] + fn migration_recreates_missing_row_on_conway_store() { + // devnet force-starts at protocol 9, so its era summary is Conway + // from epoch 0 + let domain = ToyDomain::new(None, None); + delete_gov(&domain); + + let migrated = ensure_gov_singleton::(domain.state()).unwrap(); + + assert!(migrated); + let gov = read_gov(&domain).expect("migration created the row"); + assert_eq!(gov.active_since, Some(0)); + assert_eq!(gov.constitution, None); + assert_eq!(gov.committee, None); + } + + #[test] + fn migration_creates_inactive_row_on_pre_conway_store() { + let mut genesis = crate::include::devnet::load(); + genesis.force_protocol = Some(8); + let domain = ToyDomain::new_with_genesis(Arc::new(genesis), None, None); + delete_gov(&domain); + + let migrated = ensure_gov_singleton::(domain.state()).unwrap(); + + assert!(migrated); + let gov = read_gov(&domain).expect("migration created the row"); + assert_eq!(gov, GovState::default()); + } + + #[test] + fn migration_is_noop_when_row_exists() { + let domain = ToyDomain::new(None, None); + let before = read_gov(&domain).expect("bootstrap seeds the row"); + + let migrated = ensure_gov_singleton::(domain.state()).unwrap(); + + assert!(!migrated); + assert_eq!(read_gov(&domain), Some(before)); + } + + #[test] + fn migration_is_noop_before_genesis() { + // a store with no cursor: genesis hasn't run, bootstrap will seed + let state = dolos_testing::toy_domain::empty_state_store(); + + let migrated = ensure_gov_singleton::(&state).unwrap(); + + assert!(!migrated); + assert_eq!( + state + .read_entity_typed::(GovState::NS, &GovState::singleton_key()) + .unwrap(), + None + ); + } +} diff --git a/crates/cardano/src/model/dreps.rs b/crates/cardano/src/model/dreps.rs index a7085c3e..42d82e3e 100644 --- a/crates/cardano/src/model/dreps.rs +++ b/crates/cardano/src/model/dreps.rs @@ -1,7 +1,10 @@ use dolos_core::{BlockSlot, EntityKey, NsKey, TxOrder}; use pallas::{ codec::minicbor::{self, Decode, Encode}, - ledger::primitives::conway::{Anchor, DRep}, + ledger::primitives::{ + conway::{Anchor, DRep}, + Epoch, + }, }; use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; @@ -21,6 +24,64 @@ pub fn drep_to_entity_key(value: &DRep) -> EntityKey { EntityKey::from(bytes) } +/// Epoch-based DRep expiry, stored exactly as the Haskell ledger stores +/// `drepExpiry`: **without** dormant-epoch credit. The actual expiry is +/// `current + GovState::num_dormant_epochs`; the counter is folded into the +/// stored value whenever a proposal ends a dormant stretch +/// ([`DRepDormancyRelease`]). +/// +/// The `(updated_in, prev)` pair makes the value snapshot-safe: epoch +/// boundary tallies read the value as of the end of the previous epoch even +/// if activity during the closing epoch refreshed it. `prev` holds the value +/// that was in effect before the first write of epoch `updated_in`, which is +/// sufficient because reads never look further back than one epoch. +#[derive(Debug, Encode, Decode, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct DRepExpiry { + /// Expiry epoch (inclusive), without dormancy credit. + #[n(0)] + pub current: Epoch, + + /// The epoch during which `current` was last written. + #[n(1)] + pub updated_in: Epoch, + + /// The value in effect at the end of every epoch before `updated_in`. + #[n(2)] + pub prev: Option, +} + +impl DRepExpiry { + pub fn new(value: Epoch, epoch: Epoch) -> Self { + Self { + current: value, + updated_in: epoch, + prev: None, + } + } + + /// Write `value` during `epoch`, rotating the previous value out when + /// this is the first write of the epoch. + pub fn set(&mut self, value: Epoch, epoch: Epoch) { + if self.updated_in != epoch { + self.prev = Some(self.current); + self.updated_in = epoch; + } + + self.current = value; + } + + /// The value as of the end of `epoch`. `None` means the value did not + /// exist yet (reads older than the retained window also land here, but + /// boundary tallies only ever look one epoch back). + pub fn as_of(&self, epoch: Epoch) -> Option { + if self.updated_in <= epoch { + Some(self.current) + } else { + self.prev + } + } +} + #[derive(Debug, Encode, Decode, Clone, PartialEq, Eq)] pub struct DRepState { #[n(0)] @@ -48,6 +109,13 @@ pub struct DRepState { // `None`. Index 7 must not be reused for anything else. #[n(7)] pub anchor: Option, + + // Backward-compatible addition: absent in pre-existing rows, decodes as + // `None` (those rows keep the legacy slot-arithmetic expiry heuristic + // until activity repopulates the field). Index 8 must not be reused for + // anything else. + #[n(8)] + pub expiry: Option, } impl DRepState { @@ -61,6 +129,7 @@ impl DRepState { deposit: 0, identifier, anchor: None, + expiry: None, } } @@ -88,6 +157,16 @@ pub(crate) mod testing { use crate::model::testing as root; use proptest::prelude::*; + prop_compose! { + pub fn any_drep_expiry()( + current in root::any_epoch(), + updated_in in root::any_epoch(), + prev in prop::option::of(root::any_epoch()), + ) -> DRepExpiry { + DRepExpiry { current, updated_in, prev } + } + } + prop_compose! { pub fn any_drep_state()( identifier in root::any_drep(), @@ -98,6 +177,7 @@ pub(crate) mod testing { expired in any::(), deposit in root::any_lovelace(), anchor in prop::option::of(root::any_anchor()), + expiry in prop::option::of(any_drep_expiry()), ) -> DRepState { DRepState { identifier, @@ -108,6 +188,7 @@ pub(crate) mod testing { expired, deposit, anchor, + expiry, } } } @@ -384,6 +465,179 @@ impl dolos_core::EntityDelta for DRepExpiration { } } +/// Refresh a DRep's stored expiry epoch. +/// +/// Emitted for `RegDRepCert` and `UpdateDRepCert` (`only_if_registered = +/// false`) and for every DRep vote (`only_if_registered = true`, mirroring +/// the Haskell `Map.adjust`, which only touches registered DReps). The +/// visitor computes the value — `current_epoch + drepActivity − +/// numDormantEpochs`, with the PV9 bootstrap exception on registration — so +/// the delta carries concrete data and replays exactly. +/// +/// Also clears the `expired` flag: expiry in the ledger is implicit, so any +/// refresh makes the DRep active again. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DRepExpiryUpdate { + pub(crate) drep: DRep, + pub(crate) expiry: Epoch, + pub(crate) epoch: Epoch, + pub(crate) only_if_registered: bool, + + // undo + pub(crate) applied: bool, + pub(crate) was_new: bool, + pub(crate) prev_expiry: Option, + pub(crate) prev_expired: bool, +} + +impl DRepExpiryUpdate { + pub fn new(drep: DRep, expiry: Epoch, epoch: Epoch, only_if_registered: bool) -> Self { + Self { + drep, + expiry, + epoch, + only_if_registered, + applied: false, + was_new: false, + prev_expiry: None, + prev_expired: false, + } + } +} + +impl dolos_core::EntityDelta for DRepExpiryUpdate { + type Entity = DRepState; + + fn key(&self) -> NsKey { + NsKey::from((DRepState::NS, drep_to_entity_key(&self.drep))) + } + + fn apply(&mut self, entity: &mut Option) { + if self.only_if_registered { + let registered = entity + .as_ref() + .is_some_and(|state| state.registered_at.is_some() && !state.is_unregistered()); + + if !registered { + self.applied = false; + self.was_new = false; + return; + } + } + + self.was_new = entity.is_none(); + + // Cert-driven updates (`only_if_registered = false`) are always + // queued after the same tx's `DRepRegistration`, which creates the + // entity; creation here is defensive only — a row born this way + // would carry no `registered_at`. + let entity = entity.get_or_insert_with(|| DRepState::new(self.drep.clone())); + + // save undo info + self.prev_expiry = entity.expiry; + self.prev_expired = entity.expired; + + // apply changes + match entity.expiry.as_mut() { + Some(expiry) => expiry.set(self.expiry, self.epoch), + None => entity.expiry = Some(DRepExpiry::new(self.expiry, self.epoch)), + } + + entity.expired = false; + self.applied = true; + } + + fn undo(&self, entity: &mut Option) { + if !self.applied { + return; + } + + if self.was_new { + *entity = None; + } else if let Some(state) = entity { + state.expiry = self.prev_expiry; + state.expired = self.prev_expired; + } + } +} + +/// Fold the dormant-epoch counter into one DRep's stored expiry +/// (research §3.3.1). Emitted as a per-DRep fan-out — together with a +/// single [`crate::GovDormancyReset`] — when a transaction carries the +/// first proposal after a dormant stretch. +/// +/// Long-expired DReps (`current + dormant < epoch`) are not resurrected: +/// their stored value stays untouched, exactly as in the Haskell +/// `updateDormantDRepExpiry`. Rows without the epoch-based expiry field +/// (pre-upgrade) are skipped. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DRepDormancyRelease { + pub(crate) drep_id: EntityKey, + pub(crate) dormant_epochs: u64, + pub(crate) epoch: Epoch, + + // undo + pub(crate) applied: bool, + pub(crate) prev_expiry: Option, +} + +impl DRepDormancyRelease { + pub fn new(drep_id: EntityKey, dormant_epochs: u64, epoch: Epoch) -> Self { + Self { + drep_id, + dormant_epochs, + epoch, + applied: false, + prev_expiry: None, + } + } +} + +impl dolos_core::EntityDelta for DRepDormancyRelease { + type Entity = DRepState; + + fn key(&self) -> NsKey { + NsKey::from((DRepState::NS, self.drep_id.clone())) + } + + fn apply(&mut self, entity: &mut Option) { + self.applied = false; + + let Some(state) = entity.as_mut() else { + return; + }; + + if state.is_unregistered() { + return; + } + + let Some(expiry) = state.expiry.as_mut() else { + return; + }; + + let actual = expiry.current + self.dormant_epochs; + + if actual < self.epoch { + // long-expired: don't resurrect + return; + } + + self.prev_expiry = Some(*expiry); + expiry.set(actual, self.epoch); + self.applied = true; + } + + fn undo(&self, entity: &mut Option) { + if !self.applied { + return; + } + + if let Some(state) = entity { + state.expiry = self.prev_expiry; + } + } +} + #[cfg(test)] mod prop_tests { use super::testing::any_drep_state; @@ -439,6 +693,27 @@ mod prop_tests { } } + prop_compose! { + fn any_drep_expiry_update()( + drep in root::any_drep(), + expiry in root::any_epoch(), + epoch in root::any_epoch(), + only_if_registered in any::(), + ) -> DRepExpiryUpdate { + DRepExpiryUpdate::new(drep, expiry, epoch, only_if_registered) + } + } + + prop_compose! { + fn any_drep_dormancy_release()( + drep in root::any_drep(), + dormant_epochs in 1u64..32u64, + epoch in root::any_epoch(), + ) -> DRepDormancyRelease { + DRepDormancyRelease::new(drep_to_entity_key(&drep), dormant_epochs, epoch) + } + } + proptest! { #[test] fn drep_registration_roundtrip( @@ -487,5 +762,245 @@ mod prop_tests { ) { assert_delta_roundtrip(Some(entity), delta); } + + #[test] + fn drep_expiry_update_roundtrip( + entity in prop::option::of(any_drep_state()), + delta in any_drep_expiry_update(), + ) { + assert_delta_roundtrip(entity, delta); + } + + #[test] + fn drep_expiry_update_serde_roundtrip( + entity in prop::option::of(any_drep_state()), + delta in any_drep_expiry_update(), + ) { + root::assert_delta_serde_roundtrip(entity, delta); + } + + #[test] + fn drep_dormancy_release_roundtrip( + entity in prop::option::of(any_drep_state()), + delta in any_drep_dormancy_release(), + ) { + assert_delta_roundtrip(entity, delta); + } + + #[test] + fn drep_dormancy_release_serde_roundtrip( + entity in prop::option::of(any_drep_state()), + delta in any_drep_dormancy_release(), + ) { + root::assert_delta_serde_roundtrip(entity, delta); + } + } +} + +#[cfg(test)] +mod expiry_tests { + use super::*; + use dolos_core::EntityDelta as _; + + #[test] + fn expiry_set_rotates_once_per_epoch() { + let mut expiry = DRepExpiry::new(120, 100); + + // second write in the same epoch keeps the pre-epoch value out of prev + expiry.set(121, 100); + assert_eq!(expiry.as_of(100), Some(121)); + assert_eq!(expiry.as_of(99), None); + + // first write of a later epoch rotates + expiry.set(125, 105); + assert_eq!(expiry.as_of(105), Some(125)); + assert_eq!(expiry.as_of(104), Some(121)); + + // another write in the same epoch overwrites without rotating + expiry.set(126, 105); + assert_eq!(expiry.as_of(105), Some(126)); + assert_eq!(expiry.as_of(104), Some(121)); + } + + #[test] + fn vote_refresh_skips_unregistered_dreps() { + let drep = DRep::Key([1u8; 28].into()); + + // absent entity: a vote refresh must not create one + let mut entity: Option = None; + let mut delta = DRepExpiryUpdate::new(drep.clone(), 130, 110, true); + delta.apply(&mut entity); + assert!(entity.is_none()); + delta.undo(&mut entity); + assert!(entity.is_none()); + + // unregistered entity: same + let mut state = DRepState::new(drep.clone()); + state.registered_at = Some((100, 0)); + state.unregistered_at = Some((200, 0)); + let mut entity = Some(state.clone()); + let mut delta = DRepExpiryUpdate::new(drep.clone(), 130, 110, true); + delta.apply(&mut entity); + assert_eq!(entity.as_ref().unwrap().expiry, None); + + // cert-driven refresh applies regardless + let mut delta = DRepExpiryUpdate::new(drep.clone(), 130, 110, false); + delta.apply(&mut entity); + assert_eq!( + entity.as_ref().unwrap().expiry, + Some(DRepExpiry::new(130, 110)) + ); + delta.undo(&mut entity); + assert_eq!(entity.unwrap().expiry, None); + } + + #[test] + fn expiry_refresh_clears_expired_flag() { + let drep = DRep::Key([1u8; 28].into()); + + let mut state = DRepState::new(drep.clone()); + state.registered_at = Some((100, 0)); + state.expired = true; + state.expiry = Some(DRepExpiry::new(105, 85)); + + let mut entity = Some(state); + + let mut delta = DRepExpiryUpdate::new(drep, 130, 110, true); + delta.apply(&mut entity); + + let applied = entity.as_ref().unwrap(); + assert!(!applied.expired); + assert_eq!(applied.expiry.unwrap().current, 130); + assert_eq!(applied.expiry.unwrap().as_of(109), Some(105)); + + delta.undo(&mut entity); + let restored = entity.unwrap(); + assert!(restored.expired); + assert_eq!(restored.expiry, Some(DRepExpiry::new(105, 85))); + } + + #[test] + fn dormancy_release_folds_but_never_resurrects() { + let drep = DRep::Key([1u8; 28].into()); + let key = drep_to_entity_key(&drep); + + // active drep: counter folds into the stored value + let mut state = DRepState::new(drep.clone()); + state.registered_at = Some((100, 0)); + state.expiry = Some(DRepExpiry::new(118, 98)); + let mut entity = Some(state); + + let mut delta = DRepDormancyRelease::new(key.clone(), 5, 120); + delta.apply(&mut entity); + assert_eq!(entity.as_ref().unwrap().expiry.unwrap().current, 123); + assert_eq!( + entity.as_ref().unwrap().expiry.unwrap().as_of(119), + Some(118) + ); + + delta.undo(&mut entity); + assert_eq!( + entity.as_ref().unwrap().expiry, + Some(DRepExpiry::new(118, 98)) + ); + + // long-expired drep: untouched + let mut state = DRepState::new(drep.clone()); + state.registered_at = Some((100, 0)); + state.expiry = Some(DRepExpiry::new(110, 98)); + let mut entity = Some(state.clone()); + + let mut delta = DRepDormancyRelease::new(key.clone(), 5, 120); + delta.apply(&mut entity); + assert_eq!(entity.as_ref().unwrap().expiry, state.expiry); + + // legacy row without the field: skipped + let mut state = DRepState::new(drep); + state.registered_at = Some((100, 0)); + let mut entity = Some(state); + let mut delta = DRepDormancyRelease::new(key, 5, 120); + delta.apply(&mut entity); + assert_eq!(entity.as_ref().unwrap().expiry, None); + } +} + +#[cfg(test)] +mod compat_tests { + use super::*; + + /// Replica of the on-disk `DRepState` shape before the phase-3 expiry + /// addition (indexes 0..=7). Encoding this and decoding it as the + /// current `DRepState` proves that pre-existing rows keep decoding, + /// with the new field empty. + #[derive(Debug, Encode, Decode, Clone, PartialEq, Eq)] + struct LegacyDRepState { + #[n(0)] + registered_at: Option<(BlockSlot, TxOrder)>, + + #[n(1)] + voting_power: u64, + + #[n(2)] + last_active_slot: Option, + + #[n(3)] + unregistered_at: Option<(BlockSlot, TxOrder)>, + + #[n(4)] + expired: bool, + + #[n(5)] + deposit: u64, + + #[n(6)] + identifier: DRep, + + #[n(7)] + anchor: Option, + } + + #[test] + fn legacy_rows_decode_with_expiry_empty() { + let legacy = LegacyDRepState { + registered_at: Some((1234, 2)), + voting_power: 500_000_000, + last_active_slot: Some(5678), + unregistered_at: None, + expired: false, + deposit: 500_000_000, + identifier: DRep::Key([7u8; 28].into()), + anchor: Some(Anchor { + url: "https://example.com".to_string(), + content_hash: [9u8; 32].into(), + }), + }; + + let bytes = minicbor::to_vec(&legacy).unwrap(); + let decoded: DRepState = minicbor::decode(&bytes).unwrap(); + + assert_eq!(decoded.registered_at, legacy.registered_at); + assert_eq!(decoded.voting_power, legacy.voting_power); + assert_eq!(decoded.last_active_slot, legacy.last_active_slot); + assert_eq!(decoded.unregistered_at, legacy.unregistered_at); + assert_eq!(decoded.expired, legacy.expired); + assert_eq!(decoded.deposit, legacy.deposit); + assert_eq!(decoded.identifier, legacy.identifier); + assert_eq!(decoded.anchor, legacy.anchor); + assert_eq!(decoded.expiry, None); + } + + #[test] + fn new_rows_roundtrip() { + let mut state = DRepState::new(DRep::Script([3u8; 28].into())); + state.registered_at = Some((100, 1)); + state.expiry = Some(DRepExpiry { + current: 520, + updated_in: 500, + prev: Some(510), + }); + + let bytes = minicbor::to_vec(&state).unwrap(); + let decoded: DRepState = minicbor::decode(&bytes).unwrap(); + assert_eq!(decoded, state); } } diff --git a/crates/cardano/src/model/epochs.rs b/crates/cardano/src/model/epochs.rs index 9b55cd55..cd5a5dae 100644 --- a/crates/cardano/src/model/epochs.rs +++ b/crates/cardano/src/model/epochs.rs @@ -1,6 +1,6 @@ use std::{collections::HashSet, sync::Arc}; -use dolos_core::{BlockSlot, EntityKey, Genesis, NsKey}; +use dolos_core::{BlockSlot, Genesis, NsKey}; use pallas::{ codec::minicbor::{self, Decode, Encode}, crypto::{ @@ -16,7 +16,7 @@ use super::{ eras::EraTransition, pools::PoolHash, pparams::PParamsSet, - FixedNamespace as _, + SingletonEntity, }; use crate::pots::{EpochIncentives, Pots}; @@ -317,6 +317,10 @@ impl Default for EpochState { entity_boilerplate!(EpochState, "epochs"); +impl SingletonEntity for EpochState { + const KEY: &'static [u8] = CURRENT_EPOCH_KEY; +} + #[cfg(test)] pub(crate) mod testing { use super::*; @@ -450,7 +454,7 @@ impl dolos_core::EntityDelta for EpochStatsUpdate { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -548,7 +552,7 @@ impl dolos_core::EntityDelta for NoncesUpdate { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -594,7 +598,7 @@ impl dolos_core::EntityDelta for PParamsUpdate { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -653,7 +657,7 @@ impl dolos_core::EntityDelta for EpochWrapUp { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -725,7 +729,7 @@ impl dolos_core::EntityDelta for EpochWrapUpV2 { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -788,7 +792,7 @@ impl dolos_core::EntityDelta for EpochWrapUpV3 { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -861,7 +865,7 @@ impl dolos_core::EntityDelta for EWrapProgress { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -1000,7 +1004,7 @@ impl dolos_core::EntityDelta for EStartProgress { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -1108,7 +1112,7 @@ impl dolos_core::EntityDelta for RupdProgress { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -1200,7 +1204,7 @@ impl dolos_core::EntityDelta for NonceTransition { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -1283,7 +1287,7 @@ impl dolos_core::EntityDelta for EpochTransition { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -1391,7 +1395,7 @@ impl dolos_core::EntityDelta for EpochTransitionV2 { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, CURRENT_EPOCH_KEY)) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { @@ -1477,7 +1481,7 @@ impl dolos_core::EntityDelta for SetEpochIncentives { type Entity = EpochState; fn key(&self) -> NsKey { - NsKey::from((EpochState::NS, EntityKey::from(CURRENT_EPOCH_KEY))) + EpochState::ns_key() } fn apply(&mut self, entity: &mut Option) { diff --git a/crates/cardano/src/model/eras.rs b/crates/cardano/src/model/eras.rs index 47820abf..187afe5b 100644 --- a/crates/cardano/src/model/eras.rs +++ b/crates/cardano/src/model/eras.rs @@ -76,6 +76,13 @@ impl EraTransition { pub fn entering_allegra(&self) -> bool { self.prev_version == 2 && self.new_version == 3 } + + /// Check if this boundary is transitioning into Conway (the Chang hard + /// fork). At this boundary, the governance singleton is seeded from the + /// Conway genesis (initial constitution + committee). + pub fn entering_conway(&self) -> bool { + self.prev_version < 9 && self.new_version >= 9 + } } #[derive(Debug, Encode, Decode, Clone, Serialize, Deserialize)] @@ -128,3 +135,29 @@ pub(crate) mod testing { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn transition(prev: u16, new: u16) -> EraTransition { + EraTransition { + prev_version: EraProtocol::from(prev), + new_version: EraProtocol::from(new), + } + } + + #[test] + fn entering_conway_detects_chang_boundary() { + // the Chang hard fork proper + assert!(transition(8, 9).entering_conway()); + + // a jump over protocol 9 still enters Conway + assert!(transition(8, 10).entering_conway()); + + // intra-Conway bumps and earlier boundaries don't re-seed + assert!(!transition(9, 10).entering_conway()); + assert!(!transition(7, 8).entering_conway()); + assert!(!transition(2, 3).entering_conway()); + } +} diff --git a/crates/cardano/src/model/gov.rs b/crates/cardano/src/model/gov.rs new file mode 100644 index 00000000..1c8330cc --- /dev/null +++ b/crates/cardano/src/model/gov.rs @@ -0,0 +1,809 @@ +//! Governance singleton state (`"gov"` namespace). +//! +//! Mirrors the Conway governance residue that has no per-entity home: +//! the enacted constitution, the constitutional committee, the committee +//! hot-key authorization map (`vsCommitteeState` in the Haskell ledger), +//! the four per-purpose previous-governance-action roots, and the +//! dormant-epoch counter (`vsNumDormantEpochs`). +//! +//! A single entity lives in the namespace under [`GOV_STATE_KEY`]. Its +//! existence is an invariant: every store carries the row regardless of +//! era — created inactive at genesis bootstrap (or by the CARDANO-006 +//! startup migration on stores bootstrapped before the invariant) and +//! activated with the Conway genesis enact-state at the era boundary +//! that enters protocol 9 (or directly at bootstrap for networks that +//! force-start in Conway). `active_since` distinguishes the phases. +//! +//! Stores upgraded in place past the Chang boundary get the row from the +//! migration, but their enact-state fields stay unset — the committee +//! certs and enactments since the boundary were never recorded; complete +//! governance content comes from a fresh sync. + +use std::collections::BTreeMap; + +use dolos_core::{BlockSlot, NsKey}; +use pallas::{ + codec::minicbor::{self, Decode, Encode}, + ledger::primitives::{ + conway::{Anchor, GovActionId, RationalNumber}, + Epoch, ScriptHash, StakeCredential, + }, +}; +use serde::{Deserialize, Serialize}; + +use super::SingletonEntity; + +/// Key of the single `GovState` entity inside the `"gov"` namespace. +pub const GOV_STATE_KEY: &[u8] = b"0"; + +/// Expect message for delta apply/undo on the governance singleton — its +/// existence is an invariant, so a miss means a corrupt store. +const GOV_MUST_EXIST: &str = + "gov singleton must exist: seeded at bootstrap or by the CARDANO-006 migration"; + +/// The enacted constitution: metadata anchor plus the optional guardrails +/// script hash. +#[derive(Debug, Encode, Decode, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Constitution { + #[n(0)] + pub anchor: Anchor, + + #[n(1)] + pub guardrail_script: Option, +} + +/// The enacted constitutional committee: members (cold credential → term +/// expiry epoch, inclusive) and the vote threshold. +#[derive(Debug, Encode, Decode, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Committee { + #[n(0)] + pub members: BTreeMap, + + #[n(1)] + pub threshold: RationalNumber, +} + +/// Authorization state of one committee cold credential — the Haskell +/// `CommitteeAuthorization`. +#[derive(Debug, Encode, Decode, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum CommitteeAuthorization { + #[n(0)] + HotCredential(#[n(0)] StakeCredential), + + #[n(1)] + Resigned(#[n(0)] Option), +} + +/// Slot-stamped, append-only authorization history for a single committee +/// cold credential. The last entry is the effective authorization; keeping +/// the full history is what makes as-of reads possible at epoch boundaries, +/// where the committee tally must use the authorization as of the previous +/// boundary even if the member re-authorized since. Committee events are +/// rare, so histories stay tiny. +pub type AuthHistory = Vec<(BlockSlot, CommitteeAuthorization)>; + +/// The four per-purpose previous-governance-action roots (`prevGovActionIds` +/// in the Haskell ledger). Updated on enactment; consumed by the +/// `prevActionAsExpected` check during ratification. +#[derive(Debug, Encode, Decode, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GovRoots { + #[n(0)] + pub pparam_update: Option, + + #[n(1)] + pub hard_fork: Option, + + #[n(2)] + pub committee: Option, + + #[n(3)] + pub constitution: Option, +} + +#[derive(Debug, Encode, Decode, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GovState { + /// The enacted constitution. `None` before governance activates + /// (`active_since` unset) — or on a migrated store whose enact-state + /// is unknown (in-place upgrade gap; fresh sync recovers it). + #[n(0)] + pub constitution: Option, + + /// The enacted committee. `None` means the no-confidence state — or, + /// as with `constitution`, pre-activation / the migration gap. + #[n(1)] + pub committee: Option, + + /// Per cold credential, the slot-stamped history of hot-key + /// authorizations and resignations. + #[n(2)] + #[cbor(default)] + pub committee_auths: BTreeMap, + + /// The four per-purpose previous-enacted-action roots. + #[n(3)] + #[cbor(default)] + pub prev_gov_action_ids: GovRoots, + + /// Count of consecutive epochs with zero live proposals + /// (`vsNumDormantEpochs`). Folded into every DRep's stored expiry when + /// a proposal finally shows up, then reset to zero. + #[n(4)] + #[cbor(default)] + pub num_dormant_epochs: u64, + + /// Epoch since which governance is active — the chain entered Conway. + /// `None` means governance hasn't activated yet (pre-Conway). Set to 0 + /// for networks that force-start in Conway, to the Chang-boundary + /// epoch otherwise, and derived from the era summary by the startup + /// migration (which leaves the enact-state fields unset). + #[n(5)] + #[cbor(default)] + pub active_since: Option, +} + +entity_boilerplate!(GovState, "gov"); + +impl SingletonEntity for GovState { + const KEY: &'static [u8] = GOV_STATE_KEY; +} + +impl GovState { + /// Activate governance with the Conway genesis enact-state (the + /// initial constitution and committee). Shared by the two activation + /// paths — `bootstrap_gov` for networks that force-start in Conway + /// and `GovGenesisInit` at the Chang boundary — so they can't drift. + pub fn seed_genesis( + &mut self, + constitution: Constitution, + committee: Committee, + active_since: Epoch, + ) { + self.constitution = Some(constitution); + self.committee = Some(committee); + self.active_since = Some(active_since); + } + + /// Effective authorization of `cold` as of the latest event. + pub fn committee_auth(&self, cold: &StakeCredential) -> Option<&CommitteeAuthorization> { + self.committee_auths + .get(cold) + .and_then(|history| history.last()) + .map(|(_, auth)| auth) + } + + /// Effective authorization of `cold` considering only events at or + /// before `slot` — the as-of read used by boundary tallies. + pub fn committee_auth_as_of( + &self, + cold: &StakeCredential, + slot: BlockSlot, + ) -> Option<&CommitteeAuthorization> { + self.committee_auths + .get(cold) + .and_then(|history| history.iter().rev().find(|(at, _)| *at <= slot)) + .map(|(_, auth)| auth) + } +} + +/// Parse the committee-member key format used by Conway genesis files: +/// `"scriptHash-"` or `"keyHash-"`. +fn parse_genesis_committee_member(raw: &str) -> Option { + let (kind, hex_part) = raw.split_once('-')?; + let bytes = hex::decode(hex_part).ok()?; + let hash: [u8; 28] = bytes.try_into().ok()?; + + match kind { + "scriptHash" => Some(StakeCredential::ScriptHash(hash.into())), + "keyHash" => Some(StakeCredential::AddrKeyhash(hash.into())), + _ => None, + } +} + +/// Map the Conway genesis file's constitution + committee into their model +/// representation. This is the initial enact-state a network starts Conway +/// with; every later value comes from enacted governance actions. +pub fn gov_from_conway_genesis( + genesis: &pallas::interop::hardano::configs::conway::GenesisFile, +) -> Result<(Constitution, Committee), dolos_core::ChainError> { + let malformed = + |what: &str| dolos_core::ChainError::GenesisFieldMissing(format!("conway {what}")); + + let content_hash = hex::decode(&genesis.constitution.anchor.data_hash) + .ok() + .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok()) + .ok_or_else(|| malformed("constitution.anchor.dataHash"))?; + + let guardrail_script = genesis + .constitution + .script + .as_ref() + .map(|script| { + hex::decode(script) + .ok() + .and_then(|bytes| <[u8; 28]>::try_from(bytes).ok()) + .map(ScriptHash::from) + .ok_or_else(|| malformed("constitution.script")) + }) + .transpose()?; + + let constitution = Constitution { + anchor: Anchor { + url: genesis.constitution.anchor.url.clone(), + content_hash: content_hash.into(), + }, + guardrail_script, + }; + + let mut members = BTreeMap::new(); + + for (raw, term) in genesis.committee.members.iter() { + let cred = + parse_genesis_committee_member(raw).ok_or_else(|| malformed("committee.members"))?; + members.insert(cred, *term); + } + + let committee = Committee { + members, + threshold: genesis.committee.threshold.clone().into(), + }; + + Ok((constitution, committee)) +} + +// --- Deltas --- + +/// Activate the governance singleton with the Conway genesis constitution +/// and committee, effective from `epoch`. Emitted once, at the era boundary +/// that enters protocol 9 (Chang); networks that force-start in Conway +/// activate the entity directly at bootstrap instead. Undo restores the +/// inactive row — never removes it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GovGenesisInit { + pub(crate) constitution: Constitution, + pub(crate) committee: Committee, + pub(crate) epoch: Epoch, + + // undo + pub(crate) prev_constitution: Option, + pub(crate) prev_committee: Option, + pub(crate) prev_active_since: Option, +} + +impl GovGenesisInit { + pub fn new(constitution: Constitution, committee: Committee, epoch: Epoch) -> Self { + Self { + constitution, + committee, + epoch, + prev_constitution: None, + prev_committee: None, + prev_active_since: None, + } + } +} + +impl dolos_core::EntityDelta for GovGenesisInit { + type Entity = GovState; + + fn key(&self) -> NsKey { + GovState::ns_key() + } + + fn apply(&mut self, entity: &mut Option) { + let state = entity.as_mut().expect(GOV_MUST_EXIST); + + self.prev_constitution = state.constitution.clone(); + self.prev_committee = state.committee.clone(); + self.prev_active_since = state.active_since; + + state.seed_genesis( + self.constitution.clone(), + self.committee.clone(), + self.epoch, + ); + } + + fn undo(&self, entity: &mut Option) { + let state = entity.as_mut().expect(GOV_MUST_EXIST); + + state.constitution = self.prev_constitution.clone(); + state.committee = self.prev_committee.clone(); + state.active_since = self.prev_active_since; + } +} + +/// A committee member authorized a hot credential (`AuthCommitteeHot` +/// certificate). Appends `(slot, HotCredential)` to the cold credential's +/// history; undo pops exactly the appended entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitteeAuth { + pub(crate) cold: StakeCredential, + pub(crate) hot: StakeCredential, + pub(crate) slot: BlockSlot, + + // undo + pub(crate) created_entry: bool, +} + +impl CommitteeAuth { + pub fn new(cold: StakeCredential, hot: StakeCredential, slot: BlockSlot) -> Self { + Self { + cold, + hot, + slot, + created_entry: false, + } + } +} + +/// A committee member resigned (`ResignCommitteeCold` certificate). +/// Appends `(slot, Resigned)` to the cold credential's history; undo pops +/// exactly the appended entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitteeResign { + pub(crate) cold: StakeCredential, + pub(crate) anchor: Option, + pub(crate) slot: BlockSlot, + + // undo + pub(crate) created_entry: bool, +} + +impl CommitteeResign { + pub fn new(cold: StakeCredential, anchor: Option, slot: BlockSlot) -> Self { + Self { + cold, + anchor, + slot, + created_entry: false, + } + } +} + +/// Append an authorization event to `cold`'s history. Returns whether the +/// history entry itself was created (undo state for `pop_auth`). +fn push_auth( + entity: &mut Option, + cold: &StakeCredential, + slot: BlockSlot, + auth: CommitteeAuthorization, +) -> bool { + let state = entity.as_mut().expect(GOV_MUST_EXIST); + + let created_entry = !state.committee_auths.contains_key(cold); + + state + .committee_auths + .entry(cold.clone()) + .or_default() + .push((slot, auth)); + + created_entry +} + +fn pop_auth(entity: &mut Option, cold: &StakeCredential, created: bool) { + let state = entity.as_mut().expect(GOV_MUST_EXIST); + + if let Some(history) = state.committee_auths.get_mut(cold) { + history.pop(); + } + + if created { + state.committee_auths.remove(cold); + } +} + +impl dolos_core::EntityDelta for CommitteeAuth { + type Entity = GovState; + + fn key(&self) -> NsKey { + GovState::ns_key() + } + + fn apply(&mut self, entity: &mut Option) { + self.created_entry = push_auth( + entity, + &self.cold, + self.slot, + CommitteeAuthorization::HotCredential(self.hot.clone()), + ); + } + + fn undo(&self, entity: &mut Option) { + pop_auth(entity, &self.cold, self.created_entry); + } +} + +impl dolos_core::EntityDelta for CommitteeResign { + type Entity = GovState; + + fn key(&self) -> NsKey { + GovState::ns_key() + } + + fn apply(&mut self, entity: &mut Option) { + self.created_entry = push_auth( + entity, + &self.cold, + self.slot, + CommitteeAuthorization::Resigned(self.anchor.clone()), + ); + } + + fn undo(&self, entity: &mut Option) { + pop_auth(entity, &self.cold, self.created_entry); + } +} + +/// Reset the dormant-epoch counter to zero. Emitted together with the +/// per-DRep [`crate::DRepDormancyRelease`] fan-out when the first proposal +/// after a dormant stretch shows up (research §3.3.1). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GovDormancyReset { + // undo + pub(crate) prev: u64, +} + +impl GovDormancyReset { + pub fn new() -> Self { + Self { prev: 0 } + } +} + +impl Default for GovDormancyReset { + fn default() -> Self { + Self::new() + } +} + +impl dolos_core::EntityDelta for GovDormancyReset { + type Entity = GovState; + + fn key(&self) -> NsKey { + GovState::ns_key() + } + + fn apply(&mut self, entity: &mut Option) { + let state = entity.as_mut().expect(GOV_MUST_EXIST); + + self.prev = state.num_dormant_epochs; + state.num_dormant_epochs = 0; + } + + fn undo(&self, entity: &mut Option) { + let state = entity.as_mut().expect(GOV_MUST_EXIST); + + state.num_dormant_epochs = self.prev; + } +} + +#[cfg(test)] +pub(crate) mod testing { + use super::*; + use crate::model::testing as root; + use proptest::prelude::*; + + pub fn any_committee_authorization() -> impl Strategy { + prop_oneof![ + root::any_stake_credential().prop_map(CommitteeAuthorization::HotCredential), + prop::option::of(root::any_anchor()).prop_map(CommitteeAuthorization::Resigned), + ] + } + + pub fn any_auth_history() -> impl Strategy { + prop::collection::vec((root::any_slot(), any_committee_authorization()), 0..3) + } + + prop_compose! { + pub fn any_constitution()( + anchor in root::any_anchor(), + guardrail_script in prop::option::of(root::any_hash_28()), + ) -> Constitution { + Constitution { anchor, guardrail_script } + } + } + + prop_compose! { + pub fn any_committee()( + members in prop::collection::btree_map( + root::any_stake_credential(), + root::any_epoch(), + 0..5, + ), + threshold in root::any_rational(), + ) -> Committee { + Committee { members, threshold } + } + } + + prop_compose! { + pub fn any_gov_roots()( + pparam_update in prop::option::of(root::any_gov_action_id()), + hard_fork in prop::option::of(root::any_gov_action_id()), + committee in prop::option::of(root::any_gov_action_id()), + constitution in prop::option::of(root::any_gov_action_id()), + ) -> GovRoots { + GovRoots { pparam_update, hard_fork, committee, constitution } + } + } + + prop_compose! { + pub fn any_gov_state()( + constitution in prop::option::of(any_constitution()), + committee in prop::option::of(any_committee()), + committee_auths in prop::collection::btree_map( + root::any_stake_credential(), + any_auth_history(), + 0..3, + ), + prev_gov_action_ids in any_gov_roots(), + num_dormant_epochs in 0u64..32u64, + active_since in prop::option::of(root::any_epoch()), + ) -> GovState { + GovState { + constitution, + committee, + committee_auths, + prev_gov_action_ids, + num_dormant_epochs, + active_since, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entity_roundtrip() { + let mut state = GovState { + constitution: Some(Constitution { + anchor: Anchor { + url: "ipfs://constitution".to_string(), + content_hash: [1u8; 32].into(), + }, + guardrail_script: Some([2u8; 28].into()), + }), + committee: Some(Committee { + members: BTreeMap::from([ + (StakeCredential::ScriptHash([3u8; 28].into()), 580), + (StakeCredential::AddrKeyhash([4u8; 28].into()), 600), + ]), + threshold: RationalNumber { + numerator: 2, + denominator: 3, + }, + }), + committee_auths: BTreeMap::from([( + StakeCredential::ScriptHash([3u8; 28].into()), + vec![ + ( + 100, + CommitteeAuthorization::HotCredential(StakeCredential::AddrKeyhash( + [5u8; 28].into(), + )), + ), + (200, CommitteeAuthorization::Resigned(None)), + ], + )]), + prev_gov_action_ids: GovRoots { + committee: Some(GovActionId { + transaction_id: [6u8; 32].into(), + action_index: 0, + }), + ..Default::default() + }, + num_dormant_epochs: 3, + active_since: Some(507), + }; + + let bytes = minicbor::to_vec(&state).unwrap(); + let decoded: GovState = minicbor::decode(&bytes).unwrap(); + assert_eq!(decoded, state); + + // as-of reads walk the slot-stamped history + let cold = StakeCredential::ScriptHash([3u8; 28].into()); + + assert_eq!( + state.committee_auth(&cold), + Some(&CommitteeAuthorization::Resigned(None)) + ); + assert_eq!( + state.committee_auth_as_of(&cold, 150), + Some(&CommitteeAuthorization::HotCredential( + StakeCredential::AddrKeyhash([5u8; 28].into()) + )) + ); + assert_eq!(state.committee_auth_as_of(&cold, 50), None); + + state.committee_auths.clear(); + assert_eq!(state.committee_auth(&cold), None); + } + + #[test] + fn mainnet_conway_genesis_maps() { + let genesis = crate::load_test_genesis("mainnet"); + + let (constitution, committee) = gov_from_conway_genesis(&genesis.conway).unwrap(); + + // interim constitution: guardrails script present, anchor on ipfs + assert!(constitution.anchor.url.starts_with("ipfs://")); + assert_eq!( + hex::encode(constitution.guardrail_script.unwrap()), + "fa24fb305126805cf2164c161d852a0e7330cf988f1fe558cf7d4a64" + ); + + // interim committee: 7 script members, threshold 2/3 + assert_eq!(committee.members.len(), 7); + assert!(committee + .members + .keys() + .all(|cred| matches!(cred, StakeCredential::ScriptHash(_)))); + assert!(committee.members.values().all(|term| *term == 580)); + assert_eq!( + committee.threshold, + RationalNumber { + numerator: 2, + denominator: 3, + } + ); + } + + #[test] + fn genesis_member_key_parsing() { + let script = parse_genesis_committee_member( + "scriptHash-df0e83bde65416dade5b1f97e7f115cc1ff999550ad968850783fe50", + ); + assert!(matches!(script, Some(StakeCredential::ScriptHash(_)))); + + let key = parse_genesis_committee_member( + "keyHash-df0e83bde65416dade5b1f97e7f115cc1ff999550ad968850783fe50", + ); + assert!(matches!(key, Some(StakeCredential::AddrKeyhash(_)))); + + assert_eq!(parse_genesis_committee_member("bogus"), None); + assert_eq!(parse_genesis_committee_member("keyHash-zz"), None); + } +} + +#[cfg(test)] +mod prop_tests { + use super::testing::{any_committee, any_constitution, any_gov_state}; + use super::*; + use crate::model::testing::{self as root, assert_delta_roundtrip}; + use proptest::prelude::*; + + prop_compose! { + fn any_genesis_init()( + constitution in any_constitution(), + committee in any_committee(), + epoch in root::any_epoch(), + ) -> GovGenesisInit { + GovGenesisInit::new(constitution, committee, epoch) + } + } + + prop_compose! { + fn any_committee_auth()( + cold in root::any_stake_credential(), + hot in root::any_stake_credential(), + slot in root::any_slot(), + ) -> CommitteeAuth { + CommitteeAuth::new(cold, hot, slot) + } + } + + prop_compose! { + fn any_committee_resign()( + cold in root::any_stake_credential(), + anchor in prop::option::of(root::any_anchor()), + slot in root::any_slot(), + ) -> CommitteeResign { + CommitteeResign::new(cold, anchor, slot) + } + } + + proptest! { + #[test] + fn entity_cbor_roundtrip(entity in any_gov_state()) { + let bytes = minicbor::to_vec(&entity).unwrap(); + let decoded: GovState = minicbor::decode(&bytes).unwrap(); + prop_assert_eq!(decoded, entity); + } + + #[test] + fn genesis_init_roundtrip( + entity in any_gov_state().prop_map(Some), + delta in any_genesis_init(), + ) { + assert_delta_roundtrip(entity, delta); + } + + #[test] + fn committee_auth_roundtrip( + entity in any_gov_state().prop_map(Some), + delta in any_committee_auth(), + ) { + assert_delta_roundtrip(entity, delta); + } + + #[test] + fn committee_auth_serde_roundtrip( + entity in any_gov_state().prop_map(Some), + delta in any_committee_auth(), + ) { + root::assert_delta_serde_roundtrip(entity, delta); + } + + #[test] + fn committee_resign_roundtrip( + entity in any_gov_state().prop_map(Some), + delta in any_committee_resign(), + ) { + assert_delta_roundtrip(entity, delta); + } + + #[test] + fn committee_resign_serde_roundtrip( + entity in any_gov_state().prop_map(Some), + delta in any_committee_resign(), + ) { + root::assert_delta_serde_roundtrip(entity, delta); + } + + #[test] + fn dormancy_reset_roundtrip( + entity in any_gov_state().prop_map(Some), + ) { + assert_delta_roundtrip(entity, GovDormancyReset::new()); + } + + #[test] + fn dormancy_reset_serde_roundtrip( + entity in any_gov_state().prop_map(Some), + ) { + root::assert_delta_serde_roundtrip(entity, GovDormancyReset::new()); + } + + #[test] + fn genesis_init_serde_roundtrip( + entity in any_gov_state().prop_map(Some), + delta in any_genesis_init(), + ) { + root::assert_delta_serde_roundtrip(entity, delta); + } + } + + #[test] + fn auth_history_appends_in_order() { + use dolos_core::EntityDelta as _; + + let cold = StakeCredential::ScriptHash([1u8; 28].into()); + let hot = StakeCredential::AddrKeyhash([2u8; 28].into()); + + let mut entity: Option = Some(GovState::default()); + + let mut auth = CommitteeAuth::new(cold.clone(), hot.clone(), 100); + let mut resign = CommitteeResign::new(cold.clone(), None, 200); + + auth.apply(&mut entity); + resign.apply(&mut entity); + + let state = entity.as_ref().unwrap(); + assert_eq!( + state.committee_auths.get(&cold).unwrap(), + &vec![ + (100, CommitteeAuthorization::HotCredential(hot)), + (200, CommitteeAuthorization::Resigned(None)), + ] + ); + + resign.undo(&mut entity); + auth.undo(&mut entity); + + // undo restores the pristine row — never removes it + assert_eq!(entity, Some(GovState::default())); + } +} diff --git a/crates/cardano/src/model/mod.rs b/crates/cardano/src/model/mod.rs index 57f22b0d..54181676 100644 --- a/crates/cardano/src/model/mod.rs +++ b/crates/cardano/src/model/mod.rs @@ -1,9 +1,30 @@ -use dolos_core::{ChainError, EntityValue, Namespace, NamespaceType, NsKey, StateSchema}; +use dolos_core::{ + ChainError, Entity, EntityKey, EntityValue, Namespace, NamespaceType, NsKey, StateSchema, +}; pub trait FixedNamespace { const NS: &'static str; } +/// A namespace that holds exactly one row at a fixed key. +/// +/// Singletons don't fit the population-shaped machinery (namespace +/// streams, key ranges): readers address the row directly +/// (`crate::read_singleton`) and boundary commits apply deltas through +/// `WorkDeltas::apply_singleton`, which — unlike the streams — handles +/// the row not existing yet. +pub trait SingletonEntity: Entity + FixedNamespace { + const KEY: &'static [u8]; + + fn singleton_key() -> EntityKey { + EntityKey::from(Self::KEY) + } + + fn ns_key() -> NsKey { + NsKey::from((Self::NS, Self::singleton_key())) + } +} + macro_rules! entity_boilerplate { ($type:ident, $ns:literal) => { impl super::FixedNamespace for $type { @@ -35,6 +56,7 @@ pub mod dreps; pub mod epoch_value; pub mod epochs; pub mod eras; +pub mod gov; pub mod logs; pub mod pending; pub mod pools; @@ -51,6 +73,7 @@ pub use dreps::*; pub use epoch_value::*; pub use epochs::*; pub use eras::*; +pub use gov::*; pub use logs::*; pub use pending::*; pub use pools::*; @@ -75,6 +98,7 @@ pub enum CardanoEntity { DatumState(Box), PendingRewardState(Box), PendingMirState(Box), + GovState(Box), } macro_rules! variant_boilerplate { @@ -110,6 +134,7 @@ variant_boilerplate!(StakeLog); variant_boilerplate!(DatumState); variant_boilerplate!(PendingRewardState); variant_boilerplate!(PendingMirState); +variant_boilerplate!(GovState); impl dolos_core::Entity for CardanoEntity { fn decode_entity(ns: Namespace, value: &EntityValue) -> Result { @@ -130,6 +155,7 @@ impl dolos_core::Entity for CardanoEntity { DatumState::NS => DatumState::decode_entity(ns, value).map(Into::into), PendingRewardState::NS => PendingRewardState::decode_entity(ns, value).map(Into::into), PendingMirState::NS => PendingMirState::decode_entity(ns, value).map(Into::into), + GovState::NS => GovState::decode_entity(ns, value).map(Into::into), _ => Err(ChainError::InvalidNamespace(ns)), } } @@ -150,6 +176,7 @@ impl dolos_core::Entity for CardanoEntity { Self::DatumState(x) => DatumState::encode_entity(x), Self::PendingRewardState(x) => PendingRewardState::encode_entity(x), Self::PendingMirState(x) => PendingMirState::encode_entity(x), + Self::GovState(x) => GovState::encode_entity(x), } } } @@ -170,6 +197,7 @@ pub fn build_schema() -> StateSchema { schema.insert(DatumState::NS, NamespaceType::KeyValue); schema.insert(PendingRewardState::NS, NamespaceType::KeyValue); schema.insert(PendingMirState::NS, NamespaceType::KeyValue); + schema.insert(GovState::NS, NamespaceType::KeyValue); schema } @@ -233,6 +261,12 @@ pub enum CardanoDelta { DRepAnchorUpdate(Box), NewProposalV2(Box), VoteCast(Box), + DRepExpiryUpdate(Box), + DRepDormancyRelease(Box), + GovGenesisInit(Box), + CommitteeAuth(Box), + CommitteeResign(Box), + GovDormancyReset(Box), } impl CardanoDelta { @@ -321,6 +355,12 @@ delta_from!(EpochWrapUpV3); delta_from!(DRepAnchorUpdate); delta_from!(NewProposalV2); delta_from!(VoteCast); +delta_from!(DRepExpiryUpdate); +delta_from!(DRepDormancyRelease); +delta_from!(GovGenesisInit); +delta_from!(CommitteeAuth); +delta_from!(CommitteeResign); +delta_from!(GovDormancyReset); #[allow(deprecated)] impl dolos_core::EntityDelta for CardanoDelta { @@ -351,6 +391,12 @@ impl dolos_core::EntityDelta for CardanoDelta { Self::NewProposal(x) => x.key(), Self::NewProposalV2(x) => x.key(), Self::VoteCast(x) => x.key(), + Self::DRepExpiryUpdate(x) => x.key(), + Self::DRepDormancyRelease(x) => x.key(), + Self::GovGenesisInit(x) => x.key(), + Self::CommitteeAuth(x) => x.key(), + Self::CommitteeResign(x) => x.key(), + Self::GovDormancyReset(x) => x.key(), Self::AssignRewards(x) => x.key(), Self::NonceTransition(x) => x.key(), Self::PoolTransition(x) => x.key(), @@ -404,6 +450,12 @@ impl dolos_core::EntityDelta for CardanoDelta { Self::NewProposal(x) => Self::downcast_apply(x.as_mut(), entity), Self::NewProposalV2(x) => Self::downcast_apply(x.as_mut(), entity), Self::VoteCast(x) => Self::downcast_apply(x.as_mut(), entity), + Self::DRepExpiryUpdate(x) => Self::downcast_apply(x.as_mut(), entity), + Self::DRepDormancyRelease(x) => Self::downcast_apply(x.as_mut(), entity), + Self::GovGenesisInit(x) => Self::downcast_apply(x.as_mut(), entity), + Self::CommitteeAuth(x) => Self::downcast_apply(x.as_mut(), entity), + Self::CommitteeResign(x) => Self::downcast_apply(x.as_mut(), entity), + Self::GovDormancyReset(x) => Self::downcast_apply(x.as_mut(), entity), Self::AssignRewards(x) => Self::downcast_apply(x.as_mut(), entity), Self::NonceTransition(x) => Self::downcast_apply(x.as_mut(), entity), Self::PoolTransition(x) => Self::downcast_apply(x.as_mut(), entity), @@ -457,6 +509,12 @@ impl dolos_core::EntityDelta for CardanoDelta { Self::NewProposal(x) => Self::downcast_undo(x.as_ref(), entity), Self::NewProposalV2(x) => Self::downcast_undo(x.as_ref(), entity), Self::VoteCast(x) => Self::downcast_undo(x.as_ref(), entity), + Self::DRepExpiryUpdate(x) => Self::downcast_undo(x.as_ref(), entity), + Self::DRepDormancyRelease(x) => Self::downcast_undo(x.as_ref(), entity), + Self::GovGenesisInit(x) => Self::downcast_undo(x.as_ref(), entity), + Self::CommitteeAuth(x) => Self::downcast_undo(x.as_ref(), entity), + Self::CommitteeResign(x) => Self::downcast_undo(x.as_ref(), entity), + Self::GovDormancyReset(x) => Self::downcast_undo(x.as_ref(), entity), Self::AssignRewards(x) => Self::downcast_undo(x.as_ref(), entity), Self::NonceTransition(x) => Self::downcast_undo(x.as_ref(), entity), Self::PoolTransition(x) => Self::downcast_undo(x.as_ref(), entity), diff --git a/crates/cardano/src/pallas_extras.rs b/crates/cardano/src/pallas_extras.rs index b501e793..b5f1bfe9 100644 --- a/crates/cardano/src/pallas_extras.rs +++ b/crates/cardano/src/pallas_extras.rs @@ -176,6 +176,42 @@ pub fn cert_as_drep_unregistration(cert: &MultiEraCert) -> Option Option { + match cert { + MultiEraCert::Conway(cow) => match cow.deref().deref() { + ConwayCert::AuthCommitteeHot(cold, hot) => Some(MultiEraCommitteeAuth { + cold: cold.clone(), + hot: hot.clone(), + }), + _ => None, + }, + _ => None, + } +} + +pub struct MultiEraCommitteeResign { + pub cold: StakeCredential, + pub anchor: Option, +} + +pub fn cert_as_committee_resign(cert: &MultiEraCert) -> Option { + match cert { + MultiEraCert::Conway(cow) => match cow.deref().deref() { + ConwayCert::ResignCommitteeCold(cold, anchor) => Some(MultiEraCommitteeResign { + cold: cold.clone(), + anchor: anchor.clone(), + }), + _ => None, + }, + _ => None, + } +} + #[derive(Debug)] pub struct MultiEraStakeDelegation { pub delegator: StakeCredential, diff --git a/crates/cardano/src/roll/batch.rs b/crates/cardano/src/roll/batch.rs index 66cbbfa0..0ebe29a8 100644 --- a/crates/cardano/src/roll/batch.rs +++ b/crates/cardano/src/roll/batch.rs @@ -10,12 +10,15 @@ use rayon::prelude::*; use crate::consensus::ConsensusError; use crate::indexes::CardanoIndexDeltaBuilder; -use crate::{CardanoDelta, CardanoEntity, CardanoLogic, OwnedMultiEraBlock, OwnedMultiEraOutput}; +use crate::{ + CardanoDelta, CardanoEntity, CardanoLogic, OwnedMultiEraBlock, OwnedMultiEraOutput, + SingletonEntity, +}; use dolos_core::{ ArchiveStore, ArchiveWriter as _, Block as _, BlockSlot, ChainError, ChainPoint, Domain, DomainError, EntityDelta, EntityMap, IndexDelta, IndexStore as _, IndexWriter as _, LogValue, - NsKey, RawBlock, RawUtxoMap, StateError, StateStore as _, StateWriter as _, TxoRef, - UtxoSetDelta, WalStore as _, + NsKey, RawBlock, RawUtxoMap, StateError, StateStore, StateWriter as _, TxoRef, UtxoSetDelta, + WalStore as _, }; /// Container for entity deltas computed during block processing. @@ -31,6 +34,42 @@ impl WorkDeltas { let group = self.entities.entry(key).or_default(); group.push(delta); } + + /// Apply queued deltas for a singleton entity and write the result. + /// + /// Namespace streams only visit rows that already exist, so a delta + /// that must *create* its entity (e.g. `GovGenesisInit` at the Conway + /// boundary) would be dropped silently. This path reads the fixed key + /// directly and applies against `None` when the row is absent. + /// + /// Returns the post-apply entity so callers can refresh cached + /// copies; `None` when no deltas were queued. + pub fn apply_singleton( + &mut self, + state: &S, + writer: &S::Writer, + ) -> Result, ChainError> + where + E: SingletonEntity + Into, + CardanoEntity: Into>, + S: StateStore, + { + let Some(to_apply) = self.entities.remove(&E::ns_key()) else { + return Ok(None); + }; + + let mut entity: Option = state + .read_entity_typed::(E::NS, &E::singleton_key())? + .map(Into::into); + + for mut delta in to_apply { + delta.apply(&mut entity); + } + + writer.save_entity_typed(E::NS, &E::singleton_key(), entity.as_ref())?; + + Ok(entity.and_then(Into::into)) + } } /// A single block with its computed deltas. diff --git a/crates/cardano/src/roll/dreps.rs b/crates/cardano/src/roll/dreps.rs index 76af3896..187fede1 100644 --- a/crates/cardano/src/roll/dreps.rs +++ b/crates/cardano/src/roll/dreps.rs @@ -1,15 +1,22 @@ -use std::{collections::HashMap, ops::Deref as _}; +use std::{collections::HashMap, ops::Deref as _, sync::Arc}; -use dolos_core::{ChainError, TxOrder, TxoRef}; +use dolos_core::{ChainError, EntityKey, Genesis, TxOrder, TxoRef}; use pallas::ledger::{ - primitives::conway::{self, DRep, Voter}, + primitives::{ + conway::{self, DRep, Voter}, + Epoch, + }, traverse::{MultiEraBlock, MultiEraCert, MultiEraTx}, }; use super::WorkDeltas; use crate::{ - owned::OwnedMultiEraOutput, pallas_extras::stake_cred_to_drep, roll::BlockVisitor, - DRepActivity, DRepAnchorUpdate, DRepRegistration, DRepUnRegistration, + drep_to_entity_key, + owned::OwnedMultiEraOutput, + pallas_extras::{self, stake_cred_to_drep}, + roll::BlockVisitor, + DRepActivity, DRepAnchorUpdate, DRepDormancyRelease, DRepExpiryUpdate, DRepRegistration, + DRepUnRegistration, GovDormancyReset, PParamsSet, }; fn cert_drep(cert: &MultiEraCert) -> Option { @@ -24,10 +31,112 @@ fn cert_drep(cert: &MultiEraCert) -> Option { } } +/// Governance-bookkeeping context the roll visitors need from state: +/// the dormant-epoch counter (`GovState::num_dormant_epochs`) and — only +/// when the counter is non-zero, so the lookup stays free in the common +/// case — the key set of the dreps namespace for the dormancy-release +/// fan-out. +/// +/// Built once per batch by `compute_delta`; the whole context is threaded +/// through the batch's builders so a release (or a registration seen) in +/// block `n` is visible to block `n + 1` of the same batch. +#[derive(Clone, Default)] +pub struct DormancyContext { + pub dormant_epochs: u64, + pub drep_keys: Arc>, + + /// Keys of DReps registered (in valid txs) after the batch-start + /// snapshot was taken, while the counter was still non-zero. The + /// Haskell `updateDormantDRepExpiry` maps over the live DRep map, + /// which includes these; the snapshot alone would miss them. + pub batch_registrations: Vec, +} + +impl DormancyContext { + /// Keys targeted by a dormancy-release fan-out: the batch-start + /// snapshot plus the registrations seen since, deduplicated (a DRep + /// re-registering within the batch appears in both). + fn release_targets(&self) -> Vec { + let mut targets: Vec = self + .drep_keys + .iter() + .chain(self.batch_registrations.iter()) + .cloned() + .collect(); + + targets.sort(); + targets.dedup(); + + targets + } +} + +/// Visitor for the GOVCERT-scoped state effects: DRep registration +/// lifecycle, activity + epoch-based expiry bookkeeping, dormancy release, +/// and the two committee certificates. #[derive(Default, Clone)] -pub struct DRepStateVisitor; +pub struct DRepStateVisitor { + current_epoch: Epoch, + protocol: u16, + drep_activity: Option, + dormancy: DormancyContext, +} + +impl DRepStateVisitor { + pub fn new(dormancy: DormancyContext) -> Self { + Self { + dormancy, + ..Default::default() + } + } + + /// The dormancy context as evolved by the blocks visited so far — + /// `compute_delta` threads it into the next block's builder. + pub fn take_dormancy(&mut self) -> DormancyContext { + std::mem::take(&mut self.dormancy) + } + + /// `currentEpoch + drepActivity − numDormantEpochs` — the refresh value + /// for `UpdateDRepCert` and for DRep votes (research §3.3.2), and for + /// post-bootstrap registrations. + fn refresh_expiry(&self) -> Option { + let activity = self.drep_activity?; + + Some((self.current_epoch + activity).saturating_sub(self.dormancy.dormant_epochs)) + } + + /// Expiry stamped by `RegDRepCert`. During the PV9 bootstrap phase the + /// dormancy credit is (incorrectly, but consensus-relevantly) ignored — + /// `computeDRepExpiryVersioned` in the Haskell ledger. + fn registration_expiry(&self) -> Option { + let activity = self.drep_activity?; + + if self.protocol == 9 { + Some(self.current_epoch + activity) + } else { + self.refresh_expiry() + } + } +} impl BlockVisitor for DRepStateVisitor { + fn visit_root( + &mut self, + _: &mut WorkDeltas, + _: &MultiEraBlock, + _: &Genesis, + pparams: &PParamsSet, + epoch: Epoch, + _: u64, + protocol: u16, + ) -> Result<(), ChainError> { + self.current_epoch = epoch; + self.protocol = protocol; + self.drep_activity = pparams.drep_inactivity_period(); + + Ok(()) + } + fn visit_tx( &mut self, deltas: &mut WorkDeltas, @@ -39,6 +148,27 @@ impl BlockVisitor for DRepStateVisitor { return Ok(()); }; + // Dormancy release (research §3.3.1): the first proposal after a + // dormant stretch folds the counter into every non-long-expired + // DRep's stored expiry and resets it. In the Haskell CERTS rule this + // runs in the terminal case *before* the certificates, so certs and + // votes of the same tx already see the reset counter. Phase-2-invalid + // transactions contribute nothing. + if tx.is_valid() && self.dormancy.dormant_epochs > 0 && !tx.gov_proposals().is_empty() { + for key in self.dormancy.release_targets() { + deltas.add_for_entity(DRepDormancyRelease::new( + key, + self.dormancy.dormant_epochs, + self.current_epoch, + )); + } + + deltas.add_for_entity(GovDormancyReset::new()); + + self.dormancy.dormant_epochs = 0; + self.dormancy.batch_registrations = Vec::new(); + } + let Some(voting_procedures) = &conway_tx.transaction_body.voting_procedures else { return Ok(()); }; @@ -50,7 +180,21 @@ impl BlockVisitor for DRepStateVisitor { _ => continue, }; - deltas.add_for_entity(DRepActivity::new(drep, block.slot())); + deltas.add_for_entity(DRepActivity::new(drep.clone(), block.slot())); + + // Voting refresh (research §3.3.2): a registered DRep that votes + // gets its expiry pushed out, exactly like an `UpdateDRepCert` + // heartbeat. Valid txs only. + if tx.is_valid() { + if let Some(expiry) = self.refresh_expiry() { + deltas.add_for_entity(DRepExpiryUpdate::new( + drep, + expiry, + self.current_epoch, + true, + )); + } + } } Ok(()) @@ -60,10 +204,26 @@ impl BlockVisitor for DRepStateVisitor { &mut self, deltas: &mut WorkDeltas, block: &MultiEraBlock, - _: &MultiEraTx, + tx: &MultiEraTx, order: &TxOrder, cert: &MultiEraCert, ) -> Result<(), ChainError> { + // Committee certificates target the governance singleton. Valid txs + // only — CERT state effects never apply for phase-2-invalid txs. + if tx.is_valid() { + if let Some(auth) = pallas_extras::cert_as_committee_auth(cert) { + deltas.add_for_entity(crate::CommitteeAuth::new(auth.cold, auth.hot, block.slot())); + } + + if let Some(resign) = pallas_extras::cert_as_committee_resign(cert) { + deltas.add_for_entity(crate::CommitteeResign::new( + resign.cold, + resign.anchor, + block.slot(), + )); + } + } + let Some(drep) = cert_drep(cert) else { return Ok(()); }; @@ -80,6 +240,28 @@ impl BlockVisitor for DRepStateVisitor { )); deltas.add_for_entity(DRepAnchorUpdate::new(drep.clone(), anchor.clone())); + + if tx.is_valid() { + if let Some(expiry) = self.registration_expiry() { + deltas.add_for_entity(DRepExpiryUpdate::new( + drep.clone(), + expiry, + self.current_epoch, + false, + )); + } + + // While a dormant stretch is open, a registration + // creates a row the batch-start snapshot doesn't + // have — remember it so a release later in the + // batch still reaches it (Haskell folds over the + // live DRep map, which includes it). + if self.dormancy.dormant_epochs > 0 { + self.dormancy + .batch_registrations + .push(drep_to_entity_key(&drep)); + } + } } conway::Certificate::UnRegDRepCert(_, _) => { deltas.add_for_entity(DRepUnRegistration::new( @@ -90,6 +272,17 @@ impl BlockVisitor for DRepStateVisitor { } conway::Certificate::UpdateDRepCert(_, anchor) => { deltas.add_for_entity(DRepAnchorUpdate::new(drep.clone(), anchor.clone())); + + if tx.is_valid() { + if let Some(expiry) = self.refresh_expiry() { + deltas.add_for_entity(DRepExpiryUpdate::new( + drep.clone(), + expiry, + self.current_epoch, + false, + )); + } + } } _ => (), } @@ -100,3 +293,24 @@ impl BlockVisitor for DRepStateVisitor { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn release_targets_union_snapshot_and_batch_registrations() { + let key = |b: u8| EntityKey::from([b; 32].as_slice()); + + let dormancy = DormancyContext { + dormant_epochs: 3, + drep_keys: Arc::new(vec![key(1), key(2)]), + // key(2) re-registered within the batch: must not be released twice + batch_registrations: vec![key(2), key(3), key(3)], + }; + + let targets = dormancy.release_targets(); + + assert_eq!(targets, vec![key(1), key(2), key(3)]); + } +} diff --git a/crates/cardano/src/roll/mod.rs b/crates/cardano/src/roll/mod.rs index 983b71e2..eb8ce864 100644 --- a/crates/cardano/src/roll/mod.rs +++ b/crates/cardano/src/roll/mod.rs @@ -1,6 +1,9 @@ use std::{collections::HashMap, sync::Arc}; -use dolos_core::{ChainError, Domain, Genesis, InvariantViolation, StateError, TxOrder, TxoRef}; +use dolos_core::{ + ChainError, Domain, EntityKey, Genesis, InvariantViolation, StateError, StateStore as _, + TxOrder, TxoRef, +}; use pallas::{ codec::utils::KeepRaw, ledger::{ @@ -14,8 +17,8 @@ use pallas::{ use tracing::{debug, instrument}; use crate::{ - load_effective_pparams, owned::OwnedMultiEraOutput, roll::proposals::ProposalVisitor, utxoset, - Cache, PParamsSet, + load_effective_pparams, load_gov, owned::OwnedMultiEraOutput, roll::proposals::ProposalVisitor, + utxoset, Cache, DRepState, FixedNamespace as _, PParamsSet, }; // Sub-modules @@ -37,7 +40,7 @@ pub use work_unit::RollWorkUnit; use accounts::AccountVisitor; use assets::AssetStateVisitor; use datums::DatumVisitor; -use dreps::DRepStateVisitor; +use dreps::{DRepStateVisitor, DormancyContext}; use epochs::EpochStateVisitor; use pools::PoolStateVisitor; use txs::TxLogVisitor; @@ -210,6 +213,7 @@ impl<'a> DeltaBuilder<'a> { epoch_start: u64, work: &'a mut WorkBlock, utxos: &'a HashMap, + dormancy: DormancyContext, ) -> Self { Self { genesis, @@ -222,7 +226,7 @@ impl<'a> DeltaBuilder<'a> { account_state: Default::default(), asset_state: Default::default(), datum_state: Default::default(), - drep_state: Default::default(), + drep_state: DRepStateVisitor::new(dormancy), epoch_state: Default::default(), pool_state: Default::default(), tx_logs: Default::default(), @@ -230,6 +234,14 @@ impl<'a> DeltaBuilder<'a> { } } + /// The dormancy context after this block's deltas — a release inside + /// the block zeroes the counter; registrations seen while the counter + /// was non-zero extend the fan-out key set. `compute_delta` threads + /// the context into the next block's builder. + pub fn take_dormancy(&mut self) -> DormancyContext { + self.drep_state.take_dormancy() + } + pub fn crawl(&mut self) -> Result<(), ChainError> { let block = self.work.decoded(); let block = block.view(); @@ -554,6 +566,29 @@ pub(crate) fn compute_delta( let active_params = load_effective_pparams::(state)?; + // Governance dormancy context for the DRep visitor: the dormant-epoch + // counter and — only when it's non-zero, which is rare — the dreps key + // set for the release fan-out. The context evolves across blocks of + // the batch (a release zeroes the counter, registrations extend the + // key set), so it's taken back after each crawl. + let mut dormancy = DormancyContext { + dormant_epochs: load_gov::(state)?.num_dormant_epochs, + drep_keys: Default::default(), + batch_registrations: Default::default(), + }; + + if dormancy.dormant_epochs > 0 { + let mut keys = Vec::new(); + + // raw iteration: only the keys matter, skip the CBOR decode + for record in state.iter_entities(DRepState::NS, EntityKey::full_range())? { + let (key, _) = record?; + keys.push(key); + } + + dormancy.drep_keys = Arc::new(keys); + } + for block in batch.blocks.iter_mut() { let mut builder = DeltaBuilder::new( genesis.clone(), @@ -563,10 +598,13 @@ pub(crate) fn compute_delta( epoch_start, block, &batch.utxos_decoded, + std::mem::take(&mut dormancy), ); builder.crawl()?; + dormancy = builder.take_dormancy(); + // TODO: we treat the UTxO set differently due to tech-debt. We should migrate // this into the entity system. (#1042) let blockd = block.decoded(); diff --git a/crates/cardano/src/rupd/work_unit.rs b/crates/cardano/src/rupd/work_unit.rs index 28624a70..3d3b06d7 100644 --- a/crates/cardano/src/rupd/work_unit.rs +++ b/crates/cardano/src/rupd/work_unit.rs @@ -34,7 +34,8 @@ use crate::{ rewards::{Reward, RewardMap}, rupd::credential_to_key, shard::{shard_key_ranges, ACCOUNT_SHARDS}, - CardanoLogic, ChainPoint, EpochState, FixedNamespace, PendingRewardState, PoolHash, StakeLog, + CardanoLogic, ChainPoint, EpochState, FixedNamespace, PendingRewardState, PoolHash, + SingletonEntity as _, StakeLog, }; use super::RupdWork; @@ -275,7 +276,7 @@ where // current EpochState, apply the delta, and write back. The // delta's idempotency / ordering / total-mismatch guards make // this safe to repeat on crash recovery. - let epoch_key = dolos_core::EntityKey::from(crate::model::CURRENT_EPOCH_KEY); + let epoch_key = EpochState::singleton_key(); let mut epoch_entity: Option = domain .state() .read_entity_typed::(EpochState::NS, &epoch_key)?; @@ -339,7 +340,7 @@ where // shard commits can't race on this field. let writer = domain.state().start_writer()?; - let epoch_key = dolos_core::EntityKey::from(crate::model::CURRENT_EPOCH_KEY); + let epoch_key = EpochState::singleton_key(); if let Some(mut epoch_state) = domain .state() .read_entity_typed::(crate::EpochState::NS, &epoch_key)? diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ce1b61a8..961c21a1 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -441,6 +441,9 @@ pub enum ChainError { #[error("no active epoch")] NoActiveEpoch, + #[error("CARDANO-007: governance state missing from store; storage is corrupt or the startup migration did not run")] + MissingGovState, + #[error("era not found")] EraNotFound, diff --git a/crates/testing/src/toy_domain.rs b/crates/testing/src/toy_domain.rs index fa24042a..ef4420e8 100644 --- a/crates/testing/src/toy_domain.rs +++ b/crates/testing/src/toy_domain.rs @@ -8,6 +8,13 @@ use dolos_core::{ use std::sync::Arc; use std::sync::RwLock; +/// A bare in-memory state store with the Cardano schema and no cursor — +/// the "genesis never ran" starting condition, typed as `ToyDomain`'s +/// state so domain-generic code can run against it. +pub fn empty_state_store() -> dolos_redb3::state::StateStore { + dolos_redb3::state::StateStore::in_memory(dolos_cardano::model::build_schema()).unwrap() +} + pub fn seed_random_memory_store(utxo_generator: impl UtxoGenerator) -> impl StateStore { let store = dolos_redb3::state::StateStore::in_memory(dolos_cardano::model::build_schema()).unwrap();