From 481f042da79bca23b8f57d088f6a7fc17a616127 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 25 Jul 2026 12:57:24 -0300 Subject: [PATCH 1/4] feat(cardano): track committee, constitution and governance singleton state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Conway governance work: introduce the `GovState` singleton entity (ns "gov") carrying the enacted constitution, committee, committee hot-key authorization histories, per-purpose governance roots, and the dormant-epoch counter; handle the two committee certificates; and move DRep expiry to epoch-based, Haskell-style bookkeeping on `DRepState`. - `GovState` seeded from the Conway genesis at the Chang era boundary (`GovGenesisInit` in ESTART finalize) or at bootstrap for networks that force-start in Conway; committee auths tracked as slot-stamped histories so boundary tallies can read them as-of a past slot. - `AuthCommitteeHot` / `ResignCommitteeCold` certificates now emit `CommitteeAuth` / `CommitteeResign` deltas (valid txs only). - `DRepState.expiry` (new Option field at CBOR index 8): stored without dormancy credit, refreshed on registration (with the PV9 bootstrap exception), update certs, and DRep votes; snapshot-safe via an epoch-rotated `(current, updated_in, prev)` record. - Dormancy release (research §3.3.1): the first proposal after a dormant stretch fans out `DRepDormancyRelease` over the dreps namespace and resets the counter (`GovDormancyReset`), matching the Haskell CERTS rule ordering (release before certs/votes of the same tx). - `should_expire_drep` at EWRAP now compares the stored epoch expiry (+ dormancy credit) exactly as the ledger does, falling back to the legacy slot heuristic for pre-upgrade rows. Entity changes are backward-compatible `Option` additions at unused CBOR indexes; all `CardanoDelta` variants are appended, never reordered. Old rows decode with the new fields empty (covered by compat tests). Co-Authored-By: Claude Fable 5 --- crates/cardano/src/estart/commit.rs | 41 +- crates/cardano/src/estart/loading.rs | 18 +- crates/cardano/src/ewrap/loading.rs | 23 +- crates/cardano/src/ewrap/mod.rs | 5 + crates/cardano/src/genesis/mod.rs | 31 +- crates/cardano/src/model/dreps.rs | 513 +++++++++++++++++- crates/cardano/src/model/eras.rs | 7 + crates/cardano/src/model/gov.rs | 763 +++++++++++++++++++++++++++ crates/cardano/src/model/mod.rs | 37 ++ crates/cardano/src/pallas_extras.rs | 36 ++ crates/cardano/src/roll/dreps.rs | 170 +++++- crates/cardano/src/roll/mod.rs | 45 +- 12 files changed, 1667 insertions(+), 22 deletions(-) create mode 100644 crates/cardano/src/model/gov.rs diff --git a/crates/cardano/src/estart/commit.rs b/crates/cardano/src/estart/commit.rs index f2f0e91f7..41b0e7c65 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, GOV_STATE_KEY, }; /// Era transition data collected from state. @@ -114,6 +114,41 @@ impl super::WorkContext { Ok(()) } + /// Apply any queued deltas for the governance singleton. + /// + /// Unlike `stream_and_apply_namespace`, this handles the entity being + /// absent: the `GovGenesisInit` delta emitted at the Conway era boundary + /// *creates* the singleton, so streaming existing records would silently + /// drop it. + fn apply_gov_state_deltas( + &mut self, + state: &D::State, + writer: &::Writer, + ) -> Result<(), ChainError> { + let entity_key = EntityKey::from(GOV_STATE_KEY); + + let to_apply = self + .deltas + .entities + .remove(&NsKey::from((GovState::NS, entity_key.clone()))); + + let Some(to_apply) = to_apply else { + return Ok(()); + }; + + let mut entity: Option = state + .read_entity_typed::(GovState::NS, &entity_key)? + .map(Into::into); + + for mut delta in to_apply { + delta.apply(&mut entity); + } + + writer.save_entity_typed(GovState::NS, &entity_key, entity.as_ref())?; + + Ok(()) + } + /// Commit a single per-shard run: stream-and-apply per-account snapshot /// transitions for the shard's key ranges, then commit the /// `EStartProgress` delta against `EpochState`. Archive logs @@ -225,6 +260,10 @@ impl super::WorkContext { debug!("streaming epoch entities"); self.stream_and_apply_namespace::(state, &writer, None)?; + // Governance singleton — targeted apply (the entity may not exist + // yet; the Conway-boundary `GovGenesisInit` creates it). + self.apply_gov_state_deltas::(state, &writer)?; + // Write era transition if needed (only 2 entities) if let Some(transition) = era_transition { writer diff --git a/crates/cardano/src/estart/loading.rs b/crates/cardano/src/estart/loading.rs index f4be6a999..1f8afa34c 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,18 @@ impl WorkContext { // all per-entity transitions have been queued. super::reset::emit_epoch_transition(self); + // Entering Conway (the Chang hard fork) seeds the governance + // singleton with the genesis constitution + committee — the initial + // enact-state every later governance action evolves from. Stores + // already past the boundary never see this event (documented + // in-place upgrade gap; a fresh sync captures it). + 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)); + } + } + Ok(()) } diff --git a/crates/cardano/src/ewrap/loading.rs b/crates/cardano/src/ewrap/loading.rs index 38a9995eb..72566bdf1 100644 --- a/crates/cardano/src/ewrap/loading.rs +++ b/crates/cardano/src/ewrap/loading.rs @@ -20,8 +20,8 @@ use crate::{ rewards::{Reward, RewardMap}, roll::WorkDeltas, rupd::credential_to_key, - AccountState, DRepState, EraProtocol, FixedNamespace as _, PendingMirState, PendingRewardState, - PoolState, ProposalState, + AccountState, DRepState, EraProtocol, FixedNamespace as _, GovState, PendingMirState, + PendingRewardState, PoolState, ProposalState, GOV_STATE_KEY, }; impl BoundaryWork { @@ -37,6 +37,11 @@ impl BoundaryWork { let active_protocol = EraProtocol::from(chain_summary.edge().protocol); let incentives = ending_state.incentives.clone().unwrap_or_default(); + let num_dormant_epochs = state + .read_entity_typed::(GovState::NS, &EntityKey::from(GOV_STATE_KEY))? + .map(|gov| gov.num_dormant_epochs) + .unwrap_or_default(); + Ok(BoundaryWork { ending_state, chain_summary, @@ -48,6 +53,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 +272,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 d244ac31d..d8b0ad254 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 0f93715ca..db8efe04b 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, CURRENT_EPOCH_KEY, GOV_STATE_KEY, }; mod staking; @@ -124,6 +124,29 @@ pub fn bootstrap_eras(state: &D::State, epoch: &EpochState) -> Result Ok(()) } +/// Seed the governance singleton for networks that force-start at Conway +/// (protocol >= 9) — e.g. devnets. Chains that reach Conway by crossing the +/// Chang hard fork seed it at that era boundary instead (`GovGenesisInit`). +pub fn bootstrap_gov(state: &D::State, genesis: &Genesis) -> Result<(), ChainError> { + if genesis.force_protocol.is_none_or(|protocol| protocol < 9) { + return Ok(()); + } + + let (constitution, committee) = gov_from_conway_genesis(&genesis.conway)?; + + let gov = GovState { + constitution: Some(constitution), + committee: Some(committee), + ..Default::default() + }; + + let writer = state.start_writer()?; + writer.write_entity_typed(&EntityKey::from(GOV_STATE_KEY), &gov)?; + writer.commit()?; + + Ok(()) +} + pub fn bootstrap_utxos( state: &D::State, indexes: &D::Indexes, @@ -163,6 +186,8 @@ pub fn execute( bootstrap_eras::(state, &epoch)?; + bootstrap_gov::(state, genesis)?; + bootstrap_utxos::(state, indexes, genesis, config)?; staking::bootstrap::(state, genesis)?; diff --git a/crates/cardano/src/model/dreps.rs b/crates/cardano/src/model/dreps.rs index a7085c3e4..ad5141476 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,175 @@ 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(); + + 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 +689,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 +758,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/eras.rs b/crates/cardano/src/model/eras.rs index 47820abf1..841a94a9e 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)] diff --git a/crates/cardano/src/model/gov.rs b/crates/cardano/src/model/gov.rs new file mode 100644 index 000000000..8b942c660 --- /dev/null +++ b/crates/cardano/src/model/gov.rs @@ -0,0 +1,763 @@ +//! 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`], seeded +//! from the Conway genesis at the era boundary that enters protocol 9 +//! (or at bootstrap for networks that force-start in Conway). Stores +//! upgraded in place past that boundary simply have no entity until an +//! event creates one — the documented "governance history absent" gap for +//! in-place upgrades; complete state comes from a fresh sync. + +use std::collections::BTreeMap; + +use dolos_core::{BlockSlot, EntityKey, NsKey}; +use pallas::{ + codec::minicbor::{self, Decode, Encode}, + ledger::primitives::{ + conway::{Anchor, GovActionId, RationalNumber}, + Epoch, ScriptHash, StakeCredential, + }, +}; +use serde::{Deserialize, Serialize}; + +use super::FixedNamespace as _; + +/// Key of the single `GovState` entity inside the `"gov"` namespace. +pub const GOV_STATE_KEY: &[u8] = b"0"; + +/// 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` only when the entity was created + /// lazily by a committee certificate on a store that never crossed the + /// Conway boundary with this code (in-place upgrade gap). + #[n(0)] + pub constitution: Option, + + /// The enacted committee. `None` means the no-confidence state — or the + /// same in-place upgrade gap as `constitution`. + #[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, +} + +entity_boilerplate!(GovState, "gov"); + +impl GovState { + pub fn ns_key() -> NsKey { + NsKey::from((Self::NS, EntityKey::from(GOV_STATE_KEY))) + } + + /// 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 --- + +/// Seed the governance singleton with the Conway genesis constitution and +/// committee. Emitted once, at the era boundary that enters protocol 9 +/// (Chang); networks that force-start in Conway seed the entity directly at +/// bootstrap instead. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GovGenesisInit { + pub(crate) constitution: Constitution, + pub(crate) committee: Committee, + + // undo + pub(crate) prev: Option, +} + +impl GovGenesisInit { + pub fn new(constitution: Constitution, committee: Committee) -> Self { + Self { + constitution, + committee, + prev: None, + } + } +} + +impl dolos_core::EntityDelta for GovGenesisInit { + type Entity = GovState; + + fn key(&self) -> NsKey { + GovState::ns_key() + } + + fn apply(&mut self, entity: &mut Option) { + self.prev = entity.clone(); + + let state = entity.get_or_insert_with(GovState::default); + + state.constitution = Some(self.constitution.clone()); + state.committee = Some(self.committee.clone()); + } + + fn undo(&self, entity: &mut Option) { + *entity = self.prev.clone(); + } +} + +/// 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) was_new: bool, + pub(crate) created_entry: bool, +} + +impl CommitteeAuth { + pub fn new(cold: StakeCredential, hot: StakeCredential, slot: BlockSlot) -> Self { + Self { + cold, + hot, + slot, + was_new: false, + 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) was_new: bool, + pub(crate) created_entry: bool, +} + +impl CommitteeResign { + pub fn new(cold: StakeCredential, anchor: Option, slot: BlockSlot) -> Self { + Self { + cold, + anchor, + slot, + was_new: false, + created_entry: false, + } + } +} + +fn push_auth( + entity: &mut Option, + cold: &StakeCredential, + slot: BlockSlot, + auth: CommitteeAuthorization, +) -> (bool, bool) { + let was_new = entity.is_none(); + + let state = entity.get_or_insert_with(GovState::default); + + let created_entry = !state.committee_auths.contains_key(cold); + + state + .committee_auths + .entry(cold.clone()) + .or_default() + .push((slot, auth)); + + (was_new, created_entry) +} + +fn pop_auth(entity: &mut Option, cold: &StakeCredential, was_new: bool, created: bool) { + if was_new { + *entity = None; + return; + } + + let Some(state) = entity.as_mut() else { + return; + }; + + 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) { + let (was_new, created_entry) = push_auth( + entity, + &self.cold, + self.slot, + CommitteeAuthorization::HotCredential(self.hot.clone()), + ); + + self.was_new = was_new; + self.created_entry = created_entry; + } + + fn undo(&self, entity: &mut Option) { + pop_auth(entity, &self.cold, self.was_new, 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) { + let (was_new, created_entry) = push_auth( + entity, + &self.cold, + self.slot, + CommitteeAuthorization::Resigned(self.anchor.clone()), + ); + + self.was_new = was_new; + self.created_entry = created_entry; + } + + fn undo(&self, entity: &mut Option) { + pop_auth(entity, &self.cold, self.was_new, 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) was_new: bool, + pub(crate) prev: u64, +} + +impl GovDormancyReset { + pub fn new() -> Self { + Self { + was_new: false, + 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) { + self.was_new = entity.is_none(); + + let state = entity.get_or_insert_with(GovState::default); + + self.prev = state.num_dormant_epochs; + state.num_dormant_epochs = 0; + } + + fn undo(&self, entity: &mut Option) { + if self.was_new { + *entity = None; + } else if let Some(state) = entity { + 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, + ) -> GovState { + GovState { + constitution, + committee, + committee_auths, + prev_gov_action_ids, + num_dormant_epochs, + } + } + } +} + +#[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, + }; + + 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(), + ) -> GovGenesisInit { + GovGenesisInit::new(constitution, committee) + } + } + + 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 prop::option::of(any_gov_state()), + delta in any_genesis_init(), + ) { + assert_delta_roundtrip(entity, delta); + } + + #[test] + fn committee_auth_roundtrip( + entity in prop::option::of(any_gov_state()), + delta in any_committee_auth(), + ) { + assert_delta_roundtrip(entity, delta); + } + + #[test] + fn committee_auth_serde_roundtrip( + entity in prop::option::of(any_gov_state()), + delta in any_committee_auth(), + ) { + root::assert_delta_serde_roundtrip(entity, delta); + } + + #[test] + fn committee_resign_roundtrip( + entity in prop::option::of(any_gov_state()), + delta in any_committee_resign(), + ) { + assert_delta_roundtrip(entity, delta); + } + + #[test] + fn committee_resign_serde_roundtrip( + entity in prop::option::of(any_gov_state()), + delta in any_committee_resign(), + ) { + root::assert_delta_serde_roundtrip(entity, delta); + } + + #[test] + fn dormancy_reset_roundtrip( + entity in prop::option::of(any_gov_state()), + ) { + assert_delta_roundtrip(entity, GovDormancyReset::new()); + } + } + + #[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 = None; + + 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); + + assert!(entity.is_none()); + } +} diff --git a/crates/cardano/src/model/mod.rs b/crates/cardano/src/model/mod.rs index 57f22b0d5..06da8585e 100644 --- a/crates/cardano/src/model/mod.rs +++ b/crates/cardano/src/model/mod.rs @@ -35,6 +35,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 +52,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 +77,7 @@ pub enum CardanoEntity { DatumState(Box), PendingRewardState(Box), PendingMirState(Box), + GovState(Box), } macro_rules! variant_boilerplate { @@ -110,6 +113,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 +134,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 +155,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 +176,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 +240,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 +334,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 +370,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 +429,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 +488,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 b501e793f..b5f1bfe9f 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/dreps.rs b/crates/cardano/src/roll/dreps.rs index 76af38968..2c421e93f 100644 --- a/crates/cardano/src/roll/dreps.rs +++ b/crates/cardano/src/roll/dreps.rs @@ -1,15 +1,21 @@ -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, + 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 +30,86 @@ 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 evolving counter is copied +/// back after each block so a release 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>, +} + +/// 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 dormant-epoch counter after the blocks visited so far. + pub fn dormant_epochs(&self) -> u64 { + self.dormancy.dormant_epochs + } + + /// `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 +121,26 @@ 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.drep_keys.iter() { + deltas.add_for_entity(DRepDormancyRelease::new( + key.clone(), + self.dormancy.dormant_epochs, + self.current_epoch, + )); + } + + deltas.add_for_entity(GovDormancyReset::new()); + + self.dormancy.dormant_epochs = 0; + } + let Some(voting_procedures) = &conway_tx.transaction_body.voting_procedures else { return Ok(()); }; @@ -50,7 +152,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 +176,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 +212,17 @@ 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, + )); + } + } } conway::Certificate::UnRegDRepCert(_, _) => { deltas.add_for_entity(DRepUnRegistration::new( @@ -90,6 +233,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, + )); + } + } } _ => (), } diff --git a/crates/cardano/src/roll/mod.rs b/crates/cardano/src/roll/mod.rs index 983b71e2e..ded473f29 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::{ @@ -15,7 +18,7 @@ use tracing::{debug, instrument}; use crate::{ load_effective_pparams, owned::OwnedMultiEraOutput, roll::proposals::ProposalVisitor, utxoset, - Cache, PParamsSet, + Cache, DRepState, FixedNamespace as _, GovState, PParamsSet, GOV_STATE_KEY, }; // 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,13 @@ impl<'a> DeltaBuilder<'a> { } } + /// The dormant-epoch counter after this block's deltas — a dormancy + /// release inside the block zeroes it. `compute_delta` threads the + /// value into the next block's builder. + pub fn dormant_epochs(&self) -> u64 { + self.drep_state.dormant_epochs() + } + pub fn crawl(&mut self) -> Result<(), ChainError> { let block = self.work.decoded(); let block = block.view(); @@ -554,6 +565,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 counter evolves across blocks of the + // batch (a release zeroes it), so it's copied back after each crawl. + let mut dormancy = DormancyContext { + dormant_epochs: state + .read_entity_typed::(GovState::NS, &EntityKey::from(GOV_STATE_KEY))? + .map(|gov| gov.num_dormant_epochs) + .unwrap_or_default(), + drep_keys: Default::default(), + }; + + if dormancy.dormant_epochs > 0 { + let mut keys = Vec::new(); + + for record in state.iter_entities_typed::(DRepState::NS, None)? { + 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 +597,13 @@ pub(crate) fn compute_delta( epoch_start, block, &batch.utxos_decoded, + dormancy.clone(), ); builder.crawl()?; + dormancy.dormant_epochs = builder.dormant_epochs(); + // TODO: we treat the UTxO set differently due to tech-debt. We should migrate // this into the entity system. (#1042) let blockd = block.decoded(); From 5c33a4a64a8e7c3a5fea13cd1f7e63720eff731f Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 25 Jul 2026 13:51:54 -0300 Subject: [PATCH 2/4] fix(cardano): include batch registrations in dormancy release fan-out Review follow-ups on the phase-3 governance PR: - The dormancy-release fan-out targeted only the DRep key set snapshotted at batch start, missing DReps registered in an earlier block or tx of the same batch. Haskell's updateDormantDRepExpiry folds over the live DRep map, which includes them. The visitor now records registrations seen while the counter is non-zero and the release targets the deduplicated union (a re-registration would otherwise be boosted twice); the whole DormancyContext is threaded across the batch's builders instead of just the counter. - Collect fan-out keys via the raw entity iterator, skipping the per-row CBOR decode. - Document the DRepRegistration-precedes-DRepExpiryUpdate ordering invariant at the defensive creation site in DRepExpiryUpdate::apply. - Cover the review's test gaps: entering_conway boundary math, bootstrap_gov gating on force_protocol (via ToyDomain), the entity-absent targeted gov commit path in estart, and the release-target union/dedup. Co-Authored-By: Claude Fable 5 --- crates/cardano/src/estart/commit.rs | 83 +++++++++++++++++++++++++++++ crates/cardano/src/genesis/mod.rs | 42 +++++++++++++++ crates/cardano/src/model/dreps.rs | 4 ++ crates/cardano/src/model/eras.rs | 26 +++++++++ crates/cardano/src/roll/dreps.rs | 76 +++++++++++++++++++++++--- crates/cardano/src/roll/mod.rs | 24 +++++---- 6 files changed, 237 insertions(+), 18 deletions(-) diff --git a/crates/cardano/src/estart/commit.rs b/crates/cardano/src/estart/commit.rs index 41b0e7c65..1c2d9070f 100644 --- a/crates/cardano/src/estart/commit.rs +++ b/crates/cardano/src/estart/commit.rs @@ -297,3 +297,86 @@ 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}; + + 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, &EntityKey::from(GOV_STATE_KEY)) + .unwrap() + } + + /// The targeted gov commit path must create the singleton when it does + /// not exist yet — `stream_and_apply_namespace` would silently drop the + /// `GovGenesisInit` emitted at the Conway boundary. + #[test] + fn gov_deltas_create_absent_singleton() { + let domain = ToyDomain::new(None, None); + let state = domain.state(); + + // the devnet bootstrap seeds the entity; delete it to simulate a + // store reaching the Chang boundary without one + let writer = state.start_writer().unwrap(); + writer + .delete_entity(GovState::NS, &EntityKey::from(GOV_STATE_KEY)) + .unwrap(); + writer.commit().unwrap(); + assert_eq!(read_gov(&domain), None); + + 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())); + + let writer = state.start_writer().unwrap(); + ctx.apply_gov_state_deltas::(state, &writer) + .unwrap(); + writer.commit().unwrap(); + + let gov = read_gov(&domain).expect("singleton created by delta"); + assert_eq!(gov.constitution, Some(constitution)); + assert_eq!(gov.committee, Some(committee)); + + // 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.apply_gov_state_deltas::(state, &writer) + .unwrap(); + writer.commit().unwrap(); + + assert_eq!(read_gov(&domain), Some(before)); + } +} diff --git a/crates/cardano/src/genesis/mod.rs b/crates/cardano/src/genesis/mod.rs index db8efe04b..3f4aff607 100644 --- a/crates/cardano/src/genesis/mod.rs +++ b/crates/cardano/src/genesis/mod.rs @@ -203,3 +203,45 @@ 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, GOV_STATE_KEY}; + + use super::*; + + fn read_gov(domain: &ToyDomain) -> Option { + domain + .state() + .read_entity_typed::(GovState::NS, &EntityKey::from(GOV_STATE_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); + } + + #[test] + fn bootstrap_skips_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); + + assert_eq!(read_gov(&domain), None); + } +} diff --git a/crates/cardano/src/model/dreps.rs b/crates/cardano/src/model/dreps.rs index ad5141476..42d82e3e7 100644 --- a/crates/cardano/src/model/dreps.rs +++ b/crates/cardano/src/model/dreps.rs @@ -527,6 +527,10 @@ impl dolos_core::EntityDelta for DRepExpiryUpdate { 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 diff --git a/crates/cardano/src/model/eras.rs b/crates/cardano/src/model/eras.rs index 841a94a9e..187afe5bb 100644 --- a/crates/cardano/src/model/eras.rs +++ b/crates/cardano/src/model/eras.rs @@ -135,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/roll/dreps.rs b/crates/cardano/src/roll/dreps.rs index 2c421e93f..187fede1b 100644 --- a/crates/cardano/src/roll/dreps.rs +++ b/crates/cardano/src/roll/dreps.rs @@ -11,6 +11,7 @@ use pallas::ledger::{ use super::WorkDeltas; use crate::{ + drep_to_entity_key, owned::OwnedMultiEraOutput, pallas_extras::{self, stake_cred_to_drep}, roll::BlockVisitor, @@ -36,13 +37,38 @@ fn cert_drep(cert: &MultiEraCert) -> Option { /// case — the key set of the dreps namespace for the dormancy-release /// fan-out. /// -/// Built once per batch by `compute_delta`; the evolving counter is copied -/// back after each block so a release in block `n` is visible to block -/// `n + 1` of the same batch. +/// 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 @@ -64,9 +90,10 @@ impl DRepStateVisitor { } } - /// The dormant-epoch counter after the blocks visited so far. - pub fn dormant_epochs(&self) -> u64 { - self.dormancy.dormant_epochs + /// 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 @@ -128,9 +155,9 @@ impl BlockVisitor for DRepStateVisitor { // 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.drep_keys.iter() { + for key in self.dormancy.release_targets() { deltas.add_for_entity(DRepDormancyRelease::new( - key.clone(), + key, self.dormancy.dormant_epochs, self.current_epoch, )); @@ -139,6 +166,7 @@ impl BlockVisitor for DRepStateVisitor { 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 { @@ -222,6 +250,17 @@ impl BlockVisitor for DRepStateVisitor { 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(_, _) => { @@ -254,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 ded473f29..958eda686 100644 --- a/crates/cardano/src/roll/mod.rs +++ b/crates/cardano/src/roll/mod.rs @@ -234,11 +234,12 @@ impl<'a> DeltaBuilder<'a> { } } - /// The dormant-epoch counter after this block's deltas — a dormancy - /// release inside the block zeroes it. `compute_delta` threads the - /// value into the next block's builder. - pub fn dormant_epochs(&self) -> u64 { - self.drep_state.dormant_epochs() + /// 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> { @@ -567,20 +568,23 @@ pub(crate) fn compute_delta( // 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 counter evolves across blocks of the - // batch (a release zeroes it), so it's copied back after each crawl. + // 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: state .read_entity_typed::(GovState::NS, &EntityKey::from(GOV_STATE_KEY))? .map(|gov| gov.num_dormant_epochs) .unwrap_or_default(), drep_keys: Default::default(), + batch_registrations: Default::default(), }; if dormancy.dormant_epochs > 0 { let mut keys = Vec::new(); - for record in state.iter_entities_typed::(DRepState::NS, None)? { + // 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); } @@ -597,12 +601,12 @@ pub(crate) fn compute_delta( epoch_start, block, &batch.utxos_decoded, - dormancy.clone(), + std::mem::take(&mut dormancy), ); builder.crawl()?; - dormancy.dormant_epochs = builder.dormant_epochs(); + dormancy = builder.take_dormancy(); // TODO: we treat the UTxO set differently due to tech-debt. We should migrate // this into the entity system. (#1042) From 24d0dee38e461ce067b257e05dd12ebef9439ded Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 25 Jul 2026 16:19:47 -0300 Subject: [PATCH 3/4] refactor(cardano): introduce singleton entity concept The gov singleton landed with ad-hoc plumbing around machinery shaped for entity populations: a bespoke apply_gov_state_deltas in the estart commit (streams can't create absent rows), inline (ns, key) derivation at every read site, and duplicated genesis seeding between bootstrap_gov and GovGenesisInit. EpochState had grown its own parallel workarounds: an EpochState-specific apply variant in the ewrap commit and full-namespace streams to touch one known row. Name the pattern instead: a SingletonEntity trait (fixed key over FixedNamespace) implemented by GovState and EpochState, a generic creation-capable WorkDeltas::apply_singleton used by all boundary commit paths, and a read_singleton helper for direct reads. The two gov birth paths now share GovState::seed_genesis so they can't drift. No behavior change; the estart gov tests now exercise the generic path. Co-Authored-By: Claude Fable 5 --- crates/cardano/src/estart/commit.rs | 71 ++++++++-------------------- crates/cardano/src/ewrap/commit.rs | 53 +++------------------ crates/cardano/src/ewrap/loading.rs | 7 ++- crates/cardano/src/genesis/mod.rs | 17 +++---- crates/cardano/src/lib.rs | 20 +++++--- crates/cardano/src/model/epochs.rs | 34 +++++++------ crates/cardano/src/model/gov.rs | 20 +++++--- crates/cardano/src/model/mod.rs | 23 ++++++++- crates/cardano/src/roll/batch.rs | 45 ++++++++++++++++-- crates/cardano/src/roll/mod.rs | 8 ++-- crates/cardano/src/rupd/work_unit.rs | 7 +-- 11 files changed, 156 insertions(+), 149 deletions(-) diff --git a/crates/cardano/src/estart/commit.rs b/crates/cardano/src/estart/commit.rs index 1c2d9070f..157759bab 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, - GovState, PoolState, ProposalState, GOV_STATE_KEY, + GovState, PoolState, ProposalState, }; /// Era transition data collected from state. @@ -114,41 +114,6 @@ impl super::WorkContext { Ok(()) } - /// Apply any queued deltas for the governance singleton. - /// - /// Unlike `stream_and_apply_namespace`, this handles the entity being - /// absent: the `GovGenesisInit` delta emitted at the Conway era boundary - /// *creates* the singleton, so streaming existing records would silently - /// drop it. - fn apply_gov_state_deltas( - &mut self, - state: &D::State, - writer: &::Writer, - ) -> Result<(), ChainError> { - let entity_key = EntityKey::from(GOV_STATE_KEY); - - let to_apply = self - .deltas - .entities - .remove(&NsKey::from((GovState::NS, entity_key.clone()))); - - let Some(to_apply) = to_apply else { - return Ok(()); - }; - - let mut entity: Option = state - .read_entity_typed::(GovState::NS, &entity_key)? - .map(Into::into); - - for mut delta in to_apply { - delta.apply(&mut entity); - } - - writer.save_entity_typed(GovState::NS, &entity_key, entity.as_ref())?; - - Ok(()) - } - /// Commit a single per-shard run: stream-and-apply per-account snapshot /// transitions for the shard's key ranges, then commit the /// `EStartProgress` delta against `EpochState`. Archive logs @@ -177,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()); @@ -257,12 +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)?; - // Governance singleton — targeted apply (the entity may not exist - // yet; the Conway-boundary `GovGenesisInit` creates it). - self.apply_gov_state_deltas::(state, &writer)?; + // The governance row may not exist yet — the Conway-boundary + // `GovGenesisInit` creates it, which the singleton path handles. + self.deltas.apply_singleton::(state, &writer)?; // Write era transition if needed (only 2 entities) if let Some(transition) = era_transition { @@ -303,7 +270,9 @@ mod tests { use dolos_core::{Domain as _, StateStore as _}; use dolos_testing::toy_domain::ToyDomain; - use crate::{gov_from_conway_genesis, ChainSummary, EraProtocol, GovGenesisInit}; + use crate::{ + gov_from_conway_genesis, ChainSummary, EraProtocol, GovGenesisInit, SingletonEntity as _, + }; use super::*; @@ -322,12 +291,12 @@ mod tests { fn read_gov(domain: &ToyDomain) -> Option { domain .state() - .read_entity_typed::(GovState::NS, &EntityKey::from(GOV_STATE_KEY)) + .read_entity_typed::(GovState::NS, &GovState::singleton_key()) .unwrap() } - /// The targeted gov commit path must create the singleton when it does - /// not exist yet — `stream_and_apply_namespace` would silently drop the + /// The singleton apply path must create the entity when it does not + /// exist yet — `stream_and_apply_namespace` would silently drop the /// `GovGenesisInit` emitted at the Conway boundary. #[test] fn gov_deltas_create_absent_singleton() { @@ -338,7 +307,7 @@ mod tests { // store reaching the Chang boundary without one let writer = state.start_writer().unwrap(); writer - .delete_entity(GovState::NS, &EntityKey::from(GOV_STATE_KEY)) + .delete_entity(GovState::NS, &GovState::singleton_key()) .unwrap(); writer.commit().unwrap(); assert_eq!(read_gov(&domain), None); @@ -349,7 +318,8 @@ mod tests { ctx.add_delta(GovGenesisInit::new(constitution.clone(), committee.clone())); let writer = state.start_writer().unwrap(); - ctx.apply_gov_state_deltas::(state, &writer) + ctx.deltas + .apply_singleton::(state, &writer) .unwrap(); writer.commit().unwrap(); @@ -373,7 +343,8 @@ mod tests { let mut ctx = empty_context(&domain); let writer = state.start_writer().unwrap(); - ctx.apply_gov_state_deltas::(state, &writer) + ctx.deltas + .apply_singleton::(state, &writer) .unwrap(); writer.commit().unwrap(); diff --git a/crates/cardano/src/ewrap/commit.rs b/crates/cardano/src/ewrap/commit.rs index 8012d86d2..e0ff5c353 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 72566bdf1..785814841 100644 --- a/crates/cardano/src/ewrap/loading.rs +++ b/crates/cardano/src/ewrap/loading.rs @@ -16,12 +16,12 @@ use pallas::ledger::primitives::StakeCredential; use crate::{ ewrap::{BoundaryVisitor as _, BoundaryWork}, - load_era_summary, pallas_extras, + load_era_summary, pallas_extras, read_singleton, rewards::{Reward, RewardMap}, roll::WorkDeltas, rupd::credential_to_key, AccountState, DRepState, EraProtocol, FixedNamespace as _, GovState, PendingMirState, - PendingRewardState, PoolState, ProposalState, GOV_STATE_KEY, + PendingRewardState, PoolState, ProposalState, }; impl BoundaryWork { @@ -37,8 +37,7 @@ impl BoundaryWork { let active_protocol = EraProtocol::from(chain_summary.edge().protocol); let incentives = ending_state.incentives.clone().unwrap_or_default(); - let num_dormant_epochs = state - .read_entity_typed::(GovState::NS, &EntityKey::from(GOV_STATE_KEY))? + let num_dormant_epochs = read_singleton::(state)? .map(|gov| gov.num_dormant_epochs) .unwrap_or_default(); diff --git a/crates/cardano/src/genesis/mod.rs b/crates/cardano/src/genesis/mod.rs index 3f4aff607..fda34468c 100644 --- a/crates/cardano/src/genesis/mod.rs +++ b/crates/cardano/src/genesis/mod.rs @@ -6,7 +6,7 @@ use dolos_core::{ use crate::{ 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, CURRENT_EPOCH_KEY, GOV_STATE_KEY, + 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) @@ -134,14 +134,11 @@ pub fn bootstrap_gov(state: &D::State, genesis: &Genesis) -> Result<( let (constitution, committee) = gov_from_conway_genesis(&genesis.conway)?; - let gov = GovState { - constitution: Some(constitution), - committee: Some(committee), - ..Default::default() - }; + let mut gov = GovState::default(); + gov.seed_genesis(constitution, committee); let writer = state.start_writer()?; - writer.write_entity_typed(&EntityKey::from(GOV_STATE_KEY), &gov)?; + writer.write_entity_typed(&GovState::singleton_key(), &gov)?; writer.commit()?; Ok(()) @@ -210,14 +207,14 @@ mod tests { use dolos_testing::toy_domain::ToyDomain; use std::sync::Arc; - use crate::{FixedNamespace as _, GovState, GOV_STATE_KEY}; + use crate::{FixedNamespace as _, GovState}; use super::*; fn read_gov(domain: &ToyDomain) -> Option { domain .state() - .read_entity_typed::(GovState::NS, &EntityKey::from(GOV_STATE_KEY)) + .read_entity_typed::(GovState::NS, &GovState::singleton_key()) .unwrap() } diff --git a/crates/cardano/src/lib.rs b/crates/cardano/src/lib.rs index 225eab465..8e3c4e05d 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::{ @@ -575,11 +575,17 @@ 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) +} - Ok(epoch) +/// Read a singleton entity directly at its fixed key. +/// +/// `None` is a legitimate state for singletons born mid-chain — e.g. +/// `GovState` on a store that hasn't crossed the Conway boundary. +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/model/epochs.rs b/crates/cardano/src/model/epochs.rs index 9b55cd55a..cd5a5daef 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/gov.rs b/crates/cardano/src/model/gov.rs index 8b942c660..1b65f6c0e 100644 --- a/crates/cardano/src/model/gov.rs +++ b/crates/cardano/src/model/gov.rs @@ -15,7 +15,7 @@ use std::collections::BTreeMap; -use dolos_core::{BlockSlot, EntityKey, NsKey}; +use dolos_core::{BlockSlot, NsKey}; use pallas::{ codec::minicbor::{self, Decode, Encode}, ledger::primitives::{ @@ -25,7 +25,7 @@ use pallas::{ }; use serde::{Deserialize, Serialize}; -use super::FixedNamespace as _; +use super::SingletonEntity; /// Key of the single `GovState` entity inside the `"gov"` namespace. pub const GOV_STATE_KEY: &[u8] = b"0"; @@ -123,9 +123,18 @@ pub struct GovState { entity_boilerplate!(GovState, "gov"); +impl SingletonEntity for GovState { + const KEY: &'static [u8] = GOV_STATE_KEY; +} + impl GovState { - pub fn ns_key() -> NsKey { - NsKey::from((Self::NS, EntityKey::from(GOV_STATE_KEY))) + /// Seed the enact-state from the Conway genesis (the initial + /// constitution and committee). Shared by the two birth 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) { + self.constitution = Some(constitution); + self.committee = Some(committee); } /// Effective authorization of `cold` as of the latest event. @@ -252,8 +261,7 @@ impl dolos_core::EntityDelta for GovGenesisInit { let state = entity.get_or_insert_with(GovState::default); - state.constitution = Some(self.constitution.clone()); - state.committee = Some(self.committee.clone()); + state.seed_genesis(self.constitution.clone(), self.committee.clone()); } fn undo(&self, entity: &mut Option) { diff --git a/crates/cardano/src/model/mod.rs b/crates/cardano/src/model/mod.rs index 06da8585e..541816766 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 { diff --git a/crates/cardano/src/roll/batch.rs b/crates/cardano/src/roll/batch.rs index 66cbbfa00..0ebe29a80 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/mod.rs b/crates/cardano/src/roll/mod.rs index 958eda686..266470e7c 100644 --- a/crates/cardano/src/roll/mod.rs +++ b/crates/cardano/src/roll/mod.rs @@ -17,8 +17,9 @@ use pallas::{ use tracing::{debug, instrument}; use crate::{ - load_effective_pparams, owned::OwnedMultiEraOutput, roll::proposals::ProposalVisitor, utxoset, - Cache, DRepState, FixedNamespace as _, GovState, PParamsSet, GOV_STATE_KEY, + load_effective_pparams, owned::OwnedMultiEraOutput, read_singleton, + roll::proposals::ProposalVisitor, utxoset, Cache, DRepState, FixedNamespace as _, GovState, + PParamsSet, }; // Sub-modules @@ -572,8 +573,7 @@ pub(crate) fn compute_delta( // 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: state - .read_entity_typed::(GovState::NS, &EntityKey::from(GOV_STATE_KEY))? + dormant_epochs: read_singleton::(state)? .map(|gov| gov.num_dormant_epochs) .unwrap_or_default(), drep_keys: Default::default(), diff --git a/crates/cardano/src/rupd/work_unit.rs b/crates/cardano/src/rupd/work_unit.rs index 28624a704..3d3b06d72 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)? From 525c5e0269d859d560a7abc5f127ae74807c0a09 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 25 Jul 2026 21:49:54 -0300 Subject: [PATCH 4/4] refactor(cardano): make gov singleton existence an invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gov singleton had conditional, era-dependent existence: born at genesis only for forced-Conway devnets, at the Chang boundary otherwise, and never on in-place-upgraded stores. Every delta and read paid for that with absence tolerance — was_new undo flags, get_or_insert apply paths, unwrap_or_default reads — a tax each future gov delta would inherit. Flip the model to an invariant plus a migration: - The row now exists on every store regardless of era. Bootstrap always creates it; a new active_since field (None = pre-Conway) flags whether governance is enabled, set to 0 for forced-Conway networks and to the boundary epoch by GovGenesisInit at Chang. - A one-time startup migration (CARDANO-006) creates the row on stores bootstrapped before the invariant, deriving active_since from the era summary via the new ChainSummary::first_conway_epoch. Enact-state fields stay unset — content missed since the fork only comes from a fresh sync. Runs in CardanoLogic::initialize, before WAL catch-up replays deltas that now expect the row. - Deltas assert existence: GovGenesisInit activates (and undoes to) the inactive row instead of creating/removing it; CommitteeAuth, CommitteeResign and GovDormancyReset drop their was_new machinery. - Reads assert too: load_gov replaces the tolerant unwrap_or_default reads, erroring with CARDANO-007 (MissingGovState) on a broken store. Note for dev environments: stores synced with earlier commits of this branch must be wiped — the WAL shapes of the gov deltas changed and existing gov rows carry no active_since. No released store is affected. Co-Authored-By: Claude Fable 5 --- crates/cardano/src/eras.rs | 56 ++++++++ crates/cardano/src/estart/commit.rs | 28 ++-- crates/cardano/src/estart/loading.rs | 15 ++- crates/cardano/src/ewrap/loading.rs | 10 +- crates/cardano/src/genesis/mod.rs | 28 ++-- crates/cardano/src/lib.rs | 19 ++- crates/cardano/src/migrate.rs | 136 +++++++++++++++++++ crates/cardano/src/model/gov.rs | 192 ++++++++++++++++----------- crates/cardano/src/roll/mod.rs | 9 +- crates/core/src/lib.rs | 3 + crates/testing/src/toy_domain.rs | 7 + 11 files changed, 381 insertions(+), 122 deletions(-) create mode 100644 crates/cardano/src/migrate.rs diff --git a/crates/cardano/src/eras.rs b/crates/cardano/src/eras.rs index b5d4bd2e4..c349b9dc3 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 157759bab..36ae6b8dc 100644 --- a/crates/cardano/src/estart/commit.rs +++ b/crates/cardano/src/estart/commit.rs @@ -227,8 +227,8 @@ impl super::WorkContext { self.deltas .apply_singleton::(state, &writer)?; - // The governance row may not exist yet — the Conway-boundary - // `GovGenesisInit` creates it, which the singleton path handles. + // 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) @@ -295,27 +295,30 @@ mod tests { .unwrap() } - /// The singleton apply path must create the entity when it does not - /// exist yet — `stream_and_apply_namespace` would silently drop the - /// `GovGenesisInit` emitted at the Conway boundary. + /// `GovGenesisInit` applied through the singleton path activates the + /// existing (inactive) row with the genesis enact-state. #[test] - fn gov_deltas_create_absent_singleton() { + fn gov_genesis_init_activates_existing_singleton() { let domain = ToyDomain::new(None, None); let state = domain.state(); - // the devnet bootstrap seeds the entity; delete it to simulate a - // store reaching the Chang boundary without one + // 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 - .delete_entity(GovState::NS, &GovState::singleton_key()) + .write_entity_typed(&GovState::singleton_key(), &GovState::default()) .unwrap(); writer.commit().unwrap(); - assert_eq!(read_gov(&domain), None); + 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())); + ctx.add_delta(GovGenesisInit::new( + constitution.clone(), + committee.clone(), + 507, + )); let writer = state.start_writer().unwrap(); ctx.deltas @@ -323,9 +326,10 @@ mod tests { .unwrap(); writer.commit().unwrap(); - let gov = read_gov(&domain).expect("singleton created by delta"); + 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()); diff --git a/crates/cardano/src/estart/loading.rs b/crates/cardano/src/estart/loading.rs index 1f8afa34c..a5cd15302 100644 --- a/crates/cardano/src/estart/loading.rs +++ b/crates/cardano/src/estart/loading.rs @@ -67,15 +67,20 @@ impl WorkContext { // all per-entity transitions have been queued. super::reset::emit_epoch_transition(self); - // Entering Conway (the Chang hard fork) seeds the governance + // 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. Stores - // already past the boundary never see this event (documented - // in-place upgrade gap; a fresh sync captures it). + // 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.add_delta(GovGenesisInit::new( + constitution, + committee, + self.starting_epoch_no(), + )); } } diff --git a/crates/cardano/src/ewrap/loading.rs b/crates/cardano/src/ewrap/loading.rs index 785814841..8aa87e116 100644 --- a/crates/cardano/src/ewrap/loading.rs +++ b/crates/cardano/src/ewrap/loading.rs @@ -16,12 +16,12 @@ use pallas::ledger::primitives::StakeCredential; use crate::{ ewrap::{BoundaryVisitor as _, BoundaryWork}, - load_era_summary, pallas_extras, read_singleton, + load_era_summary, load_gov, pallas_extras, rewards::{Reward, RewardMap}, roll::WorkDeltas, rupd::credential_to_key, - AccountState, DRepState, EraProtocol, FixedNamespace as _, GovState, PendingMirState, - PendingRewardState, PoolState, ProposalState, + AccountState, DRepState, EraProtocol, FixedNamespace as _, PendingMirState, PendingRewardState, + PoolState, ProposalState, }; impl BoundaryWork { @@ -37,9 +37,7 @@ impl BoundaryWork { let active_protocol = EraProtocol::from(chain_summary.edge().protocol); let incentives = ending_state.incentives.clone().unwrap_or_default(); - let num_dormant_epochs = read_singleton::(state)? - .map(|gov| gov.num_dormant_epochs) - .unwrap_or_default(); + let num_dormant_epochs = load_gov::(state)?.num_dormant_epochs; Ok(BoundaryWork { ending_state, diff --git a/crates/cardano/src/genesis/mod.rs b/crates/cardano/src/genesis/mod.rs index fda34468c..e2f967a0e 100644 --- a/crates/cardano/src/genesis/mod.rs +++ b/crates/cardano/src/genesis/mod.rs @@ -124,18 +124,18 @@ pub fn bootstrap_eras(state: &D::State, epoch: &EpochState) -> Result Ok(()) } -/// Seed the governance singleton for networks that force-start at Conway -/// (protocol >= 9) — e.g. devnets. Chains that reach Conway by crossing the -/// Chang hard fork seed it at that era boundary instead (`GovGenesisInit`). +/// 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> { - if genesis.force_protocol.is_none_or(|protocol| protocol < 9) { - return Ok(()); - } - - let (constitution, committee) = gov_from_conway_genesis(&genesis.conway)?; - let mut gov = GovState::default(); - gov.seed_genesis(constitution, committee); + + 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)?; @@ -230,15 +230,19 @@ mod tests { 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_skips_gov_singleton_before_conway() { + 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); - assert_eq!(read_gov(&domain), 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 8e3c4e05d..dfdbd33da 100644 --- a/crates/cardano/src/lib.rs +++ b/crates/cardano/src/lib.rs @@ -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 { @@ -578,10 +584,15 @@ pub fn load_epoch(state: &D::State) -> Result read_singleton::(state)?.ok_or(ChainError::NoActiveEpoch) } -/// Read a singleton entity directly at its fixed key. -/// -/// `None` is a legitimate state for singletons born mid-chain — e.g. -/// `GovState` on a store that hasn't crossed the Conway boundary. +/// 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) +} + +/// 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> { diff --git a/crates/cardano/src/migrate.rs b/crates/cardano/src/migrate.rs new file mode 100644 index 000000000..760e5ecd1 --- /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/gov.rs b/crates/cardano/src/model/gov.rs index 1b65f6c0e..1c8330cc0 100644 --- a/crates/cardano/src/model/gov.rs +++ b/crates/cardano/src/model/gov.rs @@ -6,12 +6,18 @@ //! 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`], seeded -//! from the Conway genesis at the era boundary that enters protocol 9 -//! (or at bootstrap for networks that force-start in Conway). Stores -//! upgraded in place past that boundary simply have no entity until an -//! event creates one — the documented "governance history absent" gap for -//! in-place upgrades; complete state comes from a fresh sync. +//! 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; @@ -30,6 +36,11 @@ 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)] @@ -91,14 +102,14 @@ pub struct GovRoots { #[derive(Debug, Encode, Decode, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct GovState { - /// The enacted constitution. `None` only when the entity was created - /// lazily by a committee certificate on a store that never crossed the - /// Conway boundary with this code (in-place upgrade gap). + /// 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 the - /// same in-place upgrade gap as `constitution`. + /// The enacted committee. `None` means the no-confidence state — or, + /// as with `constitution`, pre-activation / the migration gap. #[n(1)] pub committee: Option, @@ -119,6 +130,15 @@ pub struct GovState { #[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"); @@ -128,13 +148,19 @@ impl SingletonEntity for GovState { } impl GovState { - /// Seed the enact-state from the Conway genesis (the initial - /// constitution and committee). Shared by the two birth 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) { + /// 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. @@ -226,25 +252,32 @@ pub fn gov_from_conway_genesis( // --- Deltas --- -/// Seed the governance singleton with the Conway genesis constitution and -/// committee. Emitted once, at the era boundary that enters protocol 9 -/// (Chang); networks that force-start in Conway seed the entity directly at -/// bootstrap instead. +/// 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: Option, + 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) -> Self { + pub fn new(constitution: Constitution, committee: Committee, epoch: Epoch) -> Self { Self { constitution, committee, - prev: None, + epoch, + prev_constitution: None, + prev_committee: None, + prev_active_since: None, } } } @@ -257,15 +290,25 @@ impl dolos_core::EntityDelta for GovGenesisInit { } fn apply(&mut self, entity: &mut Option) { - self.prev = entity.clone(); + let state = entity.as_mut().expect(GOV_MUST_EXIST); - let state = entity.get_or_insert_with(GovState::default); + 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()); + state.seed_genesis( + self.constitution.clone(), + self.committee.clone(), + self.epoch, + ); } fn undo(&self, entity: &mut Option) { - *entity = self.prev.clone(); + 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; } } @@ -279,7 +322,6 @@ pub struct CommitteeAuth { pub(crate) slot: BlockSlot, // undo - pub(crate) was_new: bool, pub(crate) created_entry: bool, } @@ -289,7 +331,6 @@ impl CommitteeAuth { cold, hot, slot, - was_new: false, created_entry: false, } } @@ -305,7 +346,6 @@ pub struct CommitteeResign { pub(crate) slot: BlockSlot, // undo - pub(crate) was_new: bool, pub(crate) created_entry: bool, } @@ -315,21 +355,20 @@ impl CommitteeResign { cold, anchor, slot, - was_new: false, 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, bool) { - let was_new = entity.is_none(); - - let state = entity.get_or_insert_with(GovState::default); +) -> bool { + let state = entity.as_mut().expect(GOV_MUST_EXIST); let created_entry = !state.committee_auths.contains_key(cold); @@ -339,18 +378,11 @@ fn push_auth( .or_default() .push((slot, auth)); - (was_new, created_entry) + created_entry } -fn pop_auth(entity: &mut Option, cold: &StakeCredential, was_new: bool, created: bool) { - if was_new { - *entity = None; - return; - } - - let Some(state) = entity.as_mut() else { - return; - }; +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(); @@ -369,19 +401,16 @@ impl dolos_core::EntityDelta for CommitteeAuth { } fn apply(&mut self, entity: &mut Option) { - let (was_new, created_entry) = push_auth( + self.created_entry = push_auth( entity, &self.cold, self.slot, CommitteeAuthorization::HotCredential(self.hot.clone()), ); - - self.was_new = was_new; - self.created_entry = created_entry; } fn undo(&self, entity: &mut Option) { - pop_auth(entity, &self.cold, self.was_new, self.created_entry); + pop_auth(entity, &self.cold, self.created_entry); } } @@ -393,19 +422,16 @@ impl dolos_core::EntityDelta for CommitteeResign { } fn apply(&mut self, entity: &mut Option) { - let (was_new, created_entry) = push_auth( + self.created_entry = push_auth( entity, &self.cold, self.slot, CommitteeAuthorization::Resigned(self.anchor.clone()), ); - - self.was_new = was_new; - self.created_entry = created_entry; } fn undo(&self, entity: &mut Option) { - pop_auth(entity, &self.cold, self.was_new, self.created_entry); + pop_auth(entity, &self.cold, self.created_entry); } } @@ -415,16 +441,12 @@ impl dolos_core::EntityDelta for CommitteeResign { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GovDormancyReset { // undo - pub(crate) was_new: bool, pub(crate) prev: u64, } impl GovDormancyReset { pub fn new() -> Self { - Self { - was_new: false, - prev: 0, - } + Self { prev: 0 } } } @@ -442,20 +464,16 @@ impl dolos_core::EntityDelta for GovDormancyReset { } fn apply(&mut self, entity: &mut Option) { - self.was_new = entity.is_none(); - - let state = entity.get_or_insert_with(GovState::default); + 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) { - if self.was_new { - *entity = None; - } else if let Some(state) = entity { - state.num_dormant_epochs = self.prev; - } + let state = entity.as_mut().expect(GOV_MUST_EXIST); + + state.num_dormant_epochs = self.prev; } } @@ -520,6 +538,7 @@ pub(crate) mod testing { ), 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, @@ -527,6 +546,7 @@ pub(crate) mod testing { committee_auths, prev_gov_action_ids, num_dormant_epochs, + active_since, } } } @@ -576,6 +596,7 @@ mod tests { ..Default::default() }, num_dormant_epochs: 3, + active_since: Some(507), }; let bytes = minicbor::to_vec(&state).unwrap(); @@ -658,8 +679,9 @@ mod prop_tests { fn any_genesis_init()( constitution in any_constitution(), committee in any_committee(), + epoch in root::any_epoch(), ) -> GovGenesisInit { - GovGenesisInit::new(constitution, committee) + GovGenesisInit::new(constitution, committee, epoch) } } @@ -693,7 +715,7 @@ mod prop_tests { #[test] fn genesis_init_roundtrip( - entity in prop::option::of(any_gov_state()), + entity in any_gov_state().prop_map(Some), delta in any_genesis_init(), ) { assert_delta_roundtrip(entity, delta); @@ -701,7 +723,7 @@ mod prop_tests { #[test] fn committee_auth_roundtrip( - entity in prop::option::of(any_gov_state()), + entity in any_gov_state().prop_map(Some), delta in any_committee_auth(), ) { assert_delta_roundtrip(entity, delta); @@ -709,7 +731,7 @@ mod prop_tests { #[test] fn committee_auth_serde_roundtrip( - entity in prop::option::of(any_gov_state()), + entity in any_gov_state().prop_map(Some), delta in any_committee_auth(), ) { root::assert_delta_serde_roundtrip(entity, delta); @@ -717,7 +739,7 @@ mod prop_tests { #[test] fn committee_resign_roundtrip( - entity in prop::option::of(any_gov_state()), + entity in any_gov_state().prop_map(Some), delta in any_committee_resign(), ) { assert_delta_roundtrip(entity, delta); @@ -725,7 +747,7 @@ mod prop_tests { #[test] fn committee_resign_serde_roundtrip( - entity in prop::option::of(any_gov_state()), + entity in any_gov_state().prop_map(Some), delta in any_committee_resign(), ) { root::assert_delta_serde_roundtrip(entity, delta); @@ -733,10 +755,25 @@ mod prop_tests { #[test] fn dormancy_reset_roundtrip( - entity in prop::option::of(any_gov_state()), + 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] @@ -746,7 +783,7 @@ mod prop_tests { let cold = StakeCredential::ScriptHash([1u8; 28].into()); let hot = StakeCredential::AddrKeyhash([2u8; 28].into()); - let mut entity: Option = None; + 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); @@ -766,6 +803,7 @@ mod prop_tests { resign.undo(&mut entity); auth.undo(&mut entity); - assert!(entity.is_none()); + // undo restores the pristine row — never removes it + assert_eq!(entity, Some(GovState::default())); } } diff --git a/crates/cardano/src/roll/mod.rs b/crates/cardano/src/roll/mod.rs index 266470e7c..eb8ce864c 100644 --- a/crates/cardano/src/roll/mod.rs +++ b/crates/cardano/src/roll/mod.rs @@ -17,9 +17,8 @@ use pallas::{ use tracing::{debug, instrument}; use crate::{ - load_effective_pparams, owned::OwnedMultiEraOutput, read_singleton, - roll::proposals::ProposalVisitor, utxoset, Cache, DRepState, FixedNamespace as _, GovState, - PParamsSet, + load_effective_pparams, load_gov, owned::OwnedMultiEraOutput, roll::proposals::ProposalVisitor, + utxoset, Cache, DRepState, FixedNamespace as _, PParamsSet, }; // Sub-modules @@ -573,9 +572,7 @@ pub(crate) fn compute_delta( // 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: read_singleton::(state)? - .map(|gov| gov.num_dormant_epochs) - .unwrap_or_default(), + dormant_epochs: load_gov::(state)?.num_dormant_epochs, drep_keys: Default::default(), batch_registrations: Default::default(), }; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ce1b61a8f..961c21a1e 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 fa24042a0..ef4420e80 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();