Skip to content

Commit f1433f1

Browse files
author
S. K. Rotwang MMMMMMMMM
committed
fix(queries): honest operator_activity — defend against phantom wins
The dashboard claimed a "win" that turned out to be a writer-side arg-misalignment in liquidation-scanner.js (already fixed upstream): some skip-decision rows ('aave_gas_budget_rejected') were logged with success=1 because a string was passed where the success boolean belonged. The string was truthy-coerced to 1. This commit hardens the read path so the dashboard remains accurate even if future writer bugs sneak similar junk in: - liquidation_attempts: COUNT(DISTINCT tx_hash) over rows where tx_hash is a real on-chain hash (0x + 64 hex). Previously naive COUNT(*) double-counted every bundle (one submit + one timeout row per attempt) and triple-counted some failed paths. Skip rows with no tx_hash are now correctly excluded — they're "we looked and didn't fire", not attempts. - liquidations_won: same tx_hash predicate ANDed with success=1. Now requires both flags to align with a real on-chain hash. Old query trusted success=1 alone. - recentLiquidations: same tx_hash hardening applied so the public feed never surfaces a phantom-win row. Test fixture extended with a phantom-win row matching the bug pattern; new regression test asserts the row is excluded. Adds _nowMs / _windowMs injection seams so tests can pin the 24h cutoff onto 2023-vintage fixtures. 76 tests pass (was 75).
1 parent abb5585 commit f1433f1

2 files changed

Lines changed: 90 additions & 9 deletions

File tree

src/queries.js

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -376,13 +376,22 @@ export function recentLiquidations(db, params = {}) {
376376
// `strategy` typically looks like 'aave_liquidation', 'morpho_…' etc.
377377
// so a `LIKE protocol||'%'` filter routes the user's `protocol` to
378378
// matching landings.
379+
//
380+
// `success=1 AND tx_hash looks real` — see the comment in
381+
// getStatsOverview's operator_activity block for why the tx_hash
382+
// predicate is non-negotiable: a writer-side arg-misalignment used
383+
// to set success=1 on no-tx skip rows. We've since fixed the writer,
384+
// but the dashboard must remain robust against any future regression.
379385
const landings = protocol
380386
? db.prepare(`
381387
SELECT timestamp, block_number, strategy, borrower_address,
382388
tx_hash, success, actual_profit_usd, gas_used_usd
383389
FROM executions
384390
WHERE timestamp >= ?
385391
AND success = 1
392+
AND tx_hash IS NOT NULL
393+
AND tx_hash LIKE '0x%'
394+
AND length(tx_hash) = 66
386395
AND strategy LIKE ?
387396
ORDER BY timestamp DESC
388397
LIMIT ?
@@ -393,6 +402,9 @@ export function recentLiquidations(db, params = {}) {
393402
FROM executions
394403
WHERE timestamp >= ?
395404
AND success = 1
405+
AND tx_hash IS NOT NULL
406+
AND tx_hash LIKE '0x%'
407+
AND length(tx_hash) = 66
396408
ORDER BY timestamp DESC
397409
LIMIT ?
398410
`).all(sinceMs, limit);
@@ -792,7 +804,12 @@ const HF_BUCKETS = Object.freeze([
792804
export async function getStatsOverview(db, params = {}) {
793805
const shadowPath = params._shadowPath ?? '/opt/mevbot/data/shadow-blocks.jsonl';
794806
const ttlMs = params._ttlMs ?? 60_000;
795-
const now = Date.now();
807+
// Test-injection seam: tests pass fixture rows with timestamps far
808+
// in the past, so they pass `_nowMs` (and optionally `_windowMs`) to
809+
// shift the 24h cutoff onto the fixture. Production always reads
810+
// the real wall clock.
811+
const now = params._nowMs ?? Date.now();
812+
const windowMs = params._windowMs ?? (24 * 60 * 60 * 1000);
796813

797814
// Cheap row counts — same trick as getHealth: O(log n) via max(rowid).
798815
const tableSize = (table) => {
@@ -874,7 +891,7 @@ export async function getStatsOverview(db, params = {}) {
874891
// is only ~2.4K rows so a date-aggregated query is cheap; the
875892
// timestamp index makes the range scan fast.
876893
const cutoff30d = now - 30 * 24 * 60 * 60 * 1000;
877-
const cutoff24h = now - 24 * 60 * 60 * 1000;
894+
const cutoff24h = now - windowMs;
878895
const liqsPerDay = db.prepare(`
879896
SELECT
880897
CAST((timestamp - (timestamp % 86400000)) AS INTEGER) AS day_ms,
@@ -898,19 +915,44 @@ export async function getStatsOverview(db, params = {}) {
898915
// labels because those are operator-private. The intent is a
899916
// "is the bot doing its job?" signal that operators (and curious
900917
// outsiders) can see without revealing how much money is at stake.
918+
//
919+
// IMPORTANT — three layers of "honest counting":
920+
//
921+
// (a) Attempts = COUNT(DISTINCT tx_hash) over rows where tx_hash
922+
// looks like a real on-chain hash (`0x` + 64 hex chars). The
923+
// bot writes a row per outcome (submit, timeout, missed, failed)
924+
// for each bundle, so a naive COUNT(*) double- or triple-counts
925+
// every single attempt. Distinct tx_hash collapses these back
926+
// to one row per pursued opportunity.
927+
//
928+
// (b) Skipped decisions ('aave_low_gas_skip', 'aave_preflight_*',
929+
// 'aave_gas_budget_rejected', 'aave_revalidation_skip') don't
930+
// have a tx_hash at all and are excluded — those are "we looked
931+
// and decided not to fire", not attempts.
932+
//
933+
// (c) Wins require BOTH success=1 AND a real tx_hash. A historical
934+
// writer bug (since fixed) flipped success=1 on no-tx rows when
935+
// the call-site packed positional args tightly and misaligned
936+
// the success column with a string value. Even after the writer
937+
// fix, we keep the tx_hash predicate as defence-in-depth.
938+
const REAL_TX = `tx_hash IS NOT NULL
939+
AND tx_hash LIKE '0x%'
940+
AND length(tx_hash) = 66`;
901941
const ourExec24h = db.prepare(`
902942
SELECT
903-
COUNT(*) AS attempts,
904-
COALESCE(SUM(CASE WHEN success=1 THEN 1 ELSE 0 END), 0) AS successes
943+
COUNT(DISTINCT tx_hash) AS attempts,
944+
COUNT(DISTINCT CASE WHEN success=1 THEN tx_hash END) AS successes
905945
FROM executions
906946
WHERE timestamp >= ?
947+
AND ${REAL_TX}
907948
`).get(cutoff24h);
908949
const ourExec7d = db.prepare(`
909950
SELECT
910-
COUNT(*) AS attempts,
911-
COALESCE(SUM(CASE WHEN success=1 THEN 1 ELSE 0 END), 0) AS successes
951+
COUNT(DISTINCT tx_hash) AS attempts,
952+
COUNT(DISTINCT CASE WHEN success=1 THEN tx_hash END) AS successes
912953
FROM executions
913954
WHERE timestamp >= ?
955+
AND ${REAL_TX}
914956
`).get(now - 7 * 24 * 60 * 60 * 1000);
915957

916958
// At-risk count — anything with HF below 1.05. Aave straight from

test/queries.test.js

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,24 +74,43 @@ beforeAll(() => {
7474
);
7575
}
7676

77+
// A 66-char `0x…` hash is a real on-chain tx hash. The fixtures use
78+
// these so the operator_activity SQL (which requires
79+
// length(tx_hash)=66 to defend against writer-side junk) counts them.
80+
const FAKE_MISSED_HASH = '0x' + 'a'.repeat(64);
81+
const FAKE_LANDED_HASH = '0x' + 'b'.repeat(64);
82+
const FAKE_NO_TX_HASH = '0'; // simulates the historical writer bug
83+
const FAKE_LIQ_ADDR = '0x' + 'c'.repeat(40);
84+
7785
db.prepare(`
7886
INSERT INTO missed_liquidations
7987
(tx_hash, timestamp, block_number, borrower_address,
8088
liquidator, debt_asset, collateral_asset,
8189
debt_to_cover, liquidated_collateral, debt_usd,
8290
was_tracking, would_have_been_profitable)
8391
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
84-
`).run('0xtx1', 1_700_000_010_000, 25_000_010, ADDR_A,
85-
'0xliquidator', 'USDC', 'WETH',
92+
`).run(FAKE_MISSED_HASH, 1_700_000_010_000, 25_000_010, ADDR_A,
93+
FAKE_LIQ_ADDR, 'USDC', 'WETH',
8694
'50000000000', '1000000000000000000', 50000, 1, 1);
8795

96+
// One genuine landing with a real-looking 66-char tx hash.
8897
db.prepare(`
8998
INSERT INTO executions
9099
(timestamp, block_number, strategy, borrower_address, tx_hash, success,
91100
actual_profit_usd, gas_used_usd)
92101
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
93102
`).run(1_700_000_020_000, 25_000_020, 'aave_liquidation', ADDR_C,
94-
'0xtx2', 1, 300, 12.5);
103+
FAKE_LANDED_HASH, 1, 300, 12.5);
104+
105+
// One phantom "win" from the historical writer arg-misalignment bug:
106+
// success=1 with tx_hash='0'. The query MUST exclude this.
107+
db.prepare(`
108+
INSERT INTO executions
109+
(timestamp, block_number, strategy, borrower_address, tx_hash, success,
110+
actual_profit_usd, gas_used_usd)
111+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
112+
`).run(1_700_000_021_000, 25_000_021, 'aave_gas_budget_rejected', ADDR_A,
113+
FAKE_NO_TX_HASH, 1, null, null);
95114

96115
// Disk fixtures for spark JSON and shadow-blocks JSONL.
97116
tmpRoot = join(tmpdir(), `seneschal-test-${Date.now()}-${process.pid}`);
@@ -407,6 +426,26 @@ describe('getStatsOverview', () => {
407426
expect(json).not.toMatch(/gas_used/i);
408427
});
409428

429+
test('operator_activity ignores phantom-win rows (tx_hash="0", success=1)', async () => {
430+
// The fixture inserts ONE genuine landing and ONE phantom row
431+
// produced by the historical writer arg-misalignment bug. Honest
432+
// counting must yield exactly 1 win, not 2.
433+
_resetLeaderboardCacheForTest();
434+
// Make the cutoff window wide enough to include the fixture
435+
// timestamps (year 2023). The default 24h/7d window misses them.
436+
const r = await getStatsOverview(db, {
437+
_shadowPath: shadowPath,
438+
_sparkPath: sparkPath,
439+
_ttlMs: 1,
440+
_nowMs: 1_700_000_100_000,
441+
_windowMs: 200_000
442+
});
443+
const op = r.operator_activity;
444+
// One genuine landing in the fixture — phantom row excluded.
445+
expect(op.liquidations_won_24h).toBe(1);
446+
expect(op.liquidation_attempts_24h).toBe(1);
447+
});
448+
410449
test('Morpho rows in top_at_risk have null debt_usd', async () => {
411450
_resetLeaderboardCacheForTest();
412451
const r = await getStatsOverview(db, {

0 commit comments

Comments
 (0)