Files
zeekay 8142506538 Value pools only at a head that has reached the index, and read stake where luxd serves it
A chain re-imported from genesis reports a head far under the indexed cursor;
its state there predates the events, so the pass now leaves the aggregates as
they are until the head catches up.

Stake is GET /v1/chain/p/ops/stake/total, in microLUX. The read used the
removed /bc/ path, JSON-RPC and a 10^9 divisor, so it always reported zero.
go.sum records hanzoai/replicate v0.6.0 as sum.golang.org does.
2026-09-23 00:35:11 -07:00

1057 lines
41 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package indexer subscribes to EVM events and writes to storage.
//
// Connects to any EVM JSON-RPC, polls for new blocks, decodes logs,
// and writes structured data (swaps, mints, burns, transfers) to storage.
package indexer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/luxfi/graph/storage"
)
// Status reports indexer progress.
type Status struct {
LatestBlock uint64 `json:"latestBlock"`
IndexedEvents uint64 `json:"indexedEvents"`
}
// Config tunes an Indexer. PoolManager defaults to the 0x9999 settlement
// address, which every chain shares, and StartBlock to 0 (index from genesis).
// FactoryV2, FactoryV3 and Native have no defaults: they differ per chain, and
// a guess would be a claim that one chain's pools belong to another. Left
// empty, the matching surface stays empty.
type Config struct {
RPC string
// PoolManager is the DEX settlement precompile address (0x9999). Logs from
// this address are additionally derived into the DEX (CLOB) graph schema as
// Fill/Market entities. Lower-cased on construction for case-insensitive
// compare against eth_getLogs `address` fields.
PoolManager string
// FactoryV2 is the canonical Uniswap-v2 factory. ONLY its PairCreated is
// authoritative; pairs it creates are the only valid V2 Swap emitters. A
// PairCreated from any other contract is rejected (otherwise anyone could
// seed phantom pairs and inflate the public Factory/TVL/volume aggregates).
// Lower-cased on construction.
FactoryV2 string
// FactoryV3 is the canonical Uniswap-v3 factory — same trust-root role as
// FactoryV2 for PoolCreated and V3 Swaps. Lower-cased on construction.
FactoryV3 string
// Native is the wrapped native token (WLUX on Lux, WZOO on Zoo). The wire
// format quotes every token against it — `token.derivedETH` is a price in
// native units and `bundle.ethPriceUSD` is what one native unit is worth —
// so this address is what makes a price expressible at all. Lower-cased on
// construction. Empty => no native anchor, so no token quotes a price.
Native string
// StartBlock is the genesis-relative block to (re)index from. On a clean
// chain relaunch the indexer rewinds to this value (see poll's reorg guard).
StartBlock uint64
// Label names this indexer in its log lines — e.g. "cchain/amm". A process
// that runs one indexer per (chain, subgraph) is otherwise unable to say
// WHICH indexer emitted a line, and an indexer that never logs is then
// indistinguishable from one that is wedged. Empty => unprefixed.
Label string
// DexRPC, when non-empty, is the native D-Chain (dexvm) CLOB read-RPC root —
// e.g. http://node:9650/v1/chain/D/dex. Setting it runs an ADDITIONAL, orthogonal
// CLOBSource (clob.go) that polls dex_get_{markets,trades,orders} and writes
// the same Market/Fill/Order entities the EVM 0x9999 path writes. This is the
// native trading source: a D-Chain trade is a consensus state transition, not an
// EVM log, so it never appears on the EVM RPC. Empty => EVM-only (unchanged).
DexRPC string
// GenesisSupply is every native unit minted at block 0, in whole tokens —
// 2000000000000 for Lux and for Zoo. It is a fact fixed at genesis, so it
// belongs beside the factory addresses rather than being read: the alloc is
// not exposed over RPC and summing every account is not a query.
//
// It is emphatically NOT platform.getCurrentSupply. That is the P-Chain's
// own counter, which on Lux reads 13.27 billion against a real supply of
// two trillion — a market cap 150 times too small, printed with confidence.
GenesisSupply string
// Treasury holds what has not been distributed. Its balance is read live,
// because the whole point is that it goes down as tokens reach holders: at
// genesis it held all 2T, today it holds 994.7B, and the difference is what
// actually circulates.
Treasury string
}
// reorgDepth is how far the chain head may legitimately move backwards (deep
// reorg) before we treat a lower head as a chain RESET (re-genesis) rather than
// a reorg. A re-genesised chain reports a head far below our persisted cursor;
// a normal reorg is shallow. This avoids wiping progress on a transient blip
// while still self-healing across a clean relaunch.
const reorgDepth = 128
// regenesisConfirmations is how many CONSECUTIVE polls must observe a head far
// below our cursor before we even SPEND an RPC to confirm a re-genesis. A single
// low reading is NOT enough: an idle chain ("Building block", head=0x0) or a briefly
// lagging RPC backend can momentarily report a head below our cursor, and probing on
// every blip would be wasteful. A genuine relaunch keeps the head low for many polls
// as the fresh chain rebuilds; a transient dip recovers within one or two. The streak
// is a CHEAP PRE-FILTER only — the rewind itself is gated on a deterministic signal
// (a changed genesis hash), so even a head held low for the full window by a deeply
// lagging load-balanced backend never wipes a still-canonical chain.
const regenesisConfirmations = 3
// genesisBlockTag is the block selector for the genesis (block 0) header. The
// genesis hash is the chain-identity fingerprint we compare to decide a re-genesis:
// every node of a chain has block 0 regardless of how far behind its head is, so an
// equal genesis hash deterministically rules out a re-genesis even on a deeply-stale
// backend — unlike a by-number/by-hash probe of the CURSOR block, which a backend
// whose head is below the cursor cannot serve (it would null-out and false-trip).
const genesisBlockTag = "0x0"
// Indexer watches an EVM RPC and writes events to storage.
type Indexer struct {
rpc string
poolManager string // lower-cased 0x9999 settlement address
factoryV2 string // lower-cased canonical V2 factory (PairCreated trust root)
factoryV3 string // lower-cased canonical V3 factory (PoolCreated trust root)
genesisSupply string // whole native units minted at block 0
treasury string // lower-cased holder of undistributed supply
native string // lower-cased wrapped native token — the unit `derivedETH` is denominated in
label string // log prefix identifying this indexer (see Config.Label)
startBlock uint64
store *storage.Store
client *http.Client
// clob is the optional native D-Chain CLOB source (clob.go). Non-nil only when
// Config.DexRPC is set; Run then drives it in parallel with the EVM poller.
clob *CLOBSource
lastBlock uint64
lowHeadStreak int // consecutive polls observing a head far below the cursor (re-genesis pre-filter)
genesisHash string // chain-identity fingerprint; a change ⇒ true re-genesis (lazily captured)
status Status
// tokenCache memoises what a token is (symbol/name/decimals/supply) per
// address for the process lifetime so each token is read from the chain at
// most once — never per-block. Keyed lower-cased address → *SeedTokenData.
// See erc20.go (token).
tokenCache sync.Map
// written remembers each day cell as it was last persisted, so a pass that
// recomputes the whole history writes only the days that actually moved.
// Owned by the valuation goroutine (series.go).
written map[string]cell
// genesisHashFn fetches the chain's genesis (block 0) hash. It is a field so
// tests can drive the canonicality decision deterministically without a live
// RPC; in production it is fetchGenesisHash (eth_getBlockByNumber 0x0). Honors
// the no-eth_getCode rule — only a block header is read.
genesisHashFn func(ctx context.Context) (string, error)
}
// New creates an indexer connected to the given RPC endpoint with default
// settlement address and a genesis (block 0) start.
func New(rpc string, store *storage.Store) *Indexer {
return NewWithConfig(Config{RPC: rpc}, store)
}
// NewWithConfig creates an indexer from an explicit Config.
func NewWithConfig(cfg Config, store *storage.Store) *Indexer {
pm := cfg.PoolManager
if pm == "" {
pm = LXSettleAddress
}
idx := &Indexer{
rpc: cfg.RPC,
poolManager: strings.ToLower(pm),
factoryV2: strings.ToLower(cfg.FactoryV2),
factoryV3: strings.ToLower(cfg.FactoryV3),
native: strings.ToLower(cfg.Native),
genesisSupply: cfg.GenesisSupply,
treasury: strings.ToLower(cfg.Treasury),
label: cfg.Label,
startBlock: cfg.StartBlock,
store: store,
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
},
},
}
idx.genesisHashFn = idx.fetchGenesisHash
// A cursor only means anything for the addresses it was read with. Change
// the factory and every event it ever emitted is already behind the cursor,
// so the new address is never seen and the old rows stay — the store keeps
// answering for a book that is not on this chain. Zoo mainnet sat that way:
// its declared factory held no code, the real one had twelve pools, and the
// correction alone would have changed nothing because the cursor was at the
// tip. So the source is part of the store's identity, exactly as the genesis
// hash is part of the chain's, and a change to either rewinds.
//
// An UNRECORDED source is unknown, not a mismatch. Every store written before
// this existed has none, and reading that as "different" would re-read every
// chain from genesis the first time this ships — a million blocks on Lux, to
// rebuild rows that are already right. Unknown is its own state: adopt the
// current set as the store's and drop nothing.
fp := strings.ToLower(strings.Join([]string{idx.factoryV2, idx.factoryV3, idx.poolManager, idx.native}, "|"))
switch was := store.Source(); {
case was == "":
store.SetSource(fp)
case was != fp:
idx.logf("[indexer] source changed (%s -> %s) — re-reading from %d", short(was), short(fp), cfg.StartBlock)
if err := store.DropDerived(); err != nil {
idx.logf("[indexer] source changed but the old rows would not drop: %v", err)
}
store.SetSource(fp)
}
idx.lastBlock = idx.store.GetLastBlock()
if idx.lastBlock < cfg.StartBlock {
idx.lastBlock = cfg.StartBlock
}
if cfg.DexRPC != "" {
idx.clob = NewCLOBSource(cfg.DexRPC, store)
}
return idx
}
// fetchGenesisHash reads the chain's genesis (block 0) header hash via
// eth_getBlockByNumber. Only the header is fetched (txs=false); this honors the
// no-eth_getCode rule and is cheap. The genesis hash is the chain-identity
// fingerprint used to distinguish a true re-genesis (hash changed) from a merely
// lagging/idle backend on the same chain (hash unchanged).
func (idx *Indexer) fetchGenesisHash(ctx context.Context) (string, error) {
raw, err := idx.rpcCall(ctx, "eth_getBlockByNumber", []interface{}{genesisBlockTag, false})
if err != nil {
return "", err
}
var head struct {
Hash string `json:"hash"`
}
if err := json.Unmarshal(raw, &head); err != nil {
return "", fmt.Errorf("parse genesis header: %w", err)
}
if head.Hash == "" {
return "", fmt.Errorf("genesis header missing hash")
}
return strings.ToLower(head.Hash), nil
}
// ensureGenesisHash lazily captures the current chain's genesis hash as the
// baseline the first time it succeeds. Idempotent and a no-op once set (a string
// check), so it costs one RPC only on the first successful poll. Capturing the
// baseline before any reset decision guarantees maybeResetForRegenesis always has
// something to compare against.
func (idx *Indexer) ensureGenesisHash(ctx context.Context) {
if idx.genesisHash != "" {
return
}
h, err := idx.genesisHashFn(ctx)
if err != nil {
idx.logf("[indexer] genesis hash baseline unavailable (will retry): %v", err)
return
}
idx.genesisHash = h
}
// Status returns current indexer progress.
func (idx *Indexer) Status() Status {
return idx.status
}
// maybeResetForRegenesis rewinds the cursor to StartBlock ONLY on a confirmed
// re-genesis: the chain's genesis hash has changed. A re-genesised chain reports a
// head near zero while our store still holds the old chain's height; without this
// rewind `latest <= lastBlock` would hold forever and the new chain would never index.
//
// Two gates, in order:
//
// 1. CHEAP PRE-FILTER (hysteresis): the head must stay far below our cursor for
// regenesisConfirmations consecutive polls. A shallow reorg, an idle chain
// ("Building block", head=0x0), or a transient lag clears the streak before we
// spend any extra RPC. This keeps the common case free of probes.
//
// 2. DETERMINISTIC SIGNAL: only after the streak confirms do we fetch the current
// genesis hash and compare it to our captured baseline. A CHANGED hash ⇒ a
// different chain ⇒ true re-genesis ⇒ rewind. An UNCHANGED hash ⇒ the same chain
// behind a deeply-stale/idle/load-balanced backend ⇒ we must NOT wipe; we clear
// the streak (it is proven not a reset) and keep the cursor. Because every node
// of a chain serves block 0 regardless of how far behind its head is, this signal
// cannot be false-tripped by lag — unlike the poll count alone, which a backend
// held >reorgDepth behind for the full window would satisfy on a still-canonical
// chain.
//
// If the baseline genesis hash is not yet captured (first run, or the probe failed),
// we capture the current one and decline to wipe this round — a reset cannot be
// proven without a baseline to compare against.
func (idx *Indexer) maybeResetForRegenesis(ctx context.Context, latest uint64) {
farBelow := idx.lastBlock > idx.startBlock+reorgDepth && latest+reorgDepth < idx.lastBlock
if !farBelow {
idx.lowHeadStreak = 0 // head recovered (or normal) — abandon any suspicion.
return
}
idx.lowHeadStreak++
if idx.lowHeadStreak < regenesisConfirmations {
idx.logf("[indexer] head %d << cursor %d (>%d behind) — suspected chain reset, awaiting confirmation %d/%d",
latest, idx.lastBlock, reorgDepth, idx.lowHeadStreak, regenesisConfirmations)
return
}
// Streak confirmed — spend one RPC for the deterministic check.
current, err := idx.genesisHashFn(ctx)
if err != nil {
idx.logf("[indexer] head %d << cursor %d for %d polls but genesis-hash probe failed (%v) — "+
"NOT wiping; cannot confirm a reset without a canonical signal", latest, idx.lastBlock, idx.lowHeadStreak, err)
return
}
if idx.genesisHash == "" {
// No baseline yet — adopt the current hash and decline to wipe this round.
idx.genesisHash = current
idx.logf("[indexer] head %d << cursor %d but no genesis baseline yet — captured %s, NOT wiping",
latest, idx.lastBlock, current)
return
}
if current == idx.genesisHash {
// Same chain, just deeply behind/idle. The poll-count gate alone would have
// wiped here; the genesis hash proves it canonical, so we hold the cursor.
idx.logf("[indexer] head %d << cursor %d for %d polls but genesis hash unchanged (%s) — "+
"canonical chain behind a stale backend, NOT wiping", latest, idx.lastBlock, idx.lowHeadStreak, current)
idx.lowHeadStreak = 0
return
}
idx.logf("[indexer] genesis hash changed %s -> %s (head %d << cursor %d) — re-genesis confirmed, rewinding to %d",
idx.genesisHash, current, latest, idx.lastBlock, idx.startBlock)
idx.lastBlock = idx.startBlock
idx.store.SetLastBlock(idx.startBlock)
idx.genesisHash = current
idx.lowHeadStreak = 0
}
// Run starts the indexer loop. Blocks until ctx is cancelled. When a native
// D-Chain CLOB source is configured (Config.DexRPC), its poll loop runs in a
// sibling goroutine bound to the same ctx — orthogonal to the EVM poller, sharing
// only the store. A native-DEX chain has its trading state on the D-Chain, not on
// the EVM RPC, so both sources run to populate the AMM (EVM) and DEX (CLOB) views.
func (idx *Indexer) Run(ctx context.Context) error {
idx.logf("[indexer] starting — rpc=%s lastBlock=%d", idx.rpc, idx.lastBlock)
if idx.clob != nil {
go func() {
if err := idx.clob.Run(ctx); err != nil && ctx.Err() == nil {
idx.logf("[indexer] clob source: %v", err)
}
}()
}
// Swaps an older build stored with a block number where their time belongs
// are dated from their block header. It is the whole reason a day series can
// cover the history already indexed rather than starting today, and it stops
// as soon as every row has a time.
go idx.keepSwapTimes(ctx)
// The valuation pass (valuation.go) derives the USD aggregates from current
// on-chain balances. It runs beside the log poller, not inside it: reserves
// are chain STATE, events are chain HISTORY, and an idle chain still has
// state to report. Sharing only the store keeps the two concerns apart.
go idx.runValuation(ctx)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := idx.pollGuarded(ctx); err != nil {
idx.logf("[indexer] poll error: %v", err)
}
}
}
}
// pollGuarded runs one poll, converting any panic (e.g. a malformed RPC log that
// slips a length check) into an error so a single bad log cannot crash the whole
// graphd process — the loop logs it and continues on the next tick. This is the
// process-survival backstop; individual handlers still length-check their inputs.
func (idx *Indexer) pollGuarded(ctx context.Context) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("poll panic recovered: %v", r)
}
}()
return idx.poll(ctx)
}
// rpcCall makes a JSON-RPC POST and returns the result field.
func (idx *Indexer) rpcCall(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
type rpcReq struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params interface{} `json:"params"`
ID int `json:"id"`
}
type rpcResp struct {
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
body, err := json.Marshal(rpcReq{JSONRPC: "2.0", Method: method, Params: params, ID: 1})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", idx.rpc, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := idx.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return nil, err
}
var rr rpcResp
if err := json.Unmarshal(respBody, &rr); err != nil {
return nil, fmt.Errorf("rpc decode: %w", err)
}
if rr.Error != nil {
return nil, fmt.Errorf("rpc error %d: %s", rr.Error.Code, rr.Error.Message)
}
return rr.Result, nil
}
// parseHexUint64 parses a 0x-prefixed hex string to uint64.
func parseHexUint64(s string) (uint64, error) {
s = strings.TrimPrefix(s, "0x")
return strconv.ParseUint(s, 16, 64)
}
// knownTopics returns all event topic0s we want to filter for.
func knownTopics() []string {
topics := []string{
// AMM (V2/V3/V4)
SigPairCreated, SigSwapV2, SigMintV2, SigBurnV2, SigSync, SigTransfer,
SigPoolCreated, SigInitialize, SigSwapV3, SigMintV3, SigBurnV3,
SigCollect, SigFlash,
SigInitializeV4, SigModifyLiquidity, SigSwapV4,
// Native DEX (CLOB) settlement at 0x9999.
SigDEXFill,
}
// Securities — full ERC-3643 + ONCHAINID surface.
return append(topics, SecuritiesTopics()...)
}
// logEntry is a decoded eth_getLogs result entry.
//
// time is when the block was mined, in unix seconds. It is not part of the RPC
// result — a log says only which block it was in — so the poll reads it from
// the block header and stamps it here (see stampTimes). Zero means the header
// was not read, and a handler that needs a time drops the event rather than
// dating it to 1970.
type logEntry struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
BlockNumber string `json:"blockNumber"`
TransactionHash string `json:"transactionHash"`
LogIndex string `json:"logIndex"`
TransactionIndex string `json:"transactionIndex"`
time int64
}
// head is the chain's latest block number.
func (idx *Indexer) head(ctx context.Context) (uint64, error) {
raw, err := idx.rpcCall(ctx, "eth_blockNumber", []interface{}{})
if err != nil {
return 0, fmt.Errorf("eth_blockNumber: %w", err)
}
var hexBlock string
if err := json.Unmarshal(raw, &hexBlock); err != nil {
return 0, fmt.Errorf("parse blockNumber: %w", err)
}
latest, err := parseHexUint64(hexBlock)
if err != nil {
return 0, fmt.Errorf("parse hex block: %w", err)
}
return latest, nil
}
func (idx *Indexer) poll(ctx context.Context) error {
latest, err := idx.head(ctx)
if err != nil {
return err
}
// Capture the genesis-hash baseline (once) before any reset decision, then
// run the re-genesis self-heal (see maybeResetForRegenesis).
idx.ensureGenesisHash(ctx)
idx.maybeResetForRegenesis(ctx, latest)
// Nothing new
if latest <= idx.lastBlock {
return nil
}
fromBlock := idx.lastBlock + 1
toBlock := latest
// Cap batch size to 2000 blocks
if toBlock-fromBlock > 2000 {
toBlock = fromBlock + 2000
}
// 2. Get logs for known event signatures
filter := map[string]interface{}{
"fromBlock": fmt.Sprintf("0x%x", fromBlock),
"toBlock": fmt.Sprintf("0x%x", toBlock),
"topics": []interface{}{knownTopics()},
}
raw, err := idx.rpcCall(ctx, "eth_getLogs", []interface{}{filter})
if err != nil {
return fmt.Errorf("eth_getLogs: %w", err)
}
var logs []logEntry
if err := json.Unmarshal(raw, &logs); err != nil {
return fmt.Errorf("parse logs: %w", err)
}
// 3. Date the logs, then process them. One header read per block in the
// batch, before any handler runs: a swap's time is part of the swap, not
// something to be reconstructed afterwards.
if err := idx.stampTimes(ctx, logs); err != nil {
return fmt.Errorf("block times: %w", err)
}
for i := range logs {
idx.processLog(ctx, &logs[i])
idx.status.IndexedEvents++
}
// 4. Update progress
idx.lastBlock = toBlock
idx.status.LatestBlock = toBlock
idx.store.SetLastBlock(toBlock)
if len(logs) > 0 {
idx.logf("[indexer] blocks %d..%d — %d events", fromBlock, toBlock, len(logs))
}
return nil
}
// processLog matches a log entry's topic0 and writes to storage. ctx is
// threaded through so the token-seeding handlers can read ERC20 metadata via
// eth_call on the same RPC the poll loop already holds a context for.
func (idx *Indexer) processLog(ctx context.Context, l *logEntry) {
if len(l.Topics) == 0 {
return
}
topic0 := l.Topics[0]
blockNum, _ := parseHexUint64(l.BlockNumber)
txHash := l.TransactionHash
logIdx := l.LogIndex
switch topic0 {
case SigSwapV2:
idx.handleSwapV2(l, blockNum, txHash, logIdx)
case SigSwapV3:
idx.handleSwapV3(l, blockNum, txHash, logIdx)
case SigPairCreated:
idx.handlePairCreated(ctx, l)
case SigPoolCreated:
idx.handlePoolCreated(ctx, l)
case SigTransfer:
idx.handleTransfer(ctx, l, txHash, logIdx)
case SigInitializeV4:
idx.handleInitializeV4(ctx, l)
case SigSwapV4:
idx.handleSwapV4(l, blockNum, txHash, logIdx)
case SigDEXFill:
idx.handleDEXFill(l, blockNum, txHash, logIdx)
case SigMintV2, SigMintV3, SigBurnV2, SigBurnV3, SigSync,
SigCollect, SigFlash, SigInitialize, SigModifyLiquidity:
// Deliberately inert. Every one of these events only restates what a
// pool HOLDS, and that is derived state the valuation pass already owns
// (valuation.go) — from balanceOf at head, which covers V2, V3 and V4
// alike and needs no history replay. Decoding them here would be a
// second, V2-only writer for the same field.
// ── ERC-3643 IToken ───────────────────────────────────────────────
case SigAddressFrozen:
idx.handleAddressFrozen(l, blockNum, txHash, logIdx)
case SigTokensFrozen:
idx.handleTokensFrozen(l, blockNum, txHash, logIdx, true)
case SigTokensUnfrozen:
idx.handleTokensFrozen(l, blockNum, txHash, logIdx, false)
case SigSecurityPaused:
idx.handleSecurityPause(l, blockNum, txHash, logIdx, true)
case SigSecurityUnpaused:
idx.handleSecurityPause(l, blockNum, txHash, logIdx, false)
case SigRecoverySuccess:
idx.handleRecoverySuccess(l, blockNum, txHash, logIdx)
case SigUpdatedTokenInformation:
idx.handleSimpleSecuritiesEvent(l, blockNum, txHash, logIdx, "UpdatedTokenInformation")
case SigIdentityRegistryAdded:
idx.handleSimpleSecuritiesEvent(l, blockNum, txHash, logIdx, "IdentityRegistryAdded")
case SigComplianceAdded:
idx.handleSimpleSecuritiesEvent(l, blockNum, txHash, logIdx, "ComplianceAdded")
// ── IdentityRegistry / Storage ────────────────────────────────────
case SigIdentityRegistered:
idx.handleIdentityRegistryAction(l, blockNum, txHash, logIdx, "IdentityRegistered")
case SigIdentityRemoved:
idx.handleIdentityRegistryAction(l, blockNum, txHash, logIdx, "IdentityRemoved")
case SigIdentityUpdated:
idx.handleIdentityRegistryAction(l, blockNum, txHash, logIdx, "IdentityUpdated")
case SigCountryUpdated:
idx.handleIdentityRegistryAction(l, blockNum, txHash, logIdx, "CountryUpdated")
case SigIdentityStored:
idx.handleIdentityRegistryAction(l, blockNum, txHash, logIdx, "IdentityStored")
// ── ONCHAINID (ERC-734 / ERC-735) ─────────────────────────────────
case SigClaimAdded:
idx.handleClaim(l, blockNum, txHash, logIdx, "ClaimAdded")
case SigClaimRemoved:
idx.handleClaim(l, blockNum, txHash, logIdx, "ClaimRemoved")
case SigClaimChanged:
idx.handleClaim(l, blockNum, txHash, logIdx, "ClaimChanged")
case SigKeyAdded:
idx.handleKey(l, blockNum, txHash, logIdx, "KeyAdded")
case SigKeyRemoved:
idx.handleKey(l, blockNum, txHash, logIdx, "KeyRemoved")
case SigOnchainIdApproved:
idx.handleSimpleSecuritiesEvent(l, blockNum, txHash, logIdx, "OnchainIdApproved")
case SigOnchainIdExecuted:
idx.handleSimpleSecuritiesEvent(l, blockNum, txHash, logIdx, "OnchainIdExecuted")
// ── TrustedIssuersRegistry / ClaimTopicsRegistry ──────────────────
case SigTrustedIssuerAdded:
idx.handleTrustedIssuer(l, blockNum, txHash, logIdx, "TrustedIssuerAdded")
case SigTrustedIssuerRemoved:
idx.handleTrustedIssuer(l, blockNum, txHash, logIdx, "TrustedIssuerRemoved")
case SigClaimTopicsUpdated:
idx.handleTrustedIssuer(l, blockNum, txHash, logIdx, "ClaimTopicsUpdated")
case SigClaimTopicAdded:
idx.handleClaimTopic(l, blockNum, txHash, logIdx, "ClaimTopicAdded")
case SigClaimTopicRemoved:
idx.handleClaimTopic(l, blockNum, txHash, logIdx, "ClaimTopicRemoved")
// ── ModularCompliance / IModule ───────────────────────────────────
case SigModuleAdded, SigModuleRemoved, SigTokenBound, SigTokenUnbound, SigModuleInteraction:
idx.handleComplianceEvent(l, blockNum, txHash, logIdx, topic0)
}
}
// decodeUint256 reads a 32-byte hex word from data at the given word index.
// Malformed hex (a non-hex digit in the word) yields 0 rather than a silently
// half-parsed value: SetString reports ok=false and leaves n unmodified, so we
// reset to a clean zero. A bad word must not masquerade as a real (partial) amount.
func decodeUint256(data string, wordIndex int) *big.Int {
data = strings.TrimPrefix(data, "0x")
start := wordIndex * 64
if start+64 > len(data) {
return new(big.Int)
}
n := new(big.Int)
if _, ok := n.SetString(data[start:start+64], 16); !ok {
return new(big.Int)
}
return n
}
// topicAddr extracts an address from a topic (last 40 hex chars of 66-char topic).
func topicAddr(topic string) string {
if len(topic) >= 42 {
return "0x" + topic[len(topic)-40:]
}
return topic
}
func (idx *Indexer) handleSwapV2(l *logEntry, blockNum uint64, txHash, logIdx string) {
// A V2 Swap is emitted by the pair contract, NOT the factory — so the gate is
// "is the emitter a pair the canonical V2 factory created?". handlePairCreated
// (gated to factoryV2) is the ONLY path that registers a pair, so a Swap from a
// rogue pair was never registered and is dropped here. This closes the parallel
// bypass into the same SeedSwap/factory volume aggregates the V4 path now gates.
if !idx.isKnownPool(l.Address) {
return
}
id := fmt.Sprintf("%s#%s", txHash, logIdx)
sender := ""
if len(l.Topics) > 1 {
sender = topicAddr(l.Topics[1])
}
amount0In := decodeUint256(l.Data, 0)
amount1In := decodeUint256(l.Data, 1)
amount0Out := decodeUint256(l.Data, 2)
amount1Out := decodeUint256(l.Data, 3)
// Net amounts
amount0 := new(big.Int).Sub(amount0In, amount0Out)
amount1 := new(big.Int).Sub(amount1In, amount1Out)
idx.store.SeedSwap(id, &storage.SeedSwapData{
Timestamp: l.time,
Block: blockNum,
Pool: l.Address,
Amount0: amount0.String(),
Amount1: amount1.String(),
AmountUSD: "0",
Sender: sender,
})
}
func (idx *Indexer) handleSwapV3(l *logEntry, blockNum uint64, txHash, logIdx string) {
// Same trust model as handleSwapV2: a V3 Swap is emitted by the pool, which is
// only registered via handlePoolCreated (gated to factoryV3). A Swap from an
// unregistered (rogue) pool is dropped.
if !idx.isKnownPool(l.Address) {
return
}
id := fmt.Sprintf("%s#%s", txHash, logIdx)
sender := ""
if len(l.Topics) > 1 {
sender = topicAddr(l.Topics[1])
}
// A V3 Swap emits `int256 amount0, int256 amount1` — one leg of every swap
// is NEGATIVE (the token leaving the pool). Reading them unsigned turned
// that leg into ~2^256, which is invisible until something actually adds the
// amounts up: the volume rollup then reported astronomical figures.
amount0 := decodeInt256(l.Data, 0)
amount1 := decodeInt256(l.Data, 1)
idx.store.SeedSwap(id, &storage.SeedSwapData{
Timestamp: l.time,
Block: blockNum,
Pool: l.Address,
Amount0: amount0.String(),
Amount1: amount1.String(),
AmountUSD: "0",
Sender: sender,
})
}
func (idx *Indexer) handlePairCreated(ctx context.Context, l *logEntry) {
// Scope to the canonical V2 factory (the trust root), exactly as the V4
// handlers scope to 0x9999. A PairCreated from any other contract is rejected:
// honoring it would let anyone seed phantom pairs/tokens and inflate the
// explorer's public Factory/TVL/volume aggregates — and would register a rogue
// pair address whose Swaps would then be accepted (the parallel bypass).
if len(l.Topics) < 3 || !strings.EqualFold(l.Address, idx.factoryV2) {
return
}
data := strings.TrimPrefix(l.Data, "0x")
if len(data) < 64 {
return
}
token0 := topicAddr(l.Topics[1])
token1 := topicAddr(l.Topics[2])
pair := "0x" + data[:64]
// Lower-case the pair key so isKnownPool's Swap-side lookup matches regardless
// of how the RPC backend cased the emitter address (the V2 Swap's l.Address).
pair = strings.ToLower(topicAddr("0x" + pair[len(pair)-40:]))
idx.store.SeedPool(pair, &storage.SeedPoolData{
Token0: token0,
Token1: token1,
FeeTier: 3000,
})
idx.token(ctx, token0)
idx.token(ctx, token1)
}
func (idx *Indexer) handlePoolCreated(ctx context.Context, l *logEntry) {
// Scope to the canonical V3 factory (the trust root), mirroring handlePairCreated
// and the V4 0x9999 gate. A PoolCreated from a rogue contract is rejected so it
// can neither inflate the Factory/TVL aggregates nor register a rogue pool whose
// Swaps would be honored.
if len(l.Topics) < 4 || !strings.EqualFold(l.Address, idx.factoryV3) {
return
}
data := strings.TrimPrefix(l.Data, "0x")
if len(data) < 64 {
return
}
token0 := topicAddr(l.Topics[1])
token1 := topicAddr(l.Topics[2])
feeHex := strings.TrimPrefix(l.Topics[3], "0x")
fee, _ := strconv.ParseInt(feeHex, 16, 64)
// Pool address is in data (last 20 bytes of first 32-byte word if padded, or second word).
// Lower-cased so isKnownPool's V3 Swap-side lookup matches the emitter regardless of RPC casing.
pool := strings.ToLower(topicAddr("0x" + strings.TrimPrefix(l.Data, "0x")))
idx.store.SeedPool(pool, &storage.SeedPoolData{
Token0: token0,
Token1: token1,
FeeTier: fee,
})
idx.token(ctx, token0)
idx.token(ctx, token1)
}
func (idx *Indexer) handleTransfer(ctx context.Context, l *logEntry, txHash, logIdx string) {
// ERC20 Transfer — record the token if we see it. token reads the contract
// once and caches, so the per-transfer firing of this handler does NOT
// translate into per-block RPC.
if len(l.Topics) >= 3 {
idx.token(ctx, l.Address)
}
}
// decodeInt256 reads a 32-byte word as a signed two's-complement big.Int. V4
// emits amount0/amount1 as int128 (sign-extended into a 32-byte word), so a
// straight unsigned read would misreport negative legs as astronomically large.
func decodeInt256(data string, wordIndex int) *big.Int {
n := decodeUint256(data, wordIndex)
// Words with the high bit set are negative: subtract 2^256.
if n.Bit(255) == 1 {
twoExp256 := new(big.Int).Lsh(big.NewInt(1), 256)
n.Sub(n, twoExp256)
}
return n
}
// asString coerces a JSON-decoded value to its string form, returning "" for nil
// so a missing aggregate carries through a read-modify-write as empty (not "<nil>").
func asString(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprint(v)
}
// asInt64 best-effort coerces a JSON-decoded numeric (float64/json.Number/int)
// to int64 for aggregate accumulation.
func asInt64(v interface{}) int64 {
switch n := v.(type) {
case int64:
return n
case int:
return int64(n)
case float64:
return int64(n)
default:
var out int64
fmt.Sscanf(fmt.Sprint(v), "%d", &out)
return out
}
}
// handleInitializeV4 records a V4 pool (Initialize at the PoolManager). The
// pool id is the indexed bytes32 `id`; token0/token1 are the indexed
// currency0/currency1. Fee is the first data word. Pools created by the 0x9999
// settlement manager are ALSO surfaced as DEX (CLOB) Market entities.
func (idx *Indexer) handleInitializeV4(ctx context.Context, l *logEntry) {
// Scope to the canonical settlement manager (0x9999), exactly like
// handleDEXFill. Any contract can emit a lookalike Initialize; honoring them
// would let anyone seed phantom pools/tokens and inflate the explorer's public
// TVL/volume aggregates. Only the real PoolManager's Initialize is authoritative.
if len(l.Topics) < 4 || !idx.isPoolManager(l.Address) {
return
}
poolID := l.Topics[1] // full 32-byte id
token0 := topicAddr(l.Topics[2])
token1 := topicAddr(l.Topics[3])
fee := decodeUint256(l.Data, 0).Int64()
idx.store.SeedPool(poolID, &storage.SeedPoolData{
Token0: token0,
Token1: token1,
FeeTier: fee,
})
idx.token(ctx, token0)
idx.token(ctx, token1)
// A bound market carries a human BASE/QUOTE symbol (not the raw poolId) when
// BOTH currencies resolve to a clean ERC20 symbol. `assetsBound` marks the
// market as backed by two identified real assets. This is the ONE place the
// market's display identity is computed, so every downstream surface — the
// explorer /dex tab, the exchange-api real-asset gate (dexMarkets.ts requires
// assetsBound + a clean pair symbol), and the FE — reads one complete value.
// A token we cannot cleanly name yields no pair → the market stays unbound and
// the public surface hides it rather than showing a junk hex symbol.
symbol := poolID
assetsBound := false
if s0, s1 := idx.pairSide(ctx, token0), idx.pairSide(ctx, token1); s0 != "" && s1 != "" {
symbol = s0 + "/" + s1
assetsBound = true
}
// We only reach here for the 0x9999 PoolManager (gated above), so every
// Initialize is also a DEX (CLOB) Market. MERGE with any stub a prior fill
// already accumulated (writeFill creates a volume24h/tradeCount stub when a Fill
// arrives before its Initialize). The Initialize supplies the RICH fields (token
// pair, fee tier); it must NOT reset the accumulated trade aggregates to zero.
// Defaults apply only when the field is absent (no prior stub).
mk := map[string]interface{}{
"id": poolID, "symbol": symbol, "assetsBound": assetsBound,
"baseToken": token0, "quoteToken": token1,
"feeTier": fee, "volume24h": "0", "tradeCount": int64(0), "lastPrice": "0",
}
if existing, _ := idx.store.GetByType("Market", poolID); existing != nil {
if em, ok := existing.(map[string]interface{}); ok {
for _, k := range []string{"volume24h", "tradeCount", "lastPrice", "lastUpdate"} {
if v, present := em[k]; present {
mk[k] = v
}
}
}
}
idx.store.SetEntity("Market", poolID, mk)
}
// pairSide returns the clean, uppercase symbol for one side of a market pair, or
// "" if the token has no identifiable ERC20 symbol. A side is clean iff it is a
// real, non-placeholder symbol reduced to [A-Z0-9] of length 2–12 — the exact
// shape the exchange-api gate (dexMarkets.ts PAIR_SYMBOL) admits. A token whose
// symbol() reverts (placeholder = short address) yields "" so its market stays
// unbound and hidden, never surfaced with a junk symbol. The metadata is the same
// read-once-cached value the log handlers already fetched, so this adds no RPC.
func (idx *Indexer) pairSide(ctx context.Context, addr string) string {
meta := idx.token(ctx, addr)
if meta == nil || isPlaceholderSymbol(meta.Symbol, addr) {
return ""
}
var b strings.Builder
for _, r := range strings.ToUpper(meta.Symbol) {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
s := b.String()
if len(s) < 2 || len(s) > 12 {
return ""
}
return s
}
// handleSwapV4 records a V4 swap (Swap at the 0x9999 PoolManager) as an AMM swap —
// amount0/amount1 are signed int128. This is the AMM-side view only. The DEX
// (CLOB) Fill is NOT derived here: a settled native fill is recorded from its
// own authoritative DEXFill event (handleDEXFill), so the Fill has exactly one
// source. Scoped to the canonical PoolManager (like handleInitializeV4 /
// handleDEXFill): a lookalike Swap from any other contract would otherwise inject
// phantom swaps and inflate the explorer's public volume aggregate.
func (idx *Indexer) handleSwapV4(l *logEntry, blockNum uint64, txHash, logIdx string) {
if len(l.Topics) < 3 || !idx.isPoolManager(l.Address) {
return
}
id := fmt.Sprintf("%s#%s", txHash, logIdx)
poolID := l.Topics[1]
sender := topicAddr(l.Topics[2])
amount0 := decodeInt256(l.Data, 0)
amount1 := decodeInt256(l.Data, 1)
idx.store.SeedSwap(id, &storage.SeedSwapData{
Timestamp: l.time,
Block: blockNum,
Pool: poolID,
Amount0: amount0.String(),
Amount1: amount1.String(),
AmountUSD: "0",
Sender: sender,
})
}
// handleDEXFill records a native-CLOB settlement (DEXFill at 0x9999). This is
// the authoritative settled-fill signal: poolId + taker indexed, amountOut +
// blockNumber in data. Only logs from the configured PoolManager are honored —
// a spoofed DEXFill from another address is ignored.
func (idx *Indexer) handleDEXFill(l *logEntry, blockNum uint64, txHash, logIdx string) {
if len(l.Topics) < 3 || !idx.isPoolManager(l.Address) {
return
}
id := fmt.Sprintf("%s#%s", txHash, logIdx)
poolID := l.Topics[1]
taker := topicAddr(l.Topics[2])
amountOut := decodeUint256(l.Data, 0)
idx.writeFill(id, poolID, taker, amountOut, int64(blockNum), txHash)
}
// writeFill persists a DEX (CLOB) Fill entity and rolls its volume into the
// market aggregate. Shared by the V4-swap-at-0x9999 derivation and the explicit
// DEXFill handler so there is one fill-shape and one market-rollup path.
func (idx *Indexer) writeFill(id, poolID, taker string, amountOut *big.Int, ts int64, txHash string) {
idx.store.SetEntity("Fill", id, map[string]interface{}{
"id": id, "market": poolID, "taker": taker,
"amountOut": amountOut.String(), "timestamp": ts, "txHash": txHash,
})
// Market rollup — accumulate volume + trade count, keyed by pool id.
var vol = new(big.Int)
var tc int64
if m, _ := idx.store.GetByType("Market", poolID); m != nil {
if mm, ok := m.(map[string]interface{}); ok {
vol.SetString(fmt.Sprint(mm["volume24h"]), 10)
tc = asInt64(mm["tradeCount"])
vol.Add(vol, amountOut)
mm["volume24h"] = vol.String()
mm["tradeCount"] = tc + 1
mm["lastUpdate"] = ts
idx.store.SetEntity("Market", poolID, mm)
return
}
}
// First fill for a market we never saw an Initialize for — create a stub.
idx.store.SetEntity("Market", poolID, map[string]interface{}{
"id": poolID, "symbol": poolID, "volume24h": amountOut.String(),
"tradeCount": int64(1), "lastUpdate": ts,
})
}
// isPoolManager reports whether a log address is the configured 0x9999
// settlement precompile (case-insensitive).
func (idx *Indexer) isPoolManager(addr string) bool {
return strings.EqualFold(addr, idx.poolManager)
}
// isKnownPool reports whether a log address is a V2 pair / V3 pool that the
// canonical factory created — i.e. one we persisted via handlePairCreated /
// handlePoolCreated (both gated to the canonical factory). It is the Swap-side
// half of the AMM trust root: the factory authorizes a pool, and only that pool's
// Swaps are honored. eth_getLogs returns logs in block+logIndex order, so the
// PairCreated/PoolCreated (in an earlier or the same block) is always processed
// before the pool's first Swap. Pairs are seeded lower-cased (topicAddr), so the
// lookup is case-insensitive.
func (idx *Indexer) isKnownPool(addr string) bool {
p, _ := idx.store.GetPool(nil, strings.ToLower(addr))
return p != nil
}
// short renders a source fingerprint for a log line: the four addresses it
// joins are 170 characters, and what a reader needs is only that it changed.
func short(fp string) string {
if fp == "" {
return "(unrecorded)"
}
parts := strings.Split(fp, "|")
for i, p := range parts {
if len(p) > 10 {
parts[i] = p[:10]
}
}
return strings.Join(parts, "|")
}