|
| 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 ✅ |
0 commit comments