ChainResetGuard

class ChainResetGuard(indexerClient: IndexerClient, shieldedRepository: ShieldedRepository, dustRepository: DustRepository, syncStateManager: SyncStateManager, utxoManager: UtxoManager, wipeOnResetEnabled: Boolean)

Detects a chain RESET — a localnet docker down/up replaces the chain with a fresh genesis — and wipes the stale wallet caches so they re-sync from the new chain.

Why this exists: all three sync layers (shielded, dust, unshielded) decide "already synced" by comparing event/transaction-ID COUNTS. After a reset the fresh chain restarts IDs at 0 and, once the wallet is re-funded, re-climbs to the SAME counts — so every layer concludes "at tip" and keeps the OLD balance (the 30,001-NIGHT ghost). Left unfixed, the stale DUST also fails the next spend with node error 171 (OutOfDustValidityWindow).

Why a CHECKPOINT block, not the genesis hash (the #36 fix): the obvious discriminator is the genesis (height-0) hash — but a localnet booting from a FIXED chain spec reproduces the same genesis hash byte-for-byte across a reset (verified against a live localnet: a full volume-wiping down/up returned the identical height-0 hash). So genesis can't tell a reset from a healthy chain. Instead the guard pins a CHECKPOINT block ABOVE genesis — (height, hash) — and re-looks-up that height each sync. Two independent reset signals fall out of one lookup:

  • the pinned height is BlockHashLookup.NotOnChain — the fresh chain is shorter (proven live: an old tip height returned block: null on the fresh chain), or

  • the block at that height carries a DIFFERENT hash — the fresh chain re-climbed past the pin (blocks above genesis carry a per-instance timestamp, so their hashes are instance-specific).

Reliability contract (this path is credibility-critical: a false positive nukes a healthy wallet on PreProd; a false negative leaves the dev staring at a ghost balance / error 171):

Concurrency + crash safety: a Mutex serializes the decision so concurrent syncs can't double-wipe (and the checkpoint lookup runs OUTSIDE the lock so it doesn't serialize every sync on one round-trip). Because the lookup is un-locked, the pin is re-read under the lock and the decision is dropped if another sync already re-pinned in the meantime. The re-pin happens LAST, after the wipes — so a crash mid-wipe leaves the OLD pin in place and the next sync re-detects the reset and re-wipes (the wipes are idempotent), never a half-wiped wallet pinned to the new chain.

Call ensureFreshChain at the START of every wallet sync entry point (shielded, dust, unshielded). Whichever runs first triggers the wipe; the others then find their state gone and run a full re-sync.

Constructors

Link copied to clipboard
constructor(indexerClient: IndexerClient, shieldedRepository: ShieldedRepository, dustRepository: DustRepository, syncStateManager: SyncStateManager, utxoManager: UtxoManager, wipeOnResetEnabled: Boolean)

Functions

Link copied to clipboard
suspend fun ensureFreshChain(address: String): Boolean

Ensure the caches for address belong to the CURRENT chain.