Files
zeekayandHanzo Dev 2177ed3a0f Read transactions by block, so an imported chain keeps its history
A transaction hash resolves through an index the node builds as it accepts
transactions, not as it replays them. A chain restored from an RLP import
therefore serves blocks and receipts while eth_getTransactionByHash and
eth_getTransactionReceipt answer null for all of it.

The ingest path asked by hash, so it lost every transaction it read. Worse,
a null receipt unmarshalled into an empty struct rather than a miss, and the
empty transaction that fell out was written as a row keyed by the empty
hash — which every later miss then merged into. 220 transactions and 379
logs became 3 rows and 2.

Ask by block number instead. A block carries what the sender submitted, and
eth_getBlockReceipts carries what running it produced; together they are the
whole transaction, and neither needs the hash index. It also costs two round
trips per block where the old path cost two per transaction.

One receipt parser now serves both the block-keyed and hash-keyed reads, and
the hash-keyed one reports a miss as a nil transaction.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-05 19:31:05 -07:00

1448 lines
52 KiB
Go

// Copyright (c) 2025 Lux Industries Inc
// SPDX-License-Identifier: MIT
package evm
import (
"context"
"encoding/json"
"fmt"
"log"
"math/big"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/luxfi/indexer/storage"
)
// Config for EVM indexer
type Config struct {
ChainName string
ChainID int64
RPCEndpoint string
HTTPPort int
PollInterval time.Duration
// StartBlock is the lowest block to index when there is no existing
// stored state (i.e. on a fresh DB). Set this when running against a
// long-history chain — most useful for mainnet-fork devnets where the
// fork inherits millions of historical blocks but the operator only
// cares about the post-fork (locally-mined) activity. Once the indexer
// has any rows in evm_blocks, this field is ignored and indexing
// resumes from MAX(number)+1.
StartBlock uint64
}
// reorgDepth is how far a head may legitimately fall back before a lower head
// stops looking like a reorg and starts looking like a relaunched chain. Reorgs
// are shallow; a chain rebuilt from genesis reports a head far under whatever we
// already stored.
const reorgDepth = 128
// regenesisConfirmations is how many consecutive polls must see a head far under
// our cursor before we spend an RPC asking whether the chain is actually a new
// one. An idle node, or one backend of a load-balanced set lagging behind its
// peers, dips under the cursor for a poll or two; a relaunched chain stays under
// it for as long as it takes to rebuild. The streak only decides when to ask —
// the answer comes from the genesis hash.
const regenesisConfirmations = 3
// Indexer is the main EVM chain indexer
type Indexer struct {
config Config
store storage.Store
adapter *Adapter
subscriber *Subscriber
mu sync.RWMutex
lastLogTime time.Time
// lowHead counts consecutive polls whose head sits far under the cursor.
lowHead int
// genesis is the hash of block 0 the last time we looked, which is what
// tells one chain from another. Captured lazily: a chain we cannot reach
// yet is not a chain we may erase.
genesis string
// genesisAt fetches block 0's hash. A field so tests can answer without a
// network.
genesisAt func(context.Context) (string, error)
}
// EVMBlock represents a parsed EVM block
type EVMBlock struct {
Number uint64 `json:"number"`
Hash string `json:"hash"`
ParentHash string `json:"parentHash"`
Nonce string `json:"nonce"`
Miner string `json:"miner"`
Difficulty string `json:"difficulty"`
GasLimit uint64 `json:"gasLimit"`
GasUsed uint64 `json:"gasUsed"`
Timestamp time.Time `json:"timestamp"`
TxCount int `json:"txCount"`
BaseFee string `json:"baseFeePerGas,omitempty"`
Size uint64 `json:"size"`
// Transactions as the block itself carries them: sender, recipient,
// value, calldata. The execution result — status, gas used, created
// contract, logs — arrives separately, from BlockReceipts.
Transactions []Transaction `json:"transactions"`
}
// NewIndexer creates a new EVM indexer with the unified storage
func NewIndexer(cfg Config, store storage.Store) (*Indexer, error) {
if store == nil {
return nil, fmt.Errorf("storage cannot be nil")
}
adapter := New(cfg.RPCEndpoint)
idx := &Indexer{
config: cfg,
store: store,
adapter: adapter,
subscriber: NewSubscriber(),
}
idx.genesisAt = idx.fetchGenesis
return idx, nil
}
// fetchGenesis reads block 0's hash, which every node of a chain can answer
// however far behind its head is. That is what makes it a usable identity: a
// node lagging a million blocks still knows which chain it is on, so comparing
// genesis hashes distinguishes "a new chain" from "a slow node" where comparing
// heights cannot.
func (idx *Indexer) fetchGenesis(ctx context.Context) (string, error) {
b, err := idx.adapter.GetBlockByNumber(ctx, 0)
if err != nil {
return "", err
}
if b == nil || b.Hash == "" {
return "", fmt.Errorf("no genesis block")
}
return b.Hash, nil
}
// Subscriber returns the indexer's internal WebSocket subscriber so an
// embedder (e.g. luxfi/explorer) can install a Subscriber.OnBroadcast
// callback to bridge block events into its own pub/sub fabric.
//
// Returns nil if the indexer was constructed without an internal
// subscriber (currently always non-nil — NewIndexer always allocates
// one — but the contract permits nil so callers must guard).
func (idx *Indexer) Subscriber() *Subscriber {
return idx.subscriber
}
// Init initializes the EVM indexer schema
func (idx *Indexer) Init(ctx context.Context) error {
schema := storage.Schema{
Name: "evm",
Tables: []storage.Table{
{
Name: "evm_blocks",
Columns: []storage.Column{
{Name: "id", Type: storage.TypeText, Primary: true},
{Name: "number", Type: storage.TypeBigInt, Nullable: false},
{Name: "hash", Type: storage.TypeText, Nullable: false},
{Name: "parent_hash", Type: storage.TypeText},
{Name: "nonce", Type: storage.TypeText},
{Name: "miner", Type: storage.TypeText},
{Name: "difficulty", Type: storage.TypeText},
{Name: "total_difficulty", Type: storage.TypeText},
{Name: "gas_limit", Type: storage.TypeBigInt},
{Name: "gas_used", Type: storage.TypeBigInt},
{Name: "timestamp", Type: storage.TypeTimestamp, Nullable: false},
{Name: "tx_count", Type: storage.TypeInt, Default: "0"},
{Name: "base_fee", Type: storage.TypeText},
{Name: "size", Type: storage.TypeBigInt},
{Name: "created_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
},
{
Name: "evm_transactions",
Columns: []storage.Column{
{Name: "hash", Type: storage.TypeText, Primary: true},
{Name: "block_hash", Type: storage.TypeText},
{Name: "block_number", Type: storage.TypeBigInt},
{Name: "tx_index", Type: storage.TypeInt},
{Name: "from_addr", Type: storage.TypeText},
{Name: "to_addr", Type: storage.TypeText},
{Name: "value", Type: storage.TypeText},
{Name: "gas", Type: storage.TypeBigInt},
{Name: "gas_price", Type: storage.TypeText},
{Name: "gas_used", Type: storage.TypeBigInt},
{Name: "nonce", Type: storage.TypeBigInt},
{Name: "input", Type: storage.TypeText},
{Name: "status", Type: storage.TypeInt},
{Name: "contract_addr", Type: storage.TypeText},
{Name: "timestamp", Type: storage.TypeTimestamp},
{Name: "created_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
},
{
Name: "evm_addresses",
Columns: []storage.Column{
{Name: "hash", Type: storage.TypeText, Primary: true},
{Name: "balance", Type: storage.TypeText, Default: "'0'"},
{Name: "tx_count", Type: storage.TypeBigInt, Default: "0"},
{Name: "is_contract", Type: storage.TypeBool, Default: "false"},
{Name: "code", Type: storage.TypeText, Default: "''"},
{Name: "creator", Type: storage.TypeText, Default: "''"},
{Name: "creation_tx", Type: storage.TypeText, Default: "''"},
{Name: "created_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
{Name: "updated_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
},
{
Name: "evm_tokens",
Columns: []storage.Column{
{Name: "address", Type: storage.TypeText, Primary: true},
{Name: "name", Type: storage.TypeText},
{Name: "symbol", Type: storage.TypeText},
{Name: "decimals", Type: storage.TypeInt},
{Name: "total_supply", Type: storage.TypeText},
{Name: "token_type", Type: storage.TypeText},
{Name: "holder_count", Type: storage.TypeBigInt, Default: "0"},
{Name: "tx_count", Type: storage.TypeBigInt, Default: "0"},
{Name: "created_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
{Name: "updated_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
},
{
Name: "evm_token_transfers",
Columns: []storage.Column{
{Name: "id", Type: storage.TypeText, Primary: true},
{Name: "tx_hash", Type: storage.TypeText},
{Name: "log_index", Type: storage.TypeInt},
{Name: "block_number", Type: storage.TypeBigInt},
{Name: "token_address", Type: storage.TypeText},
{Name: "token_type", Type: storage.TypeText},
{Name: "from_addr", Type: storage.TypeText},
{Name: "to_addr", Type: storage.TypeText},
{Name: "value", Type: storage.TypeText},
{Name: "token_id", Type: storage.TypeText},
{Name: "timestamp", Type: storage.TypeTimestamp},
{Name: "created_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
},
{
Name: "evm_logs",
Columns: []storage.Column{
{Name: "id", Type: storage.TypeText, Primary: true},
{Name: "tx_hash", Type: storage.TypeText},
{Name: "log_index", Type: storage.TypeInt},
{Name: "block_number", Type: storage.TypeBigInt},
{Name: "address", Type: storage.TypeText},
{Name: "topic0", Type: storage.TypeText},
{Name: "topic1", Type: storage.TypeText},
{Name: "topic2", Type: storage.TypeText},
{Name: "topic3", Type: storage.TypeText},
{Name: "data", Type: storage.TypeText},
{Name: "timestamp", Type: storage.TypeTimestamp},
{Name: "created_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
},
{
// Who holds what right now. Maintained by move() on every
// decoded transfer. token_id is "" for ERC-20 (one row per
// holder per token) and the token id as a 32-byte hex word
// for ERC-721 and ERC-1155 (owner in `address`).
Name: "evm_token_balances",
Columns: []storage.Column{
// Composite PK (token_address, address, token_id) is
// required so the upsert's ON CONFLICT clause has a
// constraint to match against — without it, every
// balance write was silently lost on duplicate
// (token, holder) inserts.
{Name: "token_address", Type: storage.TypeText, Nullable: false, Primary: true},
{Name: "address", Type: storage.TypeText, Nullable: false, Primary: true},
{Name: "token_id", Type: storage.TypeText, Default: "''", Primary: true},
{Name: "value", Type: storage.TypeText, Default: "'0'"},
{Name: "token_type", Type: storage.TypeText, Default: "''"},
{Name: "updated_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
},
{
// One row per NFT — the item itself, as distinct from who
// holds it. Ownership moves and lives in
// evm_token_balances; the id and the URI the contract gives
// it belong to the item and outlive any holder, including
// after a burn leaves it with none.
Name: "evm_token_instances",
Columns: []storage.Column{
{Name: "token_address", Type: storage.TypeText, Nullable: false, Primary: true},
{Name: "token_id", Type: storage.TypeText, Nullable: false, Primary: true},
{Name: "uri", Type: storage.TypeText, Default: "''"},
// What the chain answered when asked: "ok" or "absent".
// No row means unread. See readURI.
{Name: "uri_state", Type: storage.TypeText, Default: "''"},
{Name: "updated_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
},
},
},
Indexes: []storage.Index{
{Name: "idx_evm_blocks_number", Table: "evm_blocks", Columns: []string{"number"}},
{Name: "idx_evm_blocks_hash", Table: "evm_blocks", Columns: []string{"hash"}},
{Name: "idx_evm_blocks_miner", Table: "evm_blocks", Columns: []string{"miner"}},
{Name: "idx_evm_transactions_block", Table: "evm_transactions", Columns: []string{"block_number"}},
{Name: "idx_evm_transactions_from", Table: "evm_transactions", Columns: []string{"from_addr"}},
{Name: "idx_evm_transactions_to", Table: "evm_transactions", Columns: []string{"to_addr"}},
{Name: "idx_evm_transactions_contract", Table: "evm_transactions", Columns: []string{"contract_addr"}},
{Name: "idx_evm_addresses_contract", Table: "evm_addresses", Columns: []string{"is_contract"}},
{Name: "idx_evm_tokens_type", Table: "evm_tokens", Columns: []string{"token_type"}},
{Name: "idx_evm_token_transfers_token", Table: "evm_token_transfers", Columns: []string{"token_address"}},
{Name: "idx_evm_token_transfers_from", Table: "evm_token_transfers", Columns: []string{"from_addr"}},
{Name: "idx_evm_token_transfers_to", Table: "evm_token_transfers", Columns: []string{"to_addr"}},
{Name: "idx_evm_logs_address", Table: "evm_logs", Columns: []string{"address"}},
{Name: "idx_evm_logs_topic0", Table: "evm_logs", Columns: []string{"topic0"}},
{Name: "idx_evm_token_balances_token", Table: "evm_token_balances", Columns: []string{"token_address"}},
{Name: "idx_evm_token_balances_addr", Table: "evm_token_balances", Columns: []string{"address"}},
{Name: "idx_evm_token_instances_token", Table: "evm_token_instances", Columns: []string{"token_address"}},
{Name: "idx_evm_token_transfers_item", Table: "evm_token_transfers", Columns: []string{"token_address", "token_id"}},
},
}
return idx.store.InitSchema(ctx, schema)
}
// Run starts the EVM indexer
func (idx *Indexer) Run(ctx context.Context) error {
if err := idx.Init(ctx); err != nil {
return fmt.Errorf("init: %w", err)
}
go idx.subscriber.Run(ctx)
log.Printf("[evm] WebSocket streaming at /v1/explorer/blocks/subscribe")
go idx.startHTTP(ctx)
log.Printf("[evm] API on port %d", idx.config.HTTPPort)
go idx.startIndexing(ctx)
log.Printf("[evm] Block indexing started")
ticker := time.NewTicker(idx.config.PollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
_ = idx.updateStats(ctx)
}
}
}
func (idx *Indexer) startIndexing(ctx context.Context) {
log.Printf("[evm] Indexing goroutine started, RPC: %s", idx.adapter.rpcEndpoint)
// Backfill addresses from already-indexed transactions — this repairs
// state from older indexer versions that couldn't write to evm_addresses.
if n, err := idx.backfillAddresses(ctx); err == nil && n > 0 {
log.Printf("[evm] Backfilled %d addresses from existing transactions", n)
}
// Index immediately on startup before entering ticker loop
idx.indexNewBlocks(ctx)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
idx.indexNewBlocks(ctx)
}
}
}
// tables the EVM indexer owns. Everything it has ever learned about a chain is
// in these seven, so forgetting a chain means emptying exactly this list.
var evmTables = []string{
"evm_blocks",
"evm_transactions",
"evm_addresses",
"evm_tokens",
"evm_token_transfers",
"evm_logs",
"evm_token_balances",
}
// relaunched reports whether the chain under us has been rebuilt from genesis
// rather than merely reorged or gone quiet.
//
// It asks two questions in order, and the order is the point. First a free one:
// has the head stayed more than reorgDepth under our cursor for several polls
// running? That costs nothing and is wrong often — a lagging backend looks the
// same. So it only decides whether the second question is worth asking. That one
// costs an RPC and is not wrong: has block 0's hash changed? A different genesis
// is a different chain, whatever the heights say. An unchanged genesis proves the
// chain is the one we have been indexing all along, so the streak was a lie and
// we clear it.
//
// With no baseline yet — first poll, or the probe failed — there is nothing to
// compare against, so the answer is no. Erasing a chain requires proof that it
// is gone, and absence of evidence is not that.
func (idx *Indexer) relaunched(ctx context.Context, head, cursor uint64) bool {
if cursor <= idx.config.StartBlock+reorgDepth || head+reorgDepth >= cursor {
idx.lowHead = 0
return false
}
idx.lowHead++
if idx.lowHead < regenesisConfirmations {
return false
}
now, err := idx.genesisAt(ctx)
if err != nil {
log.Printf("[evm] head %d far under cursor %d for %d polls, but genesis unreadable (%v) — keeping the index",
head, cursor, idx.lowHead, err)
return false
}
if idx.genesis == "" {
idx.genesis = now
log.Printf("[evm] head %d far under cursor %d with no genesis on record — noted %s, keeping the index", head, cursor, now)
return false
}
if idx.genesis == now {
idx.lowHead = 0
return false
}
log.Printf("[evm] chain relaunched: genesis %s became %s, head %d under cursor %d — reindexing from the new chain",
idx.genesis, now, head, cursor)
idx.genesis = now
idx.lowHead = 0
return true
}
// forget empties this chain's tables. Each chain is indexed into its own
// database, so this reaches nothing but the chain that was replaced.
func (idx *Indexer) forget(ctx context.Context) error {
for _, t := range evmTables {
if err := idx.store.Exec(ctx, "DELETE FROM "+t); err != nil {
return fmt.Errorf("clear %s: %w", t, err)
}
}
return nil
}
func (idx *Indexer) indexNewBlocks(ctx context.Context) {
// Get current block number from RPC
block, err := idx.adapter.GetLatestBlock(ctx)
if err != nil {
if idx.lastLogTime.Add(30 * time.Second).Before(time.Now()) {
log.Printf("[evm] Failed to get latest block: %v", err)
idx.lastLogTime = time.Now()
}
return
}
// Learn which chain this is while it is still the one we are indexing.
// Recorded on the first poll that can reach it, so that if it is ever
// replaced there is something to compare the new genesis against.
if idx.genesis == "" {
if h, err := idx.genesisAt(ctx); err == nil {
idx.genesis = h
}
}
// Get last indexed block from storage
var lastIndexed uint64
rows, queryErr := idx.store.Query(ctx, "SELECT COALESCE(MAX(number), 0) as max_num FROM evm_blocks")
if queryErr != nil {
log.Printf("[evm] Failed to query last indexed block: %v", queryErr)
return
}
if len(rows) > 0 {
if v, ok := rows[0]["max_num"]; ok {
switch h := v.(type) {
case int64:
lastIndexed = uint64(h)
case float64:
lastIndexed = uint64(h)
}
}
}
// A chain rebuilt from genesis reports a head below everything we hold, so
// MAX+1 would sit past it and the loop below would never run again: the
// index would serve the replaced chain's history for good. Ask before
// resuming, and start over when the answer is yes.
if idx.relaunched(ctx, block.Number, lastIndexed) {
if err := idx.forget(ctx); err != nil {
log.Printf("[evm] could not clear the replaced chain: %v", err)
return
}
lastIndexed = 0
}
// Index new blocks. When there is existing state, resume from MAX+1.
// Otherwise, honor an operator-supplied StartBlock (useful for
// mainnet-fork devnets where genesis-from-0 backfill is wasteful), or
// fall back to 0 for a true fresh-genesis chain.
startBlock := lastIndexed
if lastIndexed > 0 {
startBlock = lastIndexed + 1
} else if idx.config.StartBlock > 0 {
startBlock = idx.config.StartBlock
}
if startBlock <= block.Number && idx.lastLogTime.Add(30*time.Second).Before(time.Now()) {
log.Printf("[evm] Indexing blocks %d to %d (latest: %d)", startBlock, block.Number, block.Number)
idx.lastLogTime = time.Now()
}
for blockNum := startBlock; blockNum <= block.Number; blockNum++ {
if err := idx.indexBlock(ctx, blockNum); err != nil {
log.Printf("[evm] Failed to index block %d: %v", blockNum, err)
return
}
}
}
func (idx *Indexer) indexBlock(ctx context.Context, blockNum uint64) error {
block, err := idx.adapter.GetBlockByNumber(ctx, blockNum)
if err != nil {
return err
}
// Store block using EVM-specific schema with backend-portable SQL
query := idx.upsertBlockSQL()
args := idx.blockArgs(block)
err = idx.store.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("store block: %w", err)
}
// Index transactions: fetch receipts for each tx hash. Track each
// address's per-block tx count and known contract status so the
// downstream upsert can increment counters and resolve is_contract
// (the old code passed isContract:false for every from/to and only
// flagged the contract-creation address — but a SimplePool created
// via `new` inside a script is a regular CALL to the creator's
// factory, not a top-level creation tx, so the indexer never knew
// it was a contract).
addrTxCount := make(map[string]uint64)
addrIsContract := make(map[string]bool)
// Track tokens discovered in this block so we can insert a placeholder
// row in evm_tokens with the metadata best-effort resolved via eth_call
// (name/symbol/decimals).
tokensSeen := make(map[string]string) // address → token_type
// The (collection, id) pairs this block moved. Each one is an item whose
// tokenURI we go on to read — see readURI.
itemsSeen := make(map[item]struct{})
// Execution results for the whole block in one read, indexed by hash.
// A block whose receipts cannot be read still yields its transactions;
// they carry every submitted field, and only the result is missing.
receipts := make(map[string]Receipt)
if rs, err := idx.adapter.BlockReceipts(ctx, blockNum); err == nil {
for _, r := range rs {
receipts[r.TxHash] = r
}
}
for i, tx := range block.Transactions {
// An empty hash is not a transaction. Writing one anyway lands a
// blank row that the hash primary key then merges every later
// failure into, so a whole chain's history collapses to one row.
if tx.Hash == "" {
continue
}
r := receipts[tx.Hash]
tx.GasUsed = r.GasUsed
tx.Status = r.Status
tx.ContractAddress = r.ContractAddress
logs := r.Logs
status := 0
if tx.Status != nil {
status = *tx.Status
}
txQ := idx.upsertTxSQL()
txArgs := []interface{}{
tx.Hash, block.Hash, int64(block.Number), i,
tx.From, tx.To, tx.Value,
int64(tx.Gas), tx.GasPrice, int64(tx.GasUsed),
int64(tx.Nonce), tx.Input, status, tx.ContractAddress,
block.Timestamp, time.Now(),
}
if err := idx.store.Exec(ctx, txQ, txArgs...); err != nil {
continue
}
// Index logs + extract token transfers. Receipts include the full
// log list already, so this is "free" — no separate eth_getLogs.
for _, l := range logs {
logID := fmt.Sprintf("%s-%d", l.TxHash, l.LogIndex)
topic0, topic1, topic2, topic3 := "", "", "", ""
if len(l.Topics) > 0 {
topic0 = l.Topics[0]
}
if len(l.Topics) > 1 {
topic1 = l.Topics[1]
}
if len(l.Topics) > 2 {
topic2 = l.Topics[2]
}
if len(l.Topics) > 3 {
topic3 = l.Topics[3]
}
_ = idx.store.Exec(ctx, idx.upsertLogSQL(),
logID, l.TxHash, int64(l.LogIndex), int64(block.Number),
l.Address, topic0, topic1, topic2, topic3, l.Data,
block.Timestamp, time.Now())
// Every token movement in this log, decoded once by
// decodeTransfers — ERC-20, ERC-721, ERC-1155 single and
// batch. A batch log yields one row per (id, amount) pair,
// so no id is folded away.
for _, t := range decodeTransfers(Log{
TxHash: l.TxHash, LogIndex: l.LogIndex, BlockNumber: l.BlockNumber,
Address: l.Address, Topics: l.Topics, Data: l.Data,
}, block.Timestamp) {
_ = idx.store.Exec(ctx, idx.upsertTokenTransferSQL(),
t.ID, t.TxHash, int64(t.LogIndex), int64(t.BlockNumber),
t.TokenAddress, t.TokenType, t.From, t.To, t.Value, t.TokenID,
block.Timestamp, time.Now())
tokenLo := strings.ToLower(t.TokenAddress)
tokensSeen[tokenLo] = t.TokenType
// Per-holder state. ERC-20 moves an amount between two
// running totals; an ERC-721 id moves whole, so the row
// leaves the sender and arrives at the recipient; an
// ERC-1155 id has an amount per holder, so it moves like
// ERC-20 but keyed by id as well.
switch t.TokenType {
case TypeERC20:
idx.move(ctx, tokenLo, t.From, t.To, "", t.Value, TypeERC20)
case TypeERC721:
idx.move(ctx, tokenLo, t.From, t.To, t.TokenID, "1", TypeERC721)
itemsSeen[item{tokenLo, t.TokenID, TypeERC721}] = struct{}{}
case TypeERC1155:
idx.move(ctx, tokenLo, t.From, t.To, t.TokenID, t.Value, TypeERC1155)
itemsSeen[item{tokenLo, t.TokenID, TypeERC1155}] = struct{}{}
}
}
}
// Each tx touches up to 3 distinct addresses. Dedup per-tx so a
// self-send doesn't double-count.
touched := make(map[string]struct{}, 3)
if tx.From != "" {
touched[tx.From] = struct{}{}
}
if tx.To != "" {
touched[tx.To] = struct{}{}
}
if tx.ContractAddress != "" {
touched[tx.ContractAddress] = struct{}{}
addrIsContract[tx.ContractAddress] = true
}
for a := range touched {
addrTxCount[a]++
}
}
// Resolve token metadata (name/symbol/decimals) via eth_call for any
// token we saw a transfer for and haven't already cached. Best-effort:
// failures emit a row with empty metadata so the explorer at least
// shows the address + transfer count.
for tokenAddr, tType := range tokensSeen {
addrIsContract[tokenAddr] = true
name, symbol, decimals, totalSupply := idx.resolveTokenMeta(ctx, tokenAddr, tType)
_ = idx.store.Exec(ctx, idx.upsertTokenSQL(),
tokenAddr, name, symbol, decimals, totalSupply, tType,
time.Now(), time.Now())
}
// Read tokenURI for every item this block moved. See readURI for why
// the document behind the URI is left to the client.
for it := range itemsSeen {
idx.readURI(ctx, it)
}
// Resolve is_contract for everything we haven't already pinned as
// a contract via tx.ContractAddress. Anvil-local RPCs return code
// for any address in O(1); cost is bounded by unique addresses per
// block (~5 typical). Failures are non-fatal — fall back to false.
for addr := range addrTxCount {
if addrIsContract[addr] {
continue
}
code, err := idx.adapter.GetCode(ctx, addr)
if err == nil && code != "" && code != "0x" {
addrIsContract[addr] = true
}
}
// Upsert discovered addresses with tx_count increment + is_contract
// OR-merge so contract status, once true, stays true. Parameter order
// must match the SQL: (hash, tx_count, is_contract, created_at, updated_at).
now := time.Now()
for addr, dCount := range addrTxCount {
_ = idx.store.Exec(ctx, idx.upsertAddrSQL(),
addr, int64(dCount), addrIsContract[addr], now, now)
}
// Broadcast new block
idx.subscriber.BroadcastBlock(block)
return nil
}
// backfillAddresses upserts every from/to/contract address found in
// evm_transactions into evm_addresses, counting how many distinct txs
// touched each address and verifying contract status via eth_getCode for
// any address not already flagged. Safe to re-run: the upsert is an
// increment of tx_count, but if the caller has already seeded the table
// with per-block deltas, running this again would double-count. Intended
// for first-time bootstrap on an empty addresses table; the
// per-block upsert in Run() is the steady-state path.
func (idx *Indexer) backfillAddresses(ctx context.Context) (int, error) {
addrTxCount := make(map[string]uint64)
addrIsContract := make(map[string]bool)
rows, err := idx.store.Query(ctx, `SELECT from_addr, to_addr, contract_addr FROM evm_transactions`)
if err != nil {
return 0, err
}
for _, r := range rows {
touched := make(map[string]struct{}, 3)
if v, ok := r["from_addr"].(string); ok && v != "" {
touched[v] = struct{}{}
}
if v, ok := r["to_addr"].(string); ok && v != "" {
touched[v] = struct{}{}
}
if v, ok := r["contract_addr"].(string); ok && v != "" {
touched[v] = struct{}{}
addrIsContract[v] = true
}
for a := range touched {
addrTxCount[a]++
}
}
for a := range addrTxCount {
if addrIsContract[a] {
continue
}
code, err := idx.adapter.GetCode(ctx, a)
if err == nil && code != "" && code != "0x" {
addrIsContract[a] = true
}
}
now := time.Now()
q := idx.upsertAddrSQL()
for a, c := range addrTxCount {
_ = idx.store.Exec(ctx, q, a, int64(c), addrIsContract[a], now, now)
}
return len(addrTxCount), nil
}
// upsertAddrSQL returns an INSERT … ON CONFLICT … DO UPDATE that:
// - on first sight: writes hash, is_contract, tx_count delta, timestamps.
// - on subsequent sight: increments tx_count by the delta and OR-merges
// is_contract (once true, stays true).
//
// The legacy form was INSERT OR IGNORE / ON CONFLICT DO NOTHING, which
// meant tx_count was permanently 0 for every address ever seen and
// is_contract could never be retro-corrected if an address was first
// observed as plain `to` and only later resolved as a contract.
//
// Caller passes: (addr, isContract, txCountDelta, createdAt, updatedAt).
func (idx *Indexer) upsertAddrSQL() string {
switch idx.store.Backend() {
case storage.BackendPostgres:
return `INSERT INTO evm_addresses
(hash, balance, tx_count, is_contract, code, creator, creation_tx, created_at, updated_at)
VALUES ($1,'0',$3,$2,'','','',$4,$5)
ON CONFLICT (hash) DO UPDATE SET
tx_count = evm_addresses.tx_count + EXCLUDED.tx_count,
is_contract = evm_addresses.is_contract OR EXCLUDED.is_contract,
updated_at = EXCLUDED.updated_at`
default:
// SQLite: `OR` between integers works as bitwise/logical for the 0/1
// boolean column. MAX() also works and is more obviously a merge.
return `INSERT INTO evm_addresses
(hash, balance, tx_count, is_contract, code, creator, creation_tx, created_at, updated_at)
VALUES (?,'0',?,?,'','','',?,?)
ON CONFLICT (hash) DO UPDATE SET
tx_count = evm_addresses.tx_count + excluded.tx_count,
is_contract = MAX(evm_addresses.is_contract, excluded.is_contract),
updated_at = excluded.updated_at`
}
}
// upsertTxSQL returns the correct upsert SQL for transactions
func (idx *Indexer) upsertTxSQL() string {
switch idx.store.Backend() {
case storage.BackendPostgres:
return `INSERT INTO evm_transactions (hash, block_hash, block_number, tx_index,
from_addr, to_addr, value, gas, gas_price, gas_used, nonce, input, status, contract_addr, timestamp, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (hash) DO NOTHING`
default:
return `INSERT OR IGNORE INTO evm_transactions (hash, block_hash, block_number, tx_index,
from_addr, to_addr, value, gas, gas_price, gas_used, nonce, input, status, contract_addr, timestamp, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
}
}
// upsertBlockSQL returns the correct upsert SQL for the backend
func (idx *Indexer) upsertBlockSQL() string {
switch idx.store.Backend() {
case storage.BackendPostgres:
return `
INSERT INTO evm_blocks (id, number, hash, parent_hash, nonce, miner, difficulty,
total_difficulty, gas_limit, gas_used, timestamp, tx_count, base_fee, size, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (id) DO UPDATE SET
number = EXCLUDED.number,
hash = EXCLUDED.hash,
parent_hash = EXCLUDED.parent_hash,
nonce = EXCLUDED.nonce,
miner = EXCLUDED.miner,
difficulty = EXCLUDED.difficulty,
total_difficulty = EXCLUDED.total_difficulty,
gas_limit = EXCLUDED.gas_limit,
gas_used = EXCLUDED.gas_used,
timestamp = EXCLUDED.timestamp,
tx_count = EXCLUDED.tx_count,
base_fee = EXCLUDED.base_fee,
size = EXCLUDED.size`
default: // SQLite
return `
INSERT OR REPLACE INTO evm_blocks (id, number, hash, parent_hash, nonce, miner, difficulty,
total_difficulty, gas_limit, gas_used, timestamp, tx_count, base_fee, size, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
}
}
// blockArgs returns the block arguments for upsert
func (idx *Indexer) blockArgs(block *EVMBlock) []interface{} {
return []interface{}{
block.Hash, // id
block.Number, // number
block.Hash, // hash
block.ParentHash, // parent_hash
block.Nonce, // nonce
block.Miner, // miner
block.Difficulty, // difficulty
"", // total_difficulty
block.GasLimit, // gas_limit
block.GasUsed, // gas_used
block.Timestamp, // timestamp
len(block.Transactions), // tx_count
block.BaseFee, // base_fee
block.Size, // size
time.Now(), // created_at
}
}
func (idx *Indexer) updateStats(ctx context.Context) error {
var totalBlocks, totalTxs, totalAddresses int64
rows, _ := idx.store.Query(ctx, "SELECT COUNT(*) as cnt FROM evm_blocks")
if len(rows) > 0 {
if v, ok := rows[0]["cnt"]; ok {
totalBlocks = toInt64(v)
}
}
rows, _ = idx.store.Query(ctx, "SELECT COUNT(*) as cnt FROM evm_transactions")
if len(rows) > 0 {
if v, ok := rows[0]["cnt"]; ok {
totalTxs = toInt64(v)
}
}
rows, _ = idx.store.Query(ctx, "SELECT COUNT(*) as cnt FROM evm_addresses")
if len(rows) > 0 {
if v, ok := rows[0]["cnt"]; ok {
totalAddresses = toInt64(v)
}
}
stats := map[string]interface{}{
"total_blocks": totalBlocks,
"total_txs": totalTxs,
"total_addresses": totalAddresses,
"last_updated": time.Now(),
}
return idx.store.UpdateStats(ctx, "evm", stats)
}
func toInt64(v interface{}) int64 {
switch h := v.(type) {
case int64:
return h
case float64:
return int64(h)
case int:
return int64(h)
default:
return 0
}
}
func (idx *Indexer) startHTTP(ctx context.Context) {
r := mux.NewRouter()
api := r.PathPrefix("/v1/explorer").Subrouter()
api.HandleFunc("/stats", idx.handleStats).Methods("GET")
api.HandleFunc("/blocks", idx.handleBlocks).Methods("GET")
api.HandleFunc("/blocks/{id}", idx.handleBlock).Methods("GET")
api.HandleFunc("/transactions", idx.handleTransactions).Methods("GET")
api.HandleFunc("/transactions/{hash}", idx.handleTransaction).Methods("GET")
api.HandleFunc("/addresses/{hash}", idx.handleAddress).Methods("GET")
api.HandleFunc("/blocks/subscribe", idx.subscriber.HandleWebSocket)
api.HandleFunc("/events", idx.handleEvents).Methods("POST") // Real-time events from node
r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok", "chain": idx.config.ChainName, "type": "evm",
})
})
handler := corsMiddleware(r)
server := &http.Server{Addr: fmt.Sprintf(":%d", idx.config.HTTPPort), Handler: handler}
go func() {
<-ctx.Done()
_ = server.Shutdown(context.Background())
}()
_ = server.ListenAndServe()
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Content-Type", "application/json")
if r.Method == "OPTIONS" {
return
}
next.ServeHTTP(w, r)
})
}
func (idx *Indexer) handleStats(w http.ResponseWriter, r *http.Request) {
stats, _ := idx.store.GetStats(r.Context(), "evm")
if stats == nil {
stats = make(map[string]interface{})
}
stats["chain_name"] = idx.config.ChainName
stats["chain_id"] = idx.config.ChainID
_ = json.NewEncoder(w).Encode(stats)
}
func (idx *Indexer) handleBlocks(w http.ResponseWriter, r *http.Request) {
blocks, err := idx.store.GetRecentBlocks(r.Context(), "evm_blocks", 50)
if err != nil {
http.Error(w, "database error", 500)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"items": blocks})
}
func (idx *Indexer) handleBlock(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
block, err := idx.store.GetBlock(r.Context(), "evm_blocks", id)
if err != nil {
http.Error(w, "not found", 404)
return
}
_ = json.NewEncoder(w).Encode(block)
}
func (idx *Indexer) handleTransactions(w http.ResponseWriter, r *http.Request) {
rows, err := idx.store.Query(r.Context(),
"SELECT * FROM evm_transactions ORDER BY block_number DESC, tx_index DESC LIMIT 50")
if err != nil {
http.Error(w, "database error", 500)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{"items": rows})
}
func (idx *Indexer) handleTransaction(w http.ResponseWriter, r *http.Request) {
hash := mux.Vars(r)["hash"]
rows, err := idx.store.Query(r.Context(),
"SELECT * FROM evm_transactions WHERE hash = ?", hash)
if err != nil || len(rows) == 0 {
http.Error(w, "not found", 404)
return
}
_ = json.NewEncoder(w).Encode(rows[0])
}
func (idx *Indexer) handleAddress(w http.ResponseWriter, r *http.Request) {
hash := mux.Vars(r)["hash"]
rows, err := idx.store.Query(r.Context(),
"SELECT * FROM evm_addresses WHERE hash = ?", hash)
if err != nil || len(rows) == 0 {
http.Error(w, "not found", 404)
return
}
_ = json.NewEncoder(w).Encode(rows[0])
}
// NodeEvent represents an event from the node's hookdb
type NodeEvent struct {
ChainID string `json:"chain_id"`
Type string `json:"type"` // "put" or "delete"
Prefix string `json:"prefix"`
Key string `json:"key"`
Value []byte `json:"value,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// handleEvents receives real-time events from the node's hookdb
func (idx *Indexer) handleEvents(w http.ResponseWriter, r *http.Request) {
var events []NodeEvent
if err := json.NewDecoder(r.Body).Decode(&events); err != nil {
http.Error(w, "invalid JSON", 400)
return
}
ctx := r.Context()
processed := 0
for _, event := range events {
if event.Type != "put" {
continue // Only process writes for now
}
// Detect block data by key prefix and process
if idx.isBlockKey(event.Key) {
if err := idx.processBlockEvent(ctx, event); err != nil {
log.Printf("[evm] Failed to process block event: %v", err)
continue
}
processed++
}
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"received": len(events),
"processed": processed,
})
}
// isBlockKey checks if a key represents block data
func (idx *Indexer) isBlockKey(key string) bool {
// Common block key prefixes in geth/coreth
// B = block body, H = header hash, h = header, n = header number, b = block hash -> number
if len(key) == 0 {
return false
}
switch key[0] {
case 'B', 'H', 'h', 'n', 'b':
return true
}
// Also check for "LastBlock" or "LastAccepted" keys
return key == "LastBlock" || key == "LastAccepted"
}
// processBlockEvent handles a block write event from the node
func (idx *Indexer) processBlockEvent(ctx context.Context, event NodeEvent) error {
// Decode event and index the latest block.
block, err := idx.adapter.GetLatestBlock(ctx)
if err != nil {
return fmt.Errorf("get latest block: %w", err)
}
// Index the block
query := idx.upsertBlockSQL()
args := idx.blockArgs(block)
if err := idx.store.Exec(ctx, query, args...); err != nil {
return fmt.Errorf("store block: %w", err)
}
// Broadcast to WebSocket subscribers
idx.subscriber.BroadcastBlock(block)
log.Printf("[evm] Indexed block %d from node event", block.Number)
return nil
}
// Subscriber handles WebSocket for live block streaming.
//
// External pub/sub hook (2026-05-21):
//
// `OnBroadcast` is an optional callback fired on every
// BroadcastBlock / BroadcastTransaction. It lets an enclosing process
// — e.g. luxfi/explorer which embeds this indexer as a library — re-
// publish the same events to its own pub/sub fabric (SSE channels,
// gRPC streams, NATS, etc.) without touching the indexer's own
// WebSocket flow.
//
// The callback is invoked synchronously from the indexing goroutine,
// so implementations must NOT block on slow consumers (use a buffered
// channel + drop-on-full, the way luxfi/explorer's SSE registry does).
//
// Zero-value (nil) is a no-op: existing WebSocket subscribers see no
// change, and the indexer keeps working standalone.
type Subscriber struct {
clients map[*websocket.Conn]bool
broadcast chan interface{}
register chan *websocket.Conn
unregister chan *websocket.Conn
mu sync.RWMutex
upgrader websocket.Upgrader
maxClients int
clientSem chan struct{}
OnBroadcast func(eventType string, data any) // optional, see header
}
func NewSubscriber() *Subscriber {
return &Subscriber{
clients: make(map[*websocket.Conn]bool),
broadcast: make(chan interface{}, 100),
register: make(chan *websocket.Conn, 16),
unregister: make(chan *websocket.Conn, 16),
upgrader: websocket.Upgrader{HandshakeTimeout: 10 * time.Second},
maxClients: 1024,
clientSem: make(chan struct{}, 1024),
}
}
func (s *Subscriber) Run(ctx context.Context) {
heartbeat := time.NewTicker(30 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
return
case c := <-s.register:
s.mu.Lock()
s.clients[c] = true
s.mu.Unlock()
case c := <-s.unregister:
s.mu.Lock()
delete(s.clients, c)
c.Close()
s.mu.Unlock()
case msg := <-s.broadcast:
s.mu.RLock()
for c := range s.clients {
if err := c.WriteJSON(msg); err != nil {
go func(conn *websocket.Conn) { s.unregister <- conn }(c)
}
}
s.mu.RUnlock()
case <-heartbeat.C:
s.broadcast <- map[string]string{"type": "heartbeat"}
}
}
}
func (s *Subscriber) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
select {
case s.clientSem <- struct{}{}:
default:
http.Error(w, `{"error":"too many connections"}`, http.StatusServiceUnavailable)
return
}
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
<-s.clientSem
return
}
conn.SetReadLimit(65536)
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
s.register <- conn
_ = conn.WriteJSON(map[string]interface{}{"type": "connected"})
go func() {
defer func() {
s.unregister <- conn
<-s.clientSem
}()
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
}
}()
}
func (s *Subscriber) BroadcastBlock(block *EVMBlock) {
s.broadcast <- map[string]interface{}{"type": "block", "data": block}
if s.OnBroadcast != nil {
// `"blocks"` (plural) is the channel name the explorer SPA
// subscribes to via SSE/WS. Keeping the wire-shape consistent
// across embedders.
s.OnBroadcast("blocks", block)
}
}
// upsertLogSQL returns the correct upsert SQL for evm_logs.
// Param order: (id, tx_hash, log_index, block_number, address,
//
// topic0, topic1, topic2, topic3, data, timestamp, created_at)
func (idx *Indexer) upsertLogSQL() string {
switch idx.store.Backend() {
case storage.BackendPostgres:
return `INSERT INTO evm_logs
(id, tx_hash, log_index, block_number, address, topic0, topic1, topic2, topic3, data, timestamp, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT (id) DO NOTHING`
default:
return `INSERT OR IGNORE INTO evm_logs
(id, tx_hash, log_index, block_number, address, topic0, topic1, topic2, topic3, data, timestamp, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`
}
}
// upsertTokenTransferSQL returns the correct upsert SQL for evm_token_transfers.
// Param order: (id, tx_hash, log_index, block_number, token_address, token_type,
//
// from_addr, to_addr, value, token_id, timestamp, created_at)
func (idx *Indexer) upsertTokenTransferSQL() string {
switch idx.store.Backend() {
case storage.BackendPostgres:
return `INSERT INTO evm_token_transfers
(id, tx_hash, log_index, block_number, token_address, token_type, from_addr, to_addr, value, token_id, timestamp, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
ON CONFLICT (id) DO NOTHING`
default:
return `INSERT OR IGNORE INTO evm_token_transfers
(id, tx_hash, log_index, block_number, token_address, token_type, from_addr, to_addr, value, token_id, timestamp, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`
}
}
// upsertTokenSQL returns the correct upsert SQL for evm_tokens.
// Param order: (address, name, symbol, decimals, total_supply, token_type,
//
// created_at, updated_at). tx_count + holder_count are
// maintained separately and default to 0 on first insert.
func (idx *Indexer) upsertTokenSQL() string {
switch idx.store.Backend() {
case storage.BackendPostgres:
return `INSERT INTO evm_tokens
(address, name, symbol, decimals, total_supply, token_type, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (address) DO UPDATE SET
name = COALESCE(NULLIF(EXCLUDED.name, ''), evm_tokens.name),
symbol = COALESCE(NULLIF(EXCLUDED.symbol, ''), evm_tokens.symbol),
decimals = COALESCE(NULLIF(EXCLUDED.decimals, 0), evm_tokens.decimals),
total_supply = COALESCE(NULLIF(EXCLUDED.total_supply, ''), evm_tokens.total_supply),
token_type = COALESCE(NULLIF(EXCLUDED.token_type, ''), evm_tokens.token_type),
updated_at = EXCLUDED.updated_at`
default:
return `INSERT INTO evm_tokens
(address, name, symbol, decimals, total_supply, token_type, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT (address) DO UPDATE SET
name = CASE WHEN excluded.name != '' THEN excluded.name ELSE evm_tokens.name END,
symbol = CASE WHEN excluded.symbol != '' THEN excluded.symbol ELSE evm_tokens.symbol END,
decimals = CASE WHEN excluded.decimals != 0 THEN excluded.decimals ELSE evm_tokens.decimals END,
total_supply = CASE WHEN excluded.total_supply != '' THEN excluded.total_supply ELSE evm_tokens.total_supply END,
token_type = CASE WHEN excluded.token_type != '' THEN excluded.token_type ELSE evm_tokens.token_type END,
updated_at = excluded.updated_at`
}
}
// resolveTokenMeta does a best-effort eth_call name/symbol/decimals/totalSupply
// resolution. Empty strings + 0 decimals for the metadata when the contract
// doesn't implement the ERC-20 interface (or the call reverts). For ERC-721 we
// don't fetch totalSupply (the standard's totalSupply is optional + expensive).
func (idx *Indexer) resolveTokenMeta(ctx context.Context, addr, tokenType string) (name, symbol string, decimals uint8, totalSupply string) {
info, err := idx.adapter.GetTokenInfo(ctx, addr)
if err != nil || info == nil {
return "", "", 0, "0"
}
totalSupply = info.TotalSupply
if totalSupply == "" {
totalSupply = "0"
}
return info.Name, info.Symbol, info.Decimals, totalSupply
}
// upsertBalanceSQL returns the correct upsert SQL for evm_token_balances.
//
// Schema: (token_address, address, token_id, value, token_type, updated_at)
// Primary key: (token_address, address, token_id). token_id is "" for
// ERC-20 holdings, the actual hex tokenId for ERC-721.
func (idx *Indexer) upsertBalanceSQL() string {
switch idx.store.Backend() {
case storage.BackendPostgres:
return `INSERT INTO evm_token_balances
(token_address, address, token_id, value, token_type, updated_at)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (token_address, address, token_id) DO UPDATE SET
value = EXCLUDED.value,
token_type = COALESCE(NULLIF(EXCLUDED.token_type,''), evm_token_balances.token_type),
updated_at = EXCLUDED.updated_at`
default:
return `INSERT INTO evm_token_balances
(token_address, address, token_id, value, token_type, updated_at)
VALUES (?,?,?,?,?,?)
ON CONFLICT (token_address, address, token_id) DO UPDATE SET
value = excluded.value,
token_type = CASE WHEN excluded.token_type != '' THEN excluded.token_type ELSE evm_token_balances.token_type END,
updated_at = excluded.updated_at`
}
}
// readBalance loads the current `value` for (token, holder, tokenId) as a
// *big.Int. Returns big.NewInt(0) when no row exists.
func (idx *Indexer) readBalance(ctx context.Context, token, holder, tokenID string) *big.Int {
rows, err := idx.store.Query(ctx,
"SELECT value FROM evm_token_balances WHERE token_address = ? AND address = ? AND token_id = ? LIMIT 1",
token, holder, tokenID,
)
if err != nil || len(rows) == 0 {
return big.NewInt(0)
}
v, _ := rows[0]["value"].(string)
if v == "" {
return big.NewInt(0)
}
n, ok := new(big.Int).SetString(v, 10)
if !ok {
return big.NewInt(0)
}
return n
}
// move applies one token movement to evm_token_balances, the table that
// answers "who holds what right now".
//
// It is one function for all three token kinds because they differ only in
// what the id column holds. An ERC-20 holding is (token, holder) with an
// empty id and a running amount. An ERC-1155 holding is (token, holder, id)
// with a running amount. An ERC-721 holding is the same with the amount
// always 1, so the id simply arrives at the recipient and leaves the sender.
//
// A holding that reaches zero is deleted rather than kept as a "0" row: a row
// that says nothing is held is not a holding, and every reader already has to
// filter it out.
//
// The zero address is skipped on both sides. It is the conventional mint
// source and burn sink, not a holder, and crediting it would put every token
// ever minted in one enormous fictional account.
func (idx *Indexer) move(ctx context.Context, token, from, to, id, amount, kind string) {
v, ok := new(big.Int).SetString(amount, 10)
if !ok || v.Sign() == 0 {
return
}
now := time.Now()
const zero = "0x0000000000000000000000000000000000000000"
write := func(holder string, n *big.Int) {
if n.Sign() <= 0 {
_ = idx.store.Exec(ctx,
"DELETE FROM evm_token_balances WHERE token_address = ? AND address = ? AND token_id = ?",
token, holder, id)
return
}
_ = idx.store.Exec(ctx, idx.upsertBalanceSQL(), token, holder, id, n.String(), kind, now)
}
if from != "" && from != zero {
write(from, new(big.Int).Sub(idx.readBalance(ctx, token, from, id), v))
}
if to != "" && to != zero {
write(to, new(big.Int).Add(idx.readBalance(ctx, token, to, id), v))
}
}
// item is one NFT: a collection address and a token id, which together are
// the only thing that identifies a single collectible.
type item struct {
token string
id string
kind string
}
// readURI records what the contract says the URI of an item is.
//
// The URI is an on-chain read — tokenURI(uint256) for ERC-721, uri(uint256)
// for ERC-1155 — so it costs one eth_call against the node we are already
// reading receipts from, and it cannot stall.
//
// The document at the far end is not fetched here, and that is deliberate.
// It usually lives on IPFS or someone's CDN, where a slow or dead gateway
// would hold the block cursor open behind it; an indexer that waits on
// third-party HTTP stops indexing. It is also not our data and can change
// under us. The client asking for it can time out on its own without the
// chain falling behind, so the URI is what we store and the fetch is the
// caller's.
//
// What is recorded is therefore what the chain answered:
//
// ok the contract returned a URI
// absent the call reverted or returned nothing — this contract does not
// name a URI for this id
//
// and no row at all means we have not asked, which the API reports as
// unread. An item with no picture is never silently the same as one whose
// contract has no URI to give.
//
// The read happens once, when the item is first seen. A contract that
// reveals later by changing tokenURI is not picked up until the item next
// moves; nothing on chain announces a reveal.
func (idx *Indexer) readURI(ctx context.Context, it item) {
if it.token == "" || it.id == "" {
return
}
if rows, err := idx.store.Query(ctx,
"SELECT 1 FROM evm_token_instances WHERE token_address = ? AND token_id = ? LIMIT 1",
it.token, it.id,
); err == nil && len(rows) > 0 {
return
}
// tokenURI(uint256) for ERC-721, uri(uint256) for ERC-1155.
selector := "0xc87b56dd"
if it.kind == TypeERC1155 {
selector = "0x0e89341c"
}
uri, state := "", "absent"
if out, err := idx.adapter.callContract(ctx, it.token, selector+strings.TrimPrefix(it.id, "0x")); err == nil {
if u := decodeString(out); u != "" {
uri, state = u, "ok"
}
}
_ = idx.store.Exec(ctx, idx.upsertInstanceSQL(), it.token, it.id, uri, state, time.Now())
}
// upsertInstanceSQL returns the upsert for evm_token_instances, keyed on the
// item — (token_address, token_id).
func (idx *Indexer) upsertInstanceSQL() string {
switch idx.store.Backend() {
case storage.BackendPostgres:
return `INSERT INTO evm_token_instances (token_address, token_id, uri, uri_state, updated_at)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (token_address, token_id) DO UPDATE SET
uri = EXCLUDED.uri, uri_state = EXCLUDED.uri_state, updated_at = EXCLUDED.updated_at`
default:
return `INSERT INTO evm_token_instances (token_address, token_id, uri, uri_state, updated_at)
VALUES (?,?,?,?,?)
ON CONFLICT (token_address, token_id) DO UPDATE SET
uri = excluded.uri, uri_state = excluded.uri_state, updated_at = excluded.updated_at`
}
}