Skip to content

Commit b0825b2

Browse files
committed
fix: gate USD display on exactly one USDm side + no stale TVL without oracle
- showUsd now requires hasUsdmSide (XOR: exactly one token is USDm) so non-USDm pairs don't display fabricated dollar values - tok0Usd / tok1Usd now explicitly null when conversion is not valid, preventing raw token amounts being rendered as USD - Chart sidebar totalTvl is null when feedVal is null or no USDm side, so TVL only appears when conversion is meaningful - Import USDM_SYMBOLS in lp-concentration-chart for consistent USD semantics
1 parent 0939d16 commit b0825b2

File tree

3 files changed

+134
-13
lines changed

3 files changed

+134
-13
lines changed

indexer-envio/PHASE0-AUDIT.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Phase 0 Audit — Envio Multichain Indexer Refactor
2+
3+
**Date:** 2026-03-26
4+
**Scope:** `indexer-envio/` — schema, handlers, config files
5+
**Purpose:** Pre-refactor audit before adding multichain support (Celo + Monad in a single indexer)
6+
7+
---
8+
9+
## 1. Entity Audit Table
10+
11+
| Entity | Current ID Shape | Address-derived? | Has chainId? | Referenced by (poolId FK) | Collision Risk (2 chains, same address) |
12+
| ------------------------ | ---------------------------------------------------------------------------------- | -------------------------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
13+
| **Pool** | Pool contract address (lowercased) | ✅ Yes — `event.srcAddress` or factory param | ❌ No | OracleSnapshot, PoolSnapshot, SwapEvent, LiquidityEvent, ReserveUpdate, RebalanceEvent, TradingLimit, LiquidityPosition, OlsPool, OlsLiquidityEvent, OlsLifecycleEvent, FactoryDeployment, VirtualPoolLifecycle | 🔴 **HIGH** — same pool address on Celo and Monad would be the same DB record |
14+
| **OracleSnapshot** | `{chainId}_{blockNumber}_{logIndex}` (via `eventId`) + optional `-{poolId}` suffix | Partial — logIndex makes it unique per event | ✅ Yes (baked into eventId) || ✅ Safe (eventId includes chainId) |
15+
| **PoolSnapshot** | `{poolId}-{hourTimestamp}` (via `snapshotId`) | ✅ Yes (poolId part) | ❌ No || 🔴 **HIGH** — same pool address + same hour = same key across chains |
16+
| **FactoryDeployment** | `{chainId}_{blockNumber}_{logIndex}` | No — event-derived | ✅ Yes || ✅ Safe |
17+
| **SwapEvent** | `{chainId}_{blockNumber}_{logIndex}` | No — event-derived | ✅ Yes | Pool (poolId field) | ✅ Safe (but poolId FK unscoped) |
18+
| **LiquidityEvent** | `{chainId}_{blockNumber}_{logIndex}` | No — event-derived | ✅ Yes | Pool (poolId field) | ✅ Safe (but poolId FK unscoped) |
19+
| **ReserveUpdate** | `{chainId}_{blockNumber}_{logIndex}` | No — event-derived | ✅ Yes | Pool (poolId field) | ✅ Safe (but poolId FK unscoped) |
20+
| **RebalanceEvent** | `{chainId}_{blockNumber}_{logIndex}` | No — event-derived | ✅ Yes | Pool (poolId field) | ✅ Safe (but poolId FK unscoped) |
21+
| **TradingLimit** | `{poolId}-{tokenAddress}` | ✅ Yes (both parts) | ❌ No | Pool (poolId field) | 🔴 **HIGH** — same pool+token on two chains = same key |
22+
| **VirtualPoolLifecycle** | `{chainId}_{blockNumber}_{logIndex}` | No — event-derived | ✅ Yes | Pool (poolId field) | ✅ Safe (but poolId FK unscoped) |
23+
| **LiquidityPosition** | `{poolId}-{address}` | ✅ Yes (both parts) | ❌ No | Pool (poolId field) | 🔴 **HIGH** — same pool+LP address on two chains = same key |
24+
| **ProtocolFeeTransfer** | `{chainId}_{blockNumber}_{logIndex}` | No — event-derived | ✅ Yes | — (token field, not Pool FK) | ✅ Safe |
25+
| **OlsPool** | `{poolAddress}-{olsAddress}` | ✅ Yes (both parts) | ❌ No | Pool (poolId field) | 🔴 **HIGH** — same addresses on two chains = collision |
26+
| **OlsLiquidityEvent** | `{chainId}_{blockNumber}_{logIndex}` | No — event-derived | ✅ Yes | Pool (poolId field) | ✅ Safe (but poolId FK unscoped) |
27+
| **OlsLifecycleEvent** | `{chainId}_{blockNumber}_{logIndex}` | No — event-derived | ✅ Yes | Pool (poolId field) | ✅ Safe (but poolId FK unscoped) |
28+
29+
### Summary of Collision-Prone Entities (need ID fix for multichain)
30+
31+
1. **Pool** — primary root entity; all others hang off `poolId` FK. This is the critical one.
32+
2. **PoolSnapshot**`{poolId}-{hourTs}` will collide if pool addresses match across chains.
33+
3. **TradingLimit**`{poolId}-{tokenAddress}` composite, no chain scope.
34+
4. **LiquidityPosition**`{poolId}-{address}` composite, no chain scope.
35+
5. **OlsPool**`{poolAddress}-{olsAddress}` composite, no chain scope.
36+
37+
**Note on FK pollution:** Even "safe" entities (event-log IDs already include chainId) store `poolId` as a plain address string. If `Pool.id` becomes `{chainId}-{address}`, all FK references must also be updated to `{chainId}-{address}` format. This is a broad but mechanical change.
38+
39+
---
40+
41+
## 2. Dynamic Contract Discovery
42+
43+
### 2.1 Are child contracts discovered dynamically or statically listed?
44+
45+
**Both patterns are in use:**
46+
47+
- **FPMM pools** — Listed **statically** in config YAML under the `FPMM` contract block (explicit addresses per chain).
48+
- **VirtualPools** — Listed **statically** in `config.celo.mainnet.yaml`. Empty (`address: []`) in `config.monad.testnet.yaml` where factory isn't yet deployed.
49+
- **ERC20FeeToken** — ✅ **Dynamically registered** via `contractRegister`. The `FPMMFactory.FPMMDeployed.contractRegister` callback calls `context.addERC20FeeToken(token0)` and `context.addERC20FeeToken(token1)` to register pool token addresses for ERC20 Transfer event indexing.
50+
51+
```typescript
52+
// fpmm.ts — dynamic registration
53+
FPMMFactory.FPMMDeployed.contractRegister(({ event, context }) => {
54+
context.addERC20FeeToken(event.params.token0);
55+
context.addERC20FeeToken(event.params.token1);
56+
});
57+
```
58+
59+
### 2.2 Does the config use `addDynamicContracts`?
60+
61+
Yes — `ERC20FeeToken` uses Envio's dynamic contract registration via `contractRegister`. The config marks this contract's address list as empty (`address: []`) with a comment: _"Dynamically registered from FPMMDeployed events"_.
62+
63+
`VirtualPool` pools are **not** dynamically registered — they are pre-listed statically (or empty on testnet). There's a `VirtualPoolFactory` that emits `VirtualPoolDeployed`/`PoolDeprecated` events, but the VirtualPool contract addresses themselves are pre-seeded in the config, not registered at runtime via `contractRegister`.
64+
65+
### 2.3 Will Envio's multichain config support the same dynamic discovery pattern?
66+
67+
**Yes, with one caveat.** Envio's multichain mode supports `contractRegister` / dynamic contract addition per-chain. The `context.addERC20FeeToken(...)` pattern works in multichain mode — Envio scopes dynamic additions to the chain where the triggering event occurred.
68+
69+
The current VirtualPool static listing approach also translates cleanly to multichain — each chain block in the YAML lists its own addresses.
70+
71+
**The caveat:** With `unordered_multichain_mode: true` already set in all configs, Envio processes events across chains concurrently without strict ordering guarantees. This is already the current setting, so no behavioral change is introduced by going multichain.
72+
73+
---
74+
75+
## 3. GO / NO-GO Gate for Phase 1
76+
77+
### Verdict: ✅ GO — with mandatory ID migration
78+
79+
**All blockers are known and mechanical.** No architectural unknowns.
80+
81+
### Entities requiring ID changes before multichain can be enabled:
82+
83+
| Entity | Current ID | Required multichain ID | Scope of FK updates |
84+
| ------------------- | ---------------------------- | -------------------------------------- | ------------------------------------ |
85+
| `Pool` | `{address}` | `{chainId}-{address}` | ALL other entities' `poolId` field |
86+
| `PoolSnapshot` | `{poolId}-{hourTs}` | `{chainId}-{address}-{hourTs}` | Self + pool.ts `snapshotId()` helper |
87+
| `TradingLimit` | `{poolId}-{token}` | `{chainId}-{address}-{token}` | fpmm.ts |
88+
| `LiquidityPosition` | `{poolId}-{address}` | `{chainId}-{address}-{lpAddress}` | fpmm.ts |
89+
| `OlsPool` | `{poolAddress}-{olsAddress}` | `{chainId}-{poolAddress}-{olsAddress}` | openLiquidityStrategy.ts |
90+
91+
### Key implementation notes for Phase 1:
92+
93+
1. **`eventId()` is already chain-safe** — no change needed to event-log-derived IDs.
94+
2. **All handlers receive `event.chainId`** — already passed to `upsertPool()`. The plumbing is there.
95+
3. **`upsertPool()` in `pool.ts`** — this is the central write point for `Pool.id`. Change it here and all FPMM/VirtualPool handlers benefit automatically.
96+
4. **`snapshotId()` in `helpers.ts`** — needs to incorporate `chainId`.
97+
5. **SortedOracles handlers** use `getPoolsByFeed()` / `getPoolsWithReferenceFeed()` from `rpc.ts` to look up pools by `referenceRateFeedID`. These must query by `{chainId}-prefixed poolId` or be chain-scoped to avoid cross-chain oracle bleed.
98+
6. **`ERC20FeeToken` handler** does `context.Pool.get(sender)` where `sender` is a pool address — this lookup must become `context.Pool.get(\`${chainId}-${sender}\`)`.
99+
7. **`feeToken.ts` / `selectStaleTransfers`**`ProtocolFeeTransfer` IDs are already chain-safe, but any Pool FK lookups need updating.
100+
8. **Schema `@index` fields**`poolId` indexes are string-based and will continue to work correctly once the value format changes (no schema DDL change required, just value format).
101+
102+
### No blockers that prevent proceeding:
103+
104+
- Dynamic contract discovery (`ERC20FeeToken`) is multichain-compatible ✅
105+
- `unordered_multichain_mode: true` already set ✅
106+
- `event.chainId` already available in all handlers ✅
107+
- ID fix scope is well-bounded and mechanical ✅

ui-dashboard/src/app/pool/[poolId]/page.tsx

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,7 +1094,10 @@ function LpsTab({ poolId, pool }: { poolId: string; pool: Pool | null }) {
10941094
? Number(pool.oraclePrice) / 1e24
10951095
: null;
10961096
const usdmIsToken0 = USDM_SYMBOLS.has(sym0);
1097-
const showUsd = feedVal !== null && hasReserves;
1097+
const usdmIsToken1 = USDM_SYMBOLS.has(sym1);
1098+
// Only show USD values when exactly one side is USDm (ensures meaningful conversion)
1099+
const hasUsdmSide = usdmIsToken0 !== usdmIsToken1; // XOR: exactly one side is USDm
1100+
const showUsd = feedVal !== null && hasReserves && hasUsdmSide;
10981101

10991102
return (
11001103
<>
@@ -1128,23 +1131,36 @@ function LpsTab({ poolId, pool }: { poolId: string; pool: Pool | null }) {
11281131
const shareNum =
11291132
totalLiquidity > BigInt(0)
11301133
? Number(
1131-
(position.netLiquidity * BigInt(1_000_000)) / totalLiquidity,
1134+
(position.netLiquidity * BigInt(1_000_000)) /
1135+
totalLiquidity,
11321136
) / 1_000_000
11331137
: 0;
11341138
const sharePct = (shareNum * 100).toFixed(2);
11351139

11361140
const tok0 = hasReserves ? shareNum * reserves0Raw : null;
11371141
const tok1 = hasReserves ? shareNum * reserves1Raw : null;
11381142

1139-
// Convert each token to USD: the non-stable token uses the oracle price
1140-
const tok0Usd =
1141-
tok0 !== null && feedVal && !usdmIsToken0
1142-
? tok0 * feedVal
1143-
: tok0;
1144-
const tok1Usd =
1145-
tok1 !== null && feedVal && usdmIsToken0
1146-
? tok1 * feedVal
1147-
: tok1;
1143+
// Convert each token to USD only when we have a valid USDm-paired oracle price.
1144+
// tok0Usd = USD value of tok0:
1145+
// - if tok0 IS USDm → already in USD, value = tok0
1146+
// - if tok1 IS USDm → tok0 is the non-stable, convert via feedVal
1147+
// - otherwise → no valid conversion, null
1148+
const tok0Usd: number | null =
1149+
tok0 === null || !hasUsdmSide
1150+
? null
1151+
: usdmIsToken0
1152+
? tok0 // tok0 is USDm → already USD
1153+
: feedVal !== null
1154+
? tok0 * feedVal // tok0 is non-stable → convert
1155+
: null;
1156+
const tok1Usd: number | null =
1157+
tok1 === null || !hasUsdmSide
1158+
? null
1159+
: usdmIsToken1
1160+
? tok1 // tok1 is USDm → already USD
1161+
: feedVal !== null
1162+
? tok1 * feedVal // tok1 is non-stable → convert
1163+
: null;
11481164
const totalUsd =
11491165
tok0Usd !== null && tok1Usd !== null ? tok0Usd + tok1Usd : null;
11501166

ui-dashboard/src/components/lp-concentration-chart.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,6 @@ export function LpConcentrationChart({
5757

5858
const resolveLabel = (addr: string) => resolvePieLabel(addr, getLabel);
5959

60-
// Use raw addresses as labels — Plotly merges slices with duplicate labels,
61-
// so human names must not be the label key. Display names go in `text`.
6260
const labels = [
6361
...top.map((p) => p.address),
6462
...(otherTotal > BigInt(0) ? ["other"] : []),

0 commit comments

Comments
 (0)