ledger-gotchas

star 131

Reference of non-obvious Cardano ledger behaviors that have caused bugs in Dolos. Consult when debugging epoch pots mismatches or implementing new ledger logic.

txpipe By txpipe schedule Updated 2/27/2026

name: ledger-gotchas description: Reference of non-obvious Cardano ledger behaviors that have caused bugs in Dolos. Consult when debugging epoch pots mismatches or implementing new ledger logic. user-invocable: false

Cardano Ledger Gotchas

Non-obvious behaviors in the Cardano ledger that differ from naive expectations. Each of these has caused at least one bug in Dolos. When debugging a mismatch or implementing new ledger logic, check this list first.


Certificate Processing Order

Gotcha: There is NO priority system for certificates. The Haskell ledger processes certificates in the exact order they appear in the block (tx order, then cert order within tx). A deregistration in tx 7 followed by a registration in tx 10 results in the account being registered.

What went wrong: Dolos had a priority() method that sorted deltas (registration=0, deregistration=5), causing certificates to apply out of order. A dereg+reg in the same slot resulted in the account appearing deregistered.

Rule: Always apply deltas in natural block traversal order. Never sort or reorder them.


NEWEPOCH Sub-Rule Ordering

Gotcha: The three NEWEPOCH sub-rules execute in this exact order:

  1. applyRUpd -- rewards applied to accounts
  2. SNAP -- new mark snapshot captured; future pool params become current
  3. POOLREAP -- pools retired, deposits refunded

Consequence: SNAP happens BEFORE POOLREAP. Pool deposit refunds from POOLREAP are NOT captured in the mark snapshot for that transition. They first appear in the NEXT epoch's mark.

Consequence: SNAP moves future pool params to current BEFORE POOLREAP runs. So when POOLREAP refunds a deposit, it uses the new pool params (potentially a different reward account than when the pool was originally registered).


EpochValue Snapshot Write Targets

Gotcha: Different operations write to different snapshot slots, which propagate to mark at different times.

Operation Writes to Appears in mark
unwrap_live_mut() live Next transition (E+1)
scheduled_or_default() next Two transitions later (E+2)

EWRAP reward visitors use unwrap_live_mut() (writes to live). EWRAP refund visitors (pool deposits, proposal deposits) use scheduled_or_default() (writes to next). This means refunds and rewards propagate to the stake snapshot at different times.


DBSync epoch_stake Mapping

Gotcha: epoch_stake.epoch_no = N corresponds to the set snapshot at epoch N, which equals the mark snapshot captured at NEWEPOCH N-1, which reflects account state as of the end of epoch N-2.

This is the stake distribution used for leader election in epoch N. When comparing dolos stake against DBSync at performance_epoch = subject_epoch - 2, the fixture filename is stake-{performance_epoch}.csv.


Pre-Alonzo MIR Overwrite (protocol < 5)

Gotcha: Before Alonzo, MIR certificates for the same address OVERWRITE previous values rather than accumulating. Haskell uses Map.union (pre-Alonzo) vs Map.unionWith (<>) (Alonzo+).

Example: Two MIR certs for the same address: 100M then 32M. Pre-Alonzo result = 32M (last wins). Alonzo+ result = 132M (sum).

Rule: When protocol_version < 5, set overwrite = true on EnqueueMir. Only the last MIR per address per epoch is applied.


Pre-Allegra Reward Deduplication (protocol < 3)

Gotcha: Before Allegra, each stake address receives at most ONE reward per epoch, even if delegated to multiple pools. The Haskell ledger uses Set.deleteFindMin to keep the minimum element per the Ord Reward instance.

Ord Reward rules:

  1. LeaderReward < MemberReward (leader wins over member)
  2. Same type: smaller pool ID (by hash bytes) wins

Rule: When protocol < 3, if an account has multiple rewards, keep the one with the smallest (reward_type, pool_id) tuple. Leader < Member, then lexicographic pool hash comparison.


Eta Calculation: Pool Blocks Only

Gotcha: The eta (η) monetary expansion calculation uses only pool-produced blocks, NOT total blocks. Federated/OBFT blocks are excluded.

η = min(1, pool_blocks / ((1-d) × f × L))

The Haskell ledger's BlocksMade map only tracks stake pool blocks. Using total blocks_minted (which includes federated blocks) inflates η and produces ~2.75% higher rewards during the decentralization transition.

Rule: Sum blocks_minted from individual pool snapshots only. Do not use rolling.mark().blocks_minted.


Unspendable Rewards: Two Filter Points, Two Destinations

Gotcha: Rewards for unregistered accounts are filtered at TWO different points, with different routing:

Filter 1: At RUPD time (stability window)

  • Protocol < 7 (pre-Babbage): Unregistered accounts are excluded from reward calculation entirely (hardforkBabbageForgoRewardPrefilter). Their would-be rewards stay in reserves (never leave the reward pot).
  • Protocol >= 7 (Babbage+): Rewards are calculated for ALL accounts regardless of registration.

Filter 2: At EWRAP time (epoch boundary)

  • Accounts that were registered at RUPD but deregistered by EWRAP have their rewards routed to treasury. This happens regardless of protocol version.
  • This matches frTotalUnregistered → casTreasury in applyRUpdFiltered.

Rule: EWRAP-filtered rewards ALWAYS go to treasury. Never route them to reserves based on protocol version.


RUPD Fires at randomnessStabilisationWindow, Not stabilityWindow

Gotcha: RUPD fires at 4k/f slots into the epoch (randomness stability window = 172,800 on mainnet), NOT at 3k/f (stability window = 129,600).

Accounts that deregister between 3k/f and 4k/f are still registered when RUPD runs. If you use the wrong boundary, these accounts are incorrectly excluded from reward calculation.


Pre-Conway Proposal Timing

Gotcha: Pre-Conway (protocol 0-8) update proposals usually follow a 1-epoch lag: ratified in the submission epoch, enacted at EWRAP, effect at ESTART of next epoch. The RatifiedCurrentEpoch fallback handles this.

Exceptions:

  • Proposals submitted one epoch before their target d-parameter epoch need explicit Ratified(target_epoch) entries to avoid enacting one epoch too early.
  • Split-quorum proposals (submitted across two epochs) need Ratified() set to the epoch where quorum was actually reached.
  • The preview v7→v8 transition anomalously shows a 2-epoch lag.

Conway Governance Proposals Must Be Hardcoded

Gotcha: Dolos doesn't implement DRep governance voting. All Conway proposal outcomes (ratified/canceled/expired) are hardcoded in crates/cardano/src/hacks.rs. Missing entries cause:

  • Wrong deposit refund timing (proposal expires instead of being enacted)
  • Missed treasury withdrawals
  • Committee/constitution not updated

Rule: Ratified(enacted_epoch - 1) from DBSync's gov_action_proposal.enacted_epoch. Dropped proposals that expired naturally (dropped_epoch = expired_epoch + 1) don't need entries; the Unknown outcome lets them expire via max_epoch.


Pool Deposit Refund Account

Gotcha: When a pool retires, the deposit is refunded to the pool's reward account. But pools can be re-registered with a different reward account. The refund goes to whichever reward account is current at POOLREAP time (after SNAP has already moved future params to current).

Check the full pool_update history in DBSync -- active_epoch_no determines when new params take effect. The last update active before the retirement epoch determines the refund recipient.


Pointer Addresses

Gotcha: Pointer addresses encode (slot, tx_idx, cert_idx) to reference a stake registration certificate. Many on-chain pointer addresses contain intentionally garbage values (e.g., (12, 12, 12)) that point to nonexistent registrations. These map to None (no stake rights).

Dolos resolves pointers via a hardcoded lookup table in hacks.rs. New valid pointers must be resolved by querying DBSync's stake_registration table.


Pointer Address Overflow Values

Gotcha: Pointer address components are encoded as variable-length integers in the address bytes, but Cardano decodes them as unbounded integers. Values can overflow u64 or contain astronomically large numbers like 18446744073709551615 (u64::MAX) or 16292793057. These are NOT necessarily invalid -- some overflow pointers resolve to real stake credentials on-chain.

Example from mainnet:

  • (18446744073709551615, 1221092, 2) → maps to a VALID AddrKeyhash
  • (16292793057, 1011302, 20) → maps to None

Resolution: The standard DBSync SQL query (WHERE block.slot_no = <SLOT>) won't work for overflow pointers since the slot is invalid. Instead, use a block explorer to look up the full bech32 address (addr1g...) -- explorers typically show the resolved stake address. Multiple pointer addresses can share the same payment credential but have different pointer tuples; each tuple needs its own mapping entry.


Deposit Constants by Network

Constant Mainnet Preprod/Preview
Pool deposit 500,000,000 lovelace 500,000,000 lovelace
Key deposit 2,000,000 lovelace 2,000,000 lovelace

When a delegation/stake diff is off by exactly one of these constants, it's almost certainly a deposit refund or registration timing issue.


Pots Invariant

The total lovelace in the system is always exactly 45,000,000,000,000,000 (45 billion ADA):

reserves + treasury + utxos + rewards + fees + obligations = max_supply

If any pot is wrong, the error must appear somewhere else with opposite sign. Use this invariant to cross-check: if treasury is too low by X, then X must be too high in some other pot (usually reserves or rewards).


Single Stake Error Cascades Into Many Reward Diffs

Gotcha: A single account's stake being off by N lovelace changes that pool's total stake, which changes the reward calculation for ALL the pool's delegators -- producing hundreds or thousands of ±1 lovelace rounding differences.

How to recognize:

  • stake diff shows 1 account off by an exact amount (e.g., 3,001,337)
  • delegation diff shows 1 pool's total off by the same amount
  • rewards diff shows many ±1 differences, all for delegators of that same pool
  • epochs diff shows reserves/rewards off by a small amount (net rounding effect)

Rule: When you see this pattern, ignore the reward diffs entirely. Find and fix the single stake root cause, and all diffs resolve together.


Mismatch Diagnosis by Work Unit

Gotcha: The shape of a mismatch tells you which work unit is wrong:

Symptom Likely work unit
Equal and opposite deltas between rewards and treasury EWRAP -- unspendable reward routing
Rewards match but pots don't EWRAP/ESTART -- pot aggregation or unspendable handling
Only a subset of pools/accounts affected ROLL -- registration/retirement window or pool parameter update timing
Rounded or exact small delta RUPD/EWRAP -- conditional logic or boundary condition
Thousands of ±1 reward diffs for one pool ROLL/ESTART -- single stake error cascade (see above)
Install via CLI
npx skills add https://github.com/txpipe/dolos --skill ledger-gotchas
Repository Details
star Stars 131
call_split Forks 55
navigation Branch main
article Path SKILL.md
More from Creator