Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
de9eb3b
test: support seed data for test instances
scarmuega Feb 2, 2026
fb6259d
test: improve csv compare output
scarmuega Feb 2, 2026
7368487
test(cardano): create harness and tests for state trantisions
scarmuega Feb 3, 2026
dc3f369
fix(cardano): add mainnet pointer addresses and delayed pparams
scarmuega Feb 3, 2026
c69c1eb
chore(cardano): expose work unit internals for testing
scarmuega Feb 3, 2026
17eaa49
fix(cardano): allow total collateral calc on non-alonzo eras
scarmuega Feb 3, 2026
add06c9
fix(cardano): delay rupd until after the first Shelley epoch
scarmuega Feb 3, 2026
55e958e
fix(cardano): auto-register accounts that receive MIRs
scarmuega Feb 3, 2026
bc40cf8
fix(cardano): adjust ledger to match epochs up to mainnet-214
scarmuega Feb 3, 2026
b7817e2
fix(cardano): adjust ledger to match epochs up to mainnet-215
scarmuega Feb 3, 2026
b0126e1
fix(cardano): adjust ledger to match epochs up to mainnet-219
scarmuega Feb 3, 2026
d4d401d
fix(cardano): adjust ledger to match epochs up to mainnet-220
scarmuega Feb 3, 2026
5c930b2
fix(cardano): adjust ledger to match epochs up to mainnet-235
scarmuega Feb 4, 2026
07791d3
fix(cardano): adjust ledger to match epochs for Allegra transition (A…
scarmuega Feb 4, 2026
9747b53
fix(cardano): adjust ledger to match epochs for mainnet-239 (overlay …
scarmuega Feb 5, 2026
4641ead
fix(cardano): adjust ledger to match epochs for 239 (re-registration …
scarmuega Feb 5, 2026
e4e6960
fix(cardano): adjust ledger to match epochs for 242 (MIR from treasury)
scarmuega Feb 5, 2026
2ba50fb
fix(cardano): adjust ledger to match epochs for 243 (mystery pot delt…
scarmuega Feb 5, 2026
8372bd6
test(cardano): tidy up epoch debugging mechanics
scarmuega Feb 5, 2026
71368da
chore(testing): add progress visibility for ground truth xtask
scarmuega Feb 7, 2026
ebd00cf
fix(cardano): adjust ledger to match epochs for 286 (more mystery pot…
scarmuega Feb 7, 2026
b309e4a
fix(cardano): use correct pointer mappings for mainnet (up to 289)
scarmuega Feb 7, 2026
85be540
docs(cardano): include handling of missing pointer addresses in ledge…
scarmuega Feb 7, 2026
0c4559c
test(cardano): simplify epoch tests by introducing macro
scarmuega Feb 7, 2026
fc1da6e
fix(cardano): defer MIR evaluation to end of epoch
scarmuega Feb 8, 2026
41b8e3a
fix(cardano): handle MIR overwrite according to eras (up to 286)
scarmuega Feb 8, 2026
fdb334e
test(cardano): add more mainnet epoch tests
scarmuega Feb 8, 2026
0af229c
refactor(cardano): parallelize RUPD delegator reward calc
scarmuega Feb 9, 2026
06f7efc
chore(testing): add QoL flags to the xtask commands
scarmuega Feb 9, 2026
83f5073
fix(cardano): revert delta priority mechanism
scarmuega Feb 9, 2026
d3302d9
fix(cardano): add missing pointer addresses to mainnet
scarmuega Feb 12, 2026
13081a0
chore(cardano): fix formatting and update tests
scarmuega Feb 12, 2026
11264f4
chore: tidy up lints and dangling files
scarmuega Feb 12, 2026
e1615e6
Merge branch 'main' into fix/mainnet-200-plus
scarmuega Feb 12, 2026
bc4fbaa
Merge branch 'main' of github.com:txpipe/dolos into fix/mainnet-200-plus
scarmuega Feb 12, 2026
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
11 changes: 10 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,13 @@ nix = { version = "0.30.1", features = ["signal"] }
serde = { workspace = true }
tempfile = "3.20.0"
toml = "0.8.13"
dolos-cardano = { path = "crates/cardano" }
dolos-core = { path = "crates/core" }
dolos-testing = { path = "crates/testing" }
bech32 = { workspace = true }
hex = { workspace = true }
pallas = { workspace = true }
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }

[[test]]
name = "smoke"
Expand All @@ -111,9 +117,12 @@ path = "tests/external/smoke.rs"
name = "cardano"
path = "tests/cardano/main.rs"

[[test]]
name = "epoch_pots"
path = "tests/epoch_pots/main.rs"

[features]
relaxed = ["dolos-cardano/relaxed"]
rupd-snapshot-dump = ["dolos-cardano/rupd-snapshot-dump"]
mithril = ["mithril-client"]
utils = ["comfy-table", "inquire", "toml"]
debug = ["console-subscriber", "tokio/tracing"]
Expand Down
1 change: 0 additions & 1 deletion crates/cardano/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,3 @@ serde_json = "1.0.140"
[features]
include-genesis = []
relaxed = []
rupd-snapshot-dump = ["bech32"]
52 changes: 51 additions & 1 deletion crates/cardano/src/estart/loading.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::sync::Arc;

use dolos_core::{ChainError, Domain, Genesis, StateStore};
use dolos_core::{ChainError, Domain, Genesis, StateStore, TxoRef};

use crate::{
estart::BoundaryVisitor, load_era_summary, roll::WorkDeltas, AccountState, DRepState,
Expand Down Expand Up @@ -54,16 +54,66 @@ impl super::WorkContext {
Ok(())
}

/// Compute the value of unredeemed AVVM UTxOs at the Shelley→Allegra
/// boundary. These UTxOs are removed from the UTxO set and their value
/// returned to reserves, matching the Haskell ledger's `translateEra`.
fn compute_avvm_reclamation<D: Domain>(
state: &D::State,
genesis: &Genesis,
) -> Result<u64, ChainError> {
let avvm_utxos = pallas::ledger::configs::byron::genesis_avvm_utxos(&genesis.byron);

// Collect all Byron genesis AVVM UTxO refs (bootstrap redeemer addresses)
let refs: Vec<TxoRef> = avvm_utxos
.iter()
.map(|(tx, _, _)| TxoRef(*tx, 0))
.collect();

// Query the UTxO set to find which are still unspent
let remaining = state.get_utxos(refs)?;

// Sum the remaining values
let total: u64 = remaining
.values()
.map(|utxo| {
pallas::ledger::traverse::MultiEraOutput::try_from(utxo.as_ref())
.map(|o| o.value().coin())
.unwrap_or(0)
})
.sum();

tracing::warn!(
remaining_count = remaining.len(),
total_avvm = total,
"AVVM reclamation at Shelley→Allegra boundary"
);

Ok(total)
}

pub fn load<D: Domain>(state: &D::State, genesis: Arc<Genesis>) -> Result<Self, ChainError> {
let ended_state = crate::load_epoch::<D>(state)?;
let chain_summary = load_era_summary::<D>(state)?;
let active_protocol = EraProtocol::from(chain_summary.edge().protocol);

// Check for AVVM reclamation at Shelley→Allegra boundary
let avvm_reclamation =
if let Some(transition) = ended_state.pparams.era_transition() {
if transition.entering_allegra() {
Self::compute_avvm_reclamation::<D>(state, &genesis)?
} else {
0
}
} else {
0
};

let mut boundary = Self {
ended_state,
chain_summary,
active_protocol,
genesis,
avvm_reclamation,

// empty until computed
deltas: WorkDeltas::default(),
Expand Down
3 changes: 3 additions & 0 deletions crates/cardano/src/estart/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ pub struct WorkContext {
pub chain_summary: ChainSummary,
pub genesis: Arc<Genesis>,

/// Unredeemed AVVM UTxOs reclaimed at the Shelley→Allegra boundary.
pub avvm_reclamation: u64,

// computed via visitors
pub deltas: WorkDeltas,
pub logs: Vec<(EntityKey, CardanoEntity)>,
Expand Down
43 changes: 41 additions & 2 deletions crates/cardano/src/estart/reset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,15 @@ pub fn define_new_pots(ctx: &super::WorkContext) -> Pots {
proposal_deposits: rolling.proposal_deposits,
drep_refunds: rolling.drep_refunds,
treasury_donations: rolling.treasury_donations,
reserve_mirs: rolling.reserve_mirs,
// Use effective MIR amounts from EndStats (only MIRs applied to registered accounts)
// Rolling stats contain total from MIR certificates, which includes unregistered accounts
reserve_mirs: end.reserve_mirs,
treasury_mirs: end.treasury_mirs,
proposal_refunds: end.proposal_refunds,
proposal_invalid_refunds: end.proposal_invalid_refunds,
effective_rewards: end.effective_rewards,
unspendable_rewards: end.unspendable_rewards,
unspendable_to_treasury: end.unspendable_to_treasury,
unspendable_to_reserves: end.unspendable_to_reserves,
pool_deposit_count: end.pool_deposit_count,
pool_refund_count: end.pool_refund_count,
pool_invalid_refund_count: end.pool_invalid_refund_count,
Expand All @@ -171,8 +175,43 @@ pub fn define_new_pots(ctx: &super::WorkContext) -> Pots {
.mark()
.map(|p| p.protocol_major_or_default())
.unwrap_or_else(|| epoch.pparams.unwrap_live().protocol_major_or_default()),
avvm_reclamation: ctx.avvm_reclamation,
};

tracing::warn!(
epoch = epoch.number,
initial_reserves = epoch.initial_pots.reserves,
initial_treasury = epoch.initial_pots.treasury,
incentives_total = end.epoch_incentives.total,
incentives_treasury_tax = end.epoch_incentives.treasury_tax,
incentives_available_rewards = end.epoch_incentives.available_rewards,
incentives_used_fees = end.epoch_incentives.used_fees,
effective_rewards = end.effective_rewards,
unspendable_to_treasury = end.unspendable_to_treasury,
unspendable_to_reserves = end.unspendable_to_reserves,
consumed_incentives = delta.consumed_incentives(),
returned_rewards = end
.epoch_incentives
.available_rewards
.saturating_sub(delta.consumed_incentives()),
effective_reserve_mirs = end.reserve_mirs,
effective_treasury_mirs = end.treasury_mirs,
invalid_reserve_mirs = end.invalid_reserve_mirs,
invalid_treasury_mirs = end.invalid_treasury_mirs,
pool_invalid_refund_count = end.pool_invalid_refund_count,
proposal_invalid_refunds = end.proposal_invalid_refunds,
treasury_donations = rolling.treasury_donations,
produced_utxos = delta.produced_utxos,
consumed_utxos = delta.consumed_utxos,
initial_utxos = epoch.initial_pots.utxos,
withdrawals = delta.withdrawals,
gathered_fees = delta.gathered_fees,
avvm_reclamation = delta.avvm_reclamation,
protocol_version = delta.protocol_version,
mark_protocol_version = delta.mark_protocol_version,
"pot delta components for ESTART"
);

let pots = apply_delta(epoch.initial_pots.clone(), &end.epoch_incentives, &delta);

tracing::debug!(
Expand Down
5 changes: 5 additions & 0 deletions crates/cardano/src/estart/work_unit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ impl EstartWorkUnit {
context: None,
}
}

/// Access the ended (completed) epoch state, available after load.
pub fn ended_state(&self) -> Option<&crate::EpochState> {
self.context.as_ref().map(|ctx| ctx.ended_state())
}
}

impl<D> WorkUnit<D> for EstartWorkUnit
Expand Down
12 changes: 11 additions & 1 deletion crates/cardano/src/ewrap/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use tracing::{debug, instrument, trace, warn};

use crate::{
ewrap::BoundaryWork, rupd::credential_to_key, AccountState, CardanoEntity, DRepState,
EpochState, FixedNamespace, PendingRewardState, PoolState, ProposalState,
EpochState, FixedNamespace, PendingMirState, PendingRewardState, PoolState, ProposalState,
};

impl BoundaryWork {
Expand Down Expand Up @@ -94,6 +94,16 @@ impl BoundaryWork {
writer.delete_entity(PendingRewardState::NS, &key)?;
}

// Delete processed pending MIRs
debug!(
count = self.applied_mir_credentials.len(),
"deleting processed pending MIRs"
);
for credential in self.applied_mir_credentials.drain(..) {
let key = credential_to_key(&credential);
writer.delete_entity(PendingMirState::NS, &key)?;
}

// Drain remaining unspendable rewards
if !self.rewards.is_empty() {
warn!(
Expand Down
119 changes: 113 additions & 6 deletions crates/cardano/src/ewrap/loading.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@ use std::{collections::HashMap, sync::Arc};

use dolos_core::{BlockSlot, ChainError, Domain, Genesis, StateStore, TxOrder};
use pallas::{codec::minicbor, ledger::primitives::StakeCredential};
use tracing::debug;

use crate::{
ewrap::{BoundaryVisitor as _, BoundaryWork},
load_era_summary, pallas_extras,
pots::EpochIncentives,
rewards::{Reward, RewardMap},
roll::WorkDeltas,
rupd::RupdWork,
AccountState, DRepState, EraProtocol, FixedNamespace as _, PendingRewardState, PoolState,
ProposalState,
rupd::{credential_to_key, RupdWork},
AccountState, DRepState, EraProtocol, FixedNamespace as _, PendingMirState,
PendingRewardState, PoolState, ProposalState,
};

impl BoundaryWork {
Expand All @@ -30,7 +29,16 @@ impl BoundaryWork {
state: &D::State,
pool: &PoolState,
) -> Result<Option<AccountState>, ChainError> {
let account = &pool.snapshot.unwrap_live().params.reward_account;
// Use scheduled (next) params if available, matching the Haskell ledger's
// SNAP → POOLREAP ordering where future pool params become current before
// pool reaping. This ensures the deposit refund goes to the correct reward
// account when a pool is re-registered with a new reward account and then
// retired in the same epoch.
let snapshot = pool
.snapshot
.next()
.unwrap_or_else(|| pool.snapshot.unwrap_live());
let account = &snapshot.params.reward_account;

let account =
pallas_extras::parse_reward_account(account).ok_or(ChainError::InvalidPoolParams)?;
Expand Down Expand Up @@ -162,7 +170,89 @@ impl BoundaryWork {
Ok(())
}

/// Process pending MIRs: check registration status and apply to registered accounts.
/// MIRs to unregistered accounts stay in their source pot (no transfer).
fn process_pending_mirs<D: Domain>(&mut self, state: &D::State) -> Result<(), ChainError> {
let pending_iter =
state.iter_entities_typed::<PendingMirState>(PendingMirState::NS, None)?;

for record in pending_iter {
let (_, pending_mir) = record?;
let credential = &pending_mir.credential;

// Look up the account to check registration status
let account_key = credential_to_key(credential);
let account: Option<AccountState> =
state.read_entity_typed(AccountState::NS, &account_key)?;

// Track that we need to dequeue this pending MIR
self.applied_mir_credentials.push(credential.clone());

if let Some(account) = account {
if account.is_registered() {
// Account is registered at epoch boundary - apply MIR
self.effective_treasury_mirs += pending_mir.from_treasury;
self.effective_reserve_mirs += pending_mir.from_reserves;

// Add MIR amount to account's rewards
let total = pending_mir.total_value();
if total > 0 {
// Create delta to add MIR to account rewards
self.deltas
.add_for_entity(crate::ewrap::rewards::AssignRewards::new(
account_key.clone(),
total,
));

tracing::debug!(
credential = ?credential,
treasury = pending_mir.from_treasury,
reserves = pending_mir.from_reserves,
total,
"MIR applied to registered account"
);
}
} else {
// Account is unregistered at epoch boundary - MIR stays in source pot
self.invalid_treasury_mirs += pending_mir.from_treasury;
self.invalid_reserve_mirs += pending_mir.from_reserves;

tracing::warn!(
credential = ?credential,
treasury = pending_mir.from_treasury,
reserves = pending_mir.from_reserves,
"MIR not applied (unregistered account) - stays in source pot"
);
}
} else {
// Account doesn't exist - MIR stays in source pot
self.invalid_treasury_mirs += pending_mir.from_treasury;
self.invalid_reserve_mirs += pending_mir.from_reserves;

tracing::warn!(
credential = ?credential,
treasury = pending_mir.from_treasury,
reserves = pending_mir.from_reserves,
"MIR not applied (account not found) - stays in source pot"
);
}
}

tracing::info!(
effective_treasury_mirs = self.effective_treasury_mirs,
effective_reserve_mirs = self.effective_reserve_mirs,
invalid_treasury_mirs = self.invalid_treasury_mirs,
invalid_reserve_mirs = self.invalid_reserve_mirs,
"pending MIRs processed"
);

Ok(())
}

pub fn compute_deltas<D: Domain>(&mut self, state: &D::State) -> Result<(), ChainError> {
// Process pending MIRs first (before regular rewards)
self.process_pending_mirs::<D>(state)?;

let mut visitor_enactment = crate::ewrap::enactment::BoundaryVisitor::default();
let mut visitor_rewards = crate::ewrap::rewards::BoundaryVisitor::default();
let mut visitor_drops = crate::ewrap::drops::BoundaryVisitor::default();
Expand Down Expand Up @@ -279,8 +369,19 @@ impl BoundaryWork {
pending.insert(credential, reward);
}

debug!(
let pending_total: u64 = pending.values().map(|r| r.total_value()).sum();
let spendable_total: u64 = pending.values().filter(|r| r.is_spendable()).map(|r| r.total_value()).sum();
let unspendable_total: u64 = pending.values().filter(|r| !r.is_spendable()).map(|r| r.total_value()).sum();
let spendable_count = pending.values().filter(|r| r.is_spendable()).count();
let unspendable_count = pending.len() - spendable_count;

tracing::info!(
pending_count = pending.len(),
%pending_total,
%spendable_count,
%spendable_total,
%unspendable_count,
%unspendable_total,
"loaded pending rewards from state"
);

Expand Down Expand Up @@ -321,6 +422,12 @@ impl BoundaryWork {
deltas: WorkDeltas::default(),
logs: Default::default(),
applied_reward_credentials: Default::default(),
applied_rewards: Default::default(),
effective_treasury_mirs: 0,
effective_reserve_mirs: 0,
invalid_treasury_mirs: 0,
invalid_reserve_mirs: 0,
applied_mir_credentials: Default::default(),
};

boundary.load_pool_data::<D>(state)?;
Expand Down
Loading
Loading