Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 98 additions & 5 deletions 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,
};

/// Era transition data collected from state.
Expand Down Expand Up @@ -142,8 +142,9 @@ impl super::WorkContext {
self.stream_and_apply_namespace::<D, AccountState>(state, &writer, Some(range))?;
}

// EpochState gets the EStartProgress delta (single entity).
self.stream_and_apply_namespace::<D, EpochState>(state, &writer, None)?;
// EpochState gets the EStartProgress delta.
self.deltas
.apply_singleton::<EpochState, _>(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());
Expand Down Expand Up @@ -222,8 +223,13 @@ impl super::WorkContext {
debug!("streaming proposal entities");
self.stream_and_apply_namespace::<D, ProposalState>(state, &writer, None)?;

debug!("streaming epoch entities");
self.stream_and_apply_namespace::<D, EpochState>(state, &writer, None)?;
debug!("applying singleton deltas");
self.deltas
.apply_singleton::<EpochState, _>(state, &writer)?;

// The governance row may not exist yet — the Conway-boundary
// `GovGenesisInit` creates it, which the singleton path handles.
self.deltas.apply_singleton::<GovState, _>(state, &writer)?;

// Write era transition if needed (only 2 entities)
if let Some(transition) = era_transition {
Expand Down Expand Up @@ -258,3 +264,90 @@ impl super::WorkContext {
Ok(())
}
}

#[cfg(test)]
mod tests {
use dolos_core::{Domain as _, StateStore as _};
use dolos_testing::toy_domain::ToyDomain;

use crate::{
gov_from_conway_genesis, ChainSummary, EraProtocol, GovGenesisInit, SingletonEntity as _,
};

use super::*;

fn empty_context(domain: &ToyDomain) -> super::super::WorkContext {
super::super::WorkContext {
ended_state: Default::default(),
active_protocol: EraProtocol::from(9),
chain_summary: ChainSummary::default(),
genesis: domain.genesis(),
avvm_reclamation: 0,
deltas: Default::default(),
logs: Default::default(),
}
}

fn read_gov(domain: &ToyDomain) -> Option<GovState> {
domain
.state()
.read_entity_typed::<GovState>(GovState::NS, &GovState::singleton_key())
.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.
#[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, &GovState::singleton_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.deltas
.apply_singleton::<GovState, _>(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.deltas
.apply_singleton::<GovState, _>(state, &writer)
.unwrap();
writer.commit().unwrap();

assert_eq!(read_gov(&domain), Some(before));
}
}
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
53 changes: 7 additions & 46 deletions crates/cardano/src/ewrap/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<D>(
&mut self,
state: &D::State,
writer: &<D::State as StateStore>::Writer,
) -> Result<Option<EpochState>, ChainError>
where
D: Domain,
{
let records = state.iter_entities_typed::<EpochState>(EpochState::NS, None)?;
let mut applied: Option<EpochState> = 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<CardanoEntity> = 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
Expand All @@ -129,8 +86,9 @@ impl BoundaryWork {
self.stream_and_apply_namespace::<D, AccountState>(state, &writer, Some(range))?;
}

// EpochState gets the EWrapProgress delta (single entity).
self.stream_and_apply_namespace::<D, EpochState>(state, &writer, None)?;
// EpochState gets the EWrapProgress delta.
self.deltas
.apply_singleton::<EpochState, _>(state, &writer)?;

// Delete applied pending rewards.
debug!(
Expand Down Expand Up @@ -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::<D>(state, &writer)? {
if let Some(applied) = self
.deltas
.apply_singleton::<EpochState, _>(state, &writer)?
{
self.ending_state = applied;
}

Expand Down
24 changes: 21 additions & 3 deletions crates/cardano/src/ewrap/loading.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _, PendingMirState, PendingRewardState,
PoolState, ProposalState,
AccountState, DRepState, EraProtocol, FixedNamespace as _, GovState, PendingMirState,
PendingRewardState, PoolState, ProposalState,
};

impl BoundaryWork {
Expand All @@ -37,6 +37,10 @@ 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::<D, GovState>(state)?
.map(|gov| gov.num_dormant_epochs)
.unwrap_or_default();

Ok(BoundaryWork {
ending_state,
chain_summary,
Expand All @@ -48,6 +52,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 +271,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
Loading
Loading