Skip to content

Commit 991879a

Browse files
gisk0claude
authored andcommitted
fix(indexer): self-heal missing referenceRateFeedID on subsequent events
When the initial FPMMDeployed RPC call for referenceRateFeedID transiently fails, the pool is left with an empty feed ID. This breaks the oracle pipeline: OracleReported/MedianUpdated events are silently ignored, and the UI shows a false CRITICAL stale-oracle status. Add a retry in upsertPool() — when an existing pool has an empty referenceRateFeedID, attempt to fetch it (and oracleExpiry) before persisting. The first Swap/Mint/Burn/etc event after the failed initial fetch will self-heal the pool. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9cef3f1 commit 991879a

File tree

2 files changed

+217
-1
lines changed

2 files changed

+217
-1
lines changed

indexer-envio/src/EventHandlers.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,39 @@ export function _clearFeeTokenMetaCache(): void {
272272
feeTokenMetaCache.clear();
273273
}
274274

275+
// ---------------------------------------------------------------------------
276+
// Test mocks: referenceRateFeedID & reportExpiry (for self-heal testing)
277+
// ---------------------------------------------------------------------------
278+
const _testRateFeedIDs = new Map<string, string | null>();
279+
280+
/** @internal Test-only: pre-set a mock referenceRateFeedID for a pool. */
281+
export function _setMockRateFeedID(
282+
chainId: number,
283+
poolAddress: string,
284+
rateFeedID: string | null,
285+
): void {
286+
_testRateFeedIDs.set(`${chainId}:${poolAddress.toLowerCase()}`, rateFeedID);
287+
}
288+
289+
export function _clearMockRateFeedIDs(): void {
290+
_testRateFeedIDs.clear();
291+
}
292+
293+
const _testReportExpiry = new Map<string, bigint | null>();
294+
295+
/** @internal Test-only: pre-set a mock report expiry for a rateFeedID. */
296+
export function _setMockReportExpiry(
297+
chainId: number,
298+
rateFeedID: string,
299+
expiry: bigint | null,
300+
): void {
301+
_testReportExpiry.set(`${chainId}:${rateFeedID.toLowerCase()}`, expiry);
302+
}
303+
304+
export function _clearMockReportExpiry(): void {
305+
_testReportExpiry.clear();
306+
}
307+
275308
// ---------------------------------------------------------------------------
276309
// Pure backfill helpers (exported for unit testing)
277310
// ---------------------------------------------------------------------------
@@ -406,6 +439,10 @@ async function fetchReportExpiry(
406439
rateFeedID: string,
407440
blockNumber: bigint,
408441
): Promise<bigint | null> {
442+
// Check test mock first
443+
const mockKey = `${chainId}:${rateFeedID.toLowerCase()}`;
444+
if (_testReportExpiry.has(mockKey)) return _testReportExpiry.get(mockKey)!;
445+
409446
let address: `0x${string}`;
410447
try {
411448
address = SORTED_ORACLES_ADDRESS(chainId);
@@ -735,6 +772,10 @@ async function fetchReferenceRateFeedID(
735772
chainId: number,
736773
poolAddress: string,
737774
): Promise<string | null> {
775+
// Check test mock first
776+
const mockKey = `${chainId}:${poolAddress.toLowerCase()}`;
777+
if (_testRateFeedIDs.has(mockKey)) return _testRateFeedIDs.get(mockKey)!;
778+
738779
try {
739780
const client = getRpcClient(chainId);
740781
const result = await client.readContract({
@@ -1064,6 +1105,7 @@ const getOrCreatePool = async (
10641105

10651106
const upsertPool = async ({
10661107
context,
1108+
chainId,
10671109
poolId,
10681110
token0,
10691111
token1,
@@ -1077,6 +1119,7 @@ const upsertPool = async ({
10771119
tokenDecimals,
10781120
}: {
10791121
context: PoolContext;
1122+
chainId: number;
10801123
poolId: string;
10811124
token0?: string;
10821125
token1?: string;
@@ -1091,6 +1134,22 @@ const upsertPool = async ({
10911134
}): Promise<Pool> => {
10921135
const existing = await getOrCreatePool(context, poolId, { token0, token1 });
10931136

1137+
// Self-heal: if referenceRateFeedID is missing (transient RPC failure at
1138+
// pool creation), retry now so oracle events can start flowing.
1139+
let healedOracleDelta: Partial<typeof DEFAULT_ORACLE_FIELDS> | undefined;
1140+
if (
1141+
existing.referenceRateFeedID === "" &&
1142+
existing.source !== "" &&
1143+
!existing.source?.includes("virtual")
1144+
) {
1145+
const rateFeedID = await fetchReferenceRateFeedID(chainId, poolId);
1146+
if (rateFeedID) {
1147+
healedOracleDelta = { referenceRateFeedID: rateFeedID };
1148+
const expiry = await fetchReportExpiry(chainId, rateFeedID, blockNumber);
1149+
if (expiry !== null) healedOracleDelta.oracleExpiry = expiry;
1150+
}
1151+
}
1152+
10941153
let next: Pool = {
10951154
...existing,
10961155
token0: token0 ?? existing.token0,
@@ -1102,7 +1161,8 @@ const upsertPool = async ({
11021161
notionalVolume0: existing.notionalVolume0 + (swapDelta?.volume0 ?? 0n),
11031162
notionalVolume1: existing.notionalVolume1 + (swapDelta?.volume1 ?? 0n),
11041163
rebalanceCount: existing.rebalanceCount + (rebalanceDelta ? 1 : 0),
1105-
// Merge oracle delta if provided
1164+
// Merge healed oracle fields first, then explicit delta takes precedence
1165+
...(healedOracleDelta ?? {}),
11061166
...(oracleDelta ?? {}),
11071167
// Persist token decimals if provided (set once at pool creation)
11081168
token0Decimals: tokenDecimals?.token0Decimals ?? existing.token0Decimals,
@@ -1270,6 +1330,7 @@ FPMMFactory.FPMMDeployed.handler(async ({ event, context }) => {
12701330

12711331
const pool = await upsertPool({
12721332
context,
1333+
chainId: event.chainId,
12731334
poolId,
12741335
token0,
12751336
token1,
@@ -1327,6 +1388,7 @@ FPMM.Swap.handler(async ({ event, context }) => {
13271388

13281389
const pool = await upsertPool({
13291390
context,
1391+
chainId: event.chainId,
13301392
poolId,
13311393
source: "fpmm_swap",
13321394
blockNumber,
@@ -1454,6 +1516,7 @@ FPMM.Mint.handler(async ({ event, context }) => {
14541516

14551517
const pool = await upsertPool({
14561518
context,
1519+
chainId: event.chainId,
14571520
poolId,
14581521
source: "fpmm_mint",
14591522
blockNumber,
@@ -1493,6 +1556,7 @@ FPMM.Burn.handler(async ({ event, context }) => {
14931556

14941557
const pool = await upsertPool({
14951558
context,
1559+
chainId: event.chainId,
14961560
poolId,
14971561
source: "fpmm_burn",
14981562
blockNumber,
@@ -1565,6 +1629,7 @@ FPMM.UpdateReserves.handler(async ({ event, context }) => {
15651629

15661630
const pool = await upsertPool({
15671631
context,
1632+
chainId: event.chainId,
15681633
poolId,
15691634
source: "fpmm_update_reserves",
15701635
blockNumber,
@@ -1658,6 +1723,7 @@ FPMM.Rebalanced.handler(async ({ event, context }) => {
16581723

16591724
const pool = await upsertPool({
16601725
context,
1726+
chainId: event.chainId,
16611727
poolId,
16621728
source: "fpmm_rebalanced",
16631729
blockNumber,
@@ -2010,6 +2076,7 @@ VirtualPoolFactory.VirtualPoolDeployed.handler(async ({ event, context }) => {
20102076
// VirtualPools don't have oracle functions; set N/A health status
20112077
await upsertPool({
20122078
context,
2079+
chainId: event.chainId,
20132080
poolId,
20142081
token0,
20152082
token1,
@@ -2043,6 +2110,7 @@ VirtualPoolFactory.PoolDeprecated.handler(async ({ event, context }) => {
20432110

20442111
await upsertPool({
20452112
context,
2113+
chainId: event.chainId,
20462114
poolId,
20472115
source: "virtual_pool_factory",
20482116
blockNumber: asBigInt(event.block.number),
@@ -2093,6 +2161,7 @@ VirtualPool.Swap.handler(async ({ event, context }) => {
20932161

20942162
const pool = await upsertPool({
20952163
context,
2164+
chainId: event.chainId,
20962165
poolId,
20972166
source: "fpmm_swap", // reuse source key; VirtualPool inherits same priority
20982167
blockNumber,
@@ -2134,6 +2203,7 @@ VirtualPool.Mint.handler(async ({ event, context }) => {
21342203

21352204
const pool = await upsertPool({
21362205
context,
2206+
chainId: event.chainId,
21372207
poolId,
21382208
source: "fpmm_mint",
21392209
blockNumber,
@@ -2173,6 +2243,7 @@ VirtualPool.Burn.handler(async ({ event, context }) => {
21732243

21742244
const pool = await upsertPool({
21752245
context,
2246+
chainId: event.chainId,
21762247
poolId,
21772248
source: "fpmm_burn",
21782249
blockNumber,
@@ -2213,6 +2284,7 @@ VirtualPool.UpdateReserves.handler(async ({ event, context }) => {
22132284
// VirtualPools have no oracle; just update reserves
22142285
const pool = await upsertPool({
22152286
context,
2287+
chainId: event.chainId,
22162288
poolId,
22172289
source: "fpmm_update_reserves",
22182290
blockNumber,
@@ -2253,6 +2325,7 @@ VirtualPool.Rebalanced.handler(async ({ event, context }) => {
22532325

22542326
const pool = await upsertPool({
22552327
context,
2328+
chainId: event.chainId,
22562329
poolId,
22572330
source: "fpmm_rebalanced",
22582331
blockNumber,

indexer-envio/test/Test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import generated from "generated";
44
import {
55
_setMockRebalancingState,
66
_clearMockRebalancingStates,
7+
_setMockRateFeedID,
8+
_clearMockRateFeedIDs,
9+
_setMockReportExpiry,
10+
_clearMockReportExpiry,
711
} from "../src/EventHandlers.ts";
812

913
type MockDb = {
@@ -1244,4 +1248,143 @@ describe("Envio Celo indexer handlers", () => {
12441248
);
12451249
assert.equal(pool.rebalanceCount, 1);
12461250
});
1251+
1252+
// ---------------------------------------------------------------------------
1253+
// Self-heal: backfill referenceRateFeedID on subsequent events
1254+
// ---------------------------------------------------------------------------
1255+
1256+
it("self-heals empty referenceRateFeedID on next Swap event", async function () {
1257+
this.timeout(10_000);
1258+
1259+
const POOL_ADDR = "0x00000000000000000000000000000000000000dd";
1260+
const HEALED_FEED = "0xf47172ce00522cc7db02109634a92ce866a15fcc";
1261+
const HEALED_EXPIRY = 3720n;
1262+
const CHAIN_ID = 42220;
1263+
1264+
// 1. Deploy pool (referenceRateFeedID will be "" because no mock is set
1265+
// for the RPC call during deployment — simulating the transient failure)
1266+
let mockDb = MockDb.createMockDb();
1267+
1268+
const deployEvent = FPMMFactory.FPMMDeployed.createMockEvent({
1269+
token0: "0x0000000000000000000000000000000000000003",
1270+
token1: "0x0000000000000000000000000000000000000004",
1271+
fpmmProxy: POOL_ADDR,
1272+
fpmmImplementation: "0x00000000000000000000000000000000000000bc",
1273+
mockEventData: {
1274+
chainId: CHAIN_ID,
1275+
logIndex: 10,
1276+
srcAddress: "0x00000000000000000000000000000000000000cc",
1277+
block: { number: 100, timestamp: 1_700_000_000 },
1278+
},
1279+
});
1280+
mockDb = await FPMMFactory.FPMMDeployed.processEvent({
1281+
event: deployEvent,
1282+
mockDb,
1283+
});
1284+
1285+
// Verify the pool was created with empty referenceRateFeedID
1286+
const poolBefore = mockDb.entities.Pool.get(POOL_ADDR) as PoolEntity;
1287+
assert.ok(poolBefore, "Pool must exist after deploy");
1288+
assert.equal(
1289+
poolBefore.referenceRateFeedID,
1290+
"",
1291+
"referenceRateFeedID should be empty after failed initial fetch",
1292+
);
1293+
assert.equal(
1294+
poolBefore.oracleExpiry,
1295+
0n,
1296+
"oracleExpiry should be 0 when referenceRateFeedID is empty",
1297+
);
1298+
1299+
// 2. Set up mocks so the self-heal RPC calls succeed
1300+
_setMockRateFeedID(CHAIN_ID, POOL_ADDR, HEALED_FEED);
1301+
_setMockReportExpiry(CHAIN_ID, HEALED_FEED, HEALED_EXPIRY);
1302+
1303+
// 3. Process a Swap event — should trigger self-heal
1304+
const swapEvent = FPMM.Swap.createMockEvent({
1305+
sender: "0x0000000000000000000000000000000000000099",
1306+
to: "0x0000000000000000000000000000000000000098",
1307+
amount0In: 1000n,
1308+
amount1In: 0n,
1309+
amount0Out: 0n,
1310+
amount1Out: 990n,
1311+
mockEventData: {
1312+
chainId: CHAIN_ID,
1313+
logIndex: 20,
1314+
srcAddress: POOL_ADDR,
1315+
block: { number: 200, timestamp: 1_700_001_000 },
1316+
},
1317+
});
1318+
mockDb = await FPMM.Swap.processEvent({ event: swapEvent, mockDb });
1319+
1320+
// 4. Verify self-heal populated the fields
1321+
const poolAfter = mockDb.entities.Pool.get(POOL_ADDR) as PoolEntity;
1322+
assert.ok(poolAfter, "Pool must exist after Swap");
1323+
assert.equal(
1324+
poolAfter.referenceRateFeedID,
1325+
HEALED_FEED,
1326+
"referenceRateFeedID should be healed after Swap event",
1327+
);
1328+
assert.equal(
1329+
poolAfter.oracleExpiry,
1330+
HEALED_EXPIRY,
1331+
"oracleExpiry should be healed after Swap event",
1332+
);
1333+
1334+
// Cleanup
1335+
_clearMockRateFeedIDs();
1336+
_clearMockReportExpiry();
1337+
});
1338+
1339+
it("does NOT self-heal when referenceRateFeedID is already populated", async function () {
1340+
this.timeout(10_000);
1341+
1342+
const POOL_ADDR = "0x00000000000000000000000000000000000000ee";
1343+
const EXISTING_FEED = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1344+
const CHAIN_ID = 42220;
1345+
1346+
// 1. Seed pool with an existing referenceRateFeedID
1347+
let mockDb = await seedPoolWithFeed(MockDb.createMockDb(), {
1348+
poolId: POOL_ADDR,
1349+
feedId: EXISTING_FEED,
1350+
oracleExpiry: 600n,
1351+
});
1352+
1353+
// 2. Set up a different mock — should NOT be used because feed is already set
1354+
_setMockRateFeedID(
1355+
CHAIN_ID,
1356+
POOL_ADDR,
1357+
"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1358+
);
1359+
1360+
// 3. Process a Swap event
1361+
const swapEvent = FPMM.Swap.createMockEvent({
1362+
sender: "0x0000000000000000000000000000000000000099",
1363+
to: "0x0000000000000000000000000000000000000098",
1364+
amount0In: 500n,
1365+
amount1In: 0n,
1366+
amount0Out: 0n,
1367+
amount1Out: 495n,
1368+
mockEventData: {
1369+
chainId: CHAIN_ID,
1370+
logIndex: 30,
1371+
srcAddress: POOL_ADDR,
1372+
block: { number: 400, timestamp: 1_700_002_000 },
1373+
},
1374+
});
1375+
mockDb = await FPMM.Swap.processEvent({ event: swapEvent, mockDb });
1376+
1377+
// 4. Verify feed was NOT changed
1378+
const pool = mockDb.entities.Pool.get(POOL_ADDR) as PoolEntity;
1379+
assert.ok(pool, "Pool must exist after Swap");
1380+
assert.equal(
1381+
pool.referenceRateFeedID,
1382+
EXISTING_FEED,
1383+
"referenceRateFeedID should remain unchanged when already populated",
1384+
);
1385+
1386+
// Cleanup
1387+
_clearMockRateFeedIDs();
1388+
_clearMockReportExpiry();
1389+
});
12471390
});

0 commit comments

Comments
 (0)