Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion crates/cardano/src/estart/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<D: Domain>(
&mut self,
state: &D::State,
writer: &<D::State as StateStore>::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<CardanoEntity> = state
.read_entity_typed::<GovState>(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
Expand Down Expand Up @@ -225,6 +260,10 @@ impl super::WorkContext {
debug!("streaming epoch entities");
self.stream_and_apply_namespace::<D, EpochState>(state, &writer, None)?;

// Governance singleton — targeted apply (the entity may not exist
// yet; the Conway-boundary `GovGenesisInit` creates it).
self.apply_gov_state_deltas::<D>(state, &writer)?;

// Write era transition if needed (only 2 entities)
if let Some(transition) = era_transition {
writer
Expand Down
18 changes: 15 additions & 3 deletions crates/cardano/src/estart/loading.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(())
}

Expand Down
23 changes: 21 additions & 2 deletions crates/cardano/src/ewrap/loading.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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>(GovState::NS, &EntityKey::from(GOV_STATE_KEY))?
.map(|gov| gov.num_dormant_epochs)
.unwrap_or_default();

Ok(BoundaryWork {
ending_state,
chain_summary,
Expand All @@ -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(),
Expand Down Expand Up @@ -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());
}
Comment on lines +272 to +280

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Any writer of num_dormant_epochs other than the reset delta
rg -nP -C4 'num_dormant_epochs' crates/cardano/src

Repository: txpipe/dolos

Length of output: 8729


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Files referencing GovDormancyReset / num_dormant_epochs in model/gov.rs =="
rg -n -C 6 'GovDormancyReset|num_dormant_epochs|DRepDormancyRelease' crates/cardano/src/model/gov.rs

echo
echo "== Write/delete/usages in cardano crate =="
rg -n 'num_dormant_epochs|GovState' crates/cardano/src | sed -n '1,220p'

echo
 echo "== Candidate model functions around GovDormancyReset =="
sed -n '360,490p' crates/cardano/src/model/gov.rs

Repository: txpipe/dolos

Length of output: 12842


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== All references to GovDormancyReset =="
rg -n -C 4 'GovDormancyReset|DRepDormancyRelease' crates/cardano/src || true

echo
echo "== Cardano model delta variants involving gov/dreps =="
sed -n '60,185p' crates/cardano/src/model/mod.rs

Repository: txpipe/dolos

Length of output: 22229


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== roll modulo declarations for dreps/governance =="
sed -n '1,120p' crates/cardano/src/roll/mod.rs
echo
sed -n '120,230p' crates/cardato/src/roll/mod.rs 2>/dev/null || sed -n '120,230p' crates/cardano/src/roll/mod.rs

echo
echo "== All references to DormancyContext / dormant_epochs =="
rg -n -C 4 'DormancyContext|dormant_epochs' crates/cardano/src

Repository: txpipe/dolos

Length of output: 24989


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== DRepStateVisitor proposal/cert handling =="
sed -n '110,175p' crates/cardano/src/roll/dreps.rs

echo
echo "== Any non-test mutation of num_dormant_epochs/dormant_epochs in .rs =="
python3 - <<'PY'
from pathlib import Path
import re
for path in sorted(Path('crates/cardano/src').rglob('*.rs')):
    text = path.read_text()
    if re.search(r'\bnum_dormant_epochs\s*=\s*|^\.num_dormant_epochs\s*=', text, re.M) or re.search(r'\bdormant_epochs\s*=\s*|^\.dormant_epochs\s*=', text, re.M):
        lines = text.splitlines()
        for i,line in enumerate(lines,1):
            if re.search(r'num_dormant_epochs\s*[+\-*/&^<>=]=|dormant_epochs\s*[+\-*/&^<>=]=', line) or (re.search(r'^\s*(num_dormant_epochs|dormant_epochs)\s*=', line) and 'let mut' in text[:len(''.join(lines[:i]))].count('let mut') % 2 == 0):
                print(f'{path}:{i}:{line}')
PY

Repository: txpipe/dolos

Length of output: 2581


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== All write sites for the relevant dormant counters =="
rg -n -C 3 'num_dormant_epochs\s*[+\-*/&^<>=]?=\s*|dormant_epochs\s*[+\-*/&^<>=]?=\s*' crates/cardano/src

echo
echo "== DeltaBuilder construction / iteration around BlockVisitor calls =="
sed -n '230,320p' crates/cardano/src/roll/mod.rs

Repository: txpipe/dolos

Length of output: 5264


Increment the dormant-epoch counter on epoch/transaction boundaries with no new proposals.

Every writer of GovState::num_dormant_epochs only resets it, so epochs without live proposals keep the boundary value at 0. That makes this expiry adjustment and the DRepDormancyRelease fan-out only work if the previous epoch ended with a non-zero counter; the missing increment should preserve/drop the per-epoch activity state and account for multi-tx zero-proposal epochs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cardano/src/ewrap/loading.rs` around lines 275 - 283, Update the
GovState::num_dormant_epochs transition logic at epoch/transaction boundaries so
it increments when no live proposals are present, rather than only resetting.
Preserve reset behavior when new proposals occur, and ensure the updated counter
is used by the expiry adjustment and DRepDormancyRelease fan-out across
consecutive zero-proposal epochs and transactions.


// 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());
Expand Down
5 changes: 5 additions & 0 deletions crates/cardano/src/ewrap/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ pub struct BoundaryWork {
pub retiring_dreps: Vec<DRep>,
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)>,
Expand Down
31 changes: 28 additions & 3 deletions crates/cardano/src/genesis/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -124,6 +124,29 @@ pub fn bootstrap_eras<D: Domain>(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<D: Domain>(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<D: Domain>(
state: &D::State,
indexes: &D::Indexes,
Expand Down Expand Up @@ -163,6 +186,8 @@ pub fn execute<D: Domain>(

bootstrap_eras::<D>(state, &epoch)?;

bootstrap_gov::<D>(state, genesis)?;

bootstrap_utxos::<D>(state, indexes, genesis, config)?;

staking::bootstrap::<D>(state, genesis)?;
Expand Down
Loading
Loading