Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
28 changes: 28 additions & 0 deletions crates/db/migrations/0003_token_transfers_unique.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- 0003_token_transfers_unique.sql — audit M-3 fix (2026-05-21)
--
-- token_transfers had only PRIMARY KEY (id) when shipped in PR #46. On reorg
-- recovery + replay the worker re-inserts the same (tx_hash, log_index) rows
-- without a uniqueness gate, producing duplicate transfer rows that double-
-- count balances downstream. The atomic cursor advance in the steady-state
-- writer prevented this in practice, but reorg / forensic-rerun paths bypass
-- that protection.
--
-- Add the unique constraint that should have been there from day one. The
-- corresponding `ON CONFLICT (tx_hash, log_index) DO NOTHING` is re-enabled
-- in `crates/db/src/token_transfers.rs::insert_batch` in the same change set.
--
-- Safe to run on a populated table: drops existing duplicates first
-- (keeping the lowest id per (tx_hash, log_index) pair) so the unique index
-- can be created. Production runs as of 2026-05-21 don't yet have duplicates
-- (no reorg has hit the patched indexer), so the DELETE is a no-op for now;
-- left in place to make this migration replay-safe on chains that did
-- accumulate duplicates before the upgrade.

DELETE FROM token_transfers t1
USING token_transfers t2
WHERE t1.id > t2.id
AND t1.tx_hash = t2.tx_hash
AND t1.log_index = t2.log_index;

CREATE UNIQUE INDEX IF NOT EXISTS transfers_tx_log_unique_idx
ON token_transfers (tx_hash, log_index);
8 changes: 4 additions & 4 deletions crates/db/src/token_transfers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ where
.push_bind(t.token_id)
.push_bind(t.amount);
});
// No ON CONFLICT — table has no unique constraint on (tx_hash, log_index).
// The writer's atomic cursor advance prevents re-processing the same
// block in the steady state; reorg recovery deletes downstream rows
// before re-insert. Adding a unique index here is a follow-up migration.
// ON CONFLICT enabled after migration 0003 added the unique index on
// (tx_hash, log_index). Re-insert on reorg replay is now idempotent
// at the DB layer in addition to the cursor-based writer guard.
qb.push(" ON CONFLICT (tx_hash, log_index) DO NOTHING");
qb.build().execute(executor).await?;
Ok(())
}
Expand Down
9 changes: 8 additions & 1 deletion crates/sync/src/backfill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,14 @@ async fn fetch_one(
.filter(|l| tx_hash_set.contains(&l.tx_hash))
.collect();
if dom_logs.len() < logs_total {
tracing::debug!(
// Raised from debug! to warn! per audit M-5 (2026-05-21).
// Orphan logs (tx_hash in eth_getLogs but missing from
// `/chain/blocks/<n>`'s tx vec) signal Sentrix-side data
// inconsistency — usually a reverted tx whose log envelopes
// still get returned. Low-volume warnings are fine; sustained
// high-volume warnings here flag a real chain bug worth
// investigating, so they should be visible at default log level.
tracing::warn!(
block = h.0,
dropped = logs_total - dom_logs.len(),
"backfill: dropped orphan logs (tx_hash not in block.txs)"
Expand Down
Loading