Skip to content

Commit 452fba5

Browse files
committed
fix(store-indexer): reorg gate clears once at boundary, not every replayed block
greptile P1 on mud#9: the reorg fix used Math.max for highWaterBlock, so after a reorg regressed the block once, EVERY subsequent replayed block stayed below the frozen peak and re-cleared lastStateById — wiping prior-state accumulated during the replay, so a training-return-to-IDLE on an already-revealed taru looked like a first write → false mint once caught up. Track the PREVIOUS block (lastBlock) instead: regression triggers the clear exactly once at the boundary, then blocks climb forward without re-clearing. Added a cross-replayed-block test. tested: 10/10 projector tests (incl. across-replay prior-state) + tsc + eslint clean
1 parent 7e2477e commit 452fba5

2 files changed

Lines changed: 32 additions & 15 deletions

File tree

packages/store-indexer/src/postgres/notificationEventProjector.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,20 @@ describe("extractNotifEvents — MINT", () => {
109109
const ev = extractNotifEvents([statusLog(1n, IDLE)], c, 98);
110110
expect(ev).toEqual([{ type: "mint", recipient_wallet: OWNER_A, taruchi_id: "1" }]);
111111
});
112+
113+
it("preserves prior-state ACROSS replayed blocks — the gate clears once at the boundary, not every block", () => {
114+
const c = emptyCaches();
115+
extractNotifEvents([coreLog(1n, OWNER_A, 10)], c, 100); // learn owner, lastBlock=100
116+
// reorg boundary: block regresses to 98. Reveal re-lands here (first write
117+
// since the gate cleared) → mint re-fires. This is the boundary block.
118+
expect(extractNotifEvents([statusLog(1n, IDLE)], c, 98)).toEqual([
119+
{ type: "mint", recipient_wallet: OWNER_A, taruchi_id: "1" },
120+
]);
121+
// Next replayed block (99 > 98) climbs forward — must NOT re-clear the gate.
122+
// A training-return-to-IDLE on the already-revealed taru must stay silent.
123+
// (With the old Math.max bug, 99 < highWater(100) re-cleared → false mint.)
124+
expect(extractNotifEvents([statusLog(1n, IDLE)], c, 99)).toEqual([]);
125+
});
112126
});
113127

114128
describe("extractNotifEvents — DUEL (resolves via TourneyResult, not a status splice)", () => {

packages/store-indexer/src/postgres/notificationEventProjector.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,13 @@ export type NotifCaches = {
9696
duelPlayersById: Map<string, [number, number]>;
9797
playersById: Map<string, bigint>;
9898
bracketById: Map<string, number>;
99-
/** Highest block processed — used to detect a reorg replay (block regresses).
100-
* The indexer recovers from ReorgError in-process (no restart), so caches
101-
* persist; without this, lastStateById would silence a legit re-revealed
102-
* mint after a reorg. -1 = nothing processed yet. */
103-
highWaterBlock: number;
99+
/** The previous block processed — used to detect a reorg replay (block
100+
* regresses below it exactly once, at the boundary). NOT a high-water max:
101+
* tracking the last block means subsequent replayed blocks climb forward and
102+
* don't re-trigger the gate clear. The indexer recovers from ReorgError
103+
* in-process (no restart), so caches persist; without this, lastStateById
104+
* would silence a legit re-revealed mint after a reorg. -1 = none yet. */
105+
lastBlock: number;
104106
};
105107

106108
export function emptyCaches(): NotifCaches {
@@ -111,7 +113,7 @@ export function emptyCaches(): NotifCaches {
111113
duelPlayersById: new Map(),
112114
playersById: new Map(),
113115
bracketById: new Map(),
114-
highWaterBlock: -1,
116+
lastBlock: -1,
115117
};
116118
}
117119

@@ -132,17 +134,18 @@ export function extractNotifEvents(
132134
blockNumber: number,
133135
): NotifEvent[] {
134136
// 0) Reorg detection. The indexer recovers from ReorgError in-process and
135-
// re-processes from the common ancestor, so blockNumber regresses. Clear
136-
// the mint-seen gate so a reveal in the replayed range can re-fire — the
137-
// notification_events dedup (same block) absorbs a same-block replay, and
138-
// a genuinely re-mined reveal re-notifying once beats permanent silence.
139-
// Owner/enroll caches are learned facts (taught before the boundary) — keep
140-
// them so replayed resolves can still resolve recipients.
141-
if (blockNumber < caches.highWaterBlock) {
142-
log.info("reorg replay detected — clearing mint-seen gate", { blockNumber, from: caches.highWaterBlock });
137+
// re-processes from the common ancestor, so blockNumber regresses ONCE at
138+
// the boundary, then climbs again. Clear the mint-seen gate exactly at that
139+
// boundary so a reveal in the replayed range can re-fire — but track the
140+
// PREVIOUS block (not a high-water max), or every replayed block would stay
141+
// below the peak and re-clear the gate on each one, wiping prior-state
142+
// accumulated during the replay (→ false mints once caught up). Owner/enroll
143+
// caches are learned facts (taught before the boundary) — kept either way.
144+
if (blockNumber < caches.lastBlock) {
145+
log.info("reorg replay detected — clearing mint-seen gate", { blockNumber, from: caches.lastBlock });
143146
caches.lastStateById.clear();
144147
}
145-
caches.highWaterBlock = Math.max(caches.highWaterBlock, blockNumber);
148+
caches.lastBlock = blockNumber;
146149

147150
// 1) Learn owners (TaruchiCore is written at mint/reroll, before any resolve).
148151
for (const rec of setRecordsFor(logs, TARUCHI_CORE_TABLE_ID)) {

0 commit comments

Comments
 (0)