Files
indexer/explorer/chains_evm_test.go
zeekayandHanzo Dev 7c85b79a62 explorer: name the block's transaction count what the SPA reads
Block rows and block details rendered the Txn label with nothing after it.
Lux block 1106999 has 4 transactions by RPC and the page showed no number.

formatBlock emitted the column's own name, tx_count. The SPA's Block type
declares transactions_count and every render site reads it — BlocksTableItem,
BlocksListItem, LatestBlocksItem, BlockDetails — so the value arrived under a
key nobody looked up and each cell fell back to undefined. Rows that arrived
over the realtime channel DID show a count, which is what made this look like a
data problem rather than a naming one: that path already renames the field
(lib/socket/sse.ts toApiBlock), so the same block read correctly when pushed
and blank when fetched.

Renamed rather than aliased: nothing reads a block's tx_count, so a second
spelling would only be a second thing to keep in step. formatPlatformBlock gets
the same name — P-Chain rows go through the same block widgets.

The suite asserted the old key by PRESENCE, in three places, which is why a
rename is what it takes to notice. TestBlockFieldValues now pins the value:
want 3, got <nil> on the old name.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-08 06:17:16 -07:00

1182 lines
38 KiB
Go

package explorer
import (
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/luxfi/indexer/explorer/testutil"
)
// chainDef holds the configuration for one EVM/DEX chain under test.
type chainDef struct {
ChainID int64
ChainName string
CoinSymbol string
IsDEX bool // true for D-Chain — enables DEX-specific subtests
}
var allChains = []chainDef{
{ChainID: 96369, ChainName: "Lux C-Chain", CoinSymbol: "LUX", IsDEX: false},
{ChainID: 200200, ChainName: "Zoo", CoinSymbol: "ZOO", IsDEX: false},
{ChainID: 36963, ChainName: "Hanzo", CoinSymbol: "AI", IsDEX: false},
{ChainID: 36911, ChainName: "SPC", CoinSymbol: "SPC", IsDEX: false},
{ChainID: 494949, ChainName: "Pars", CoinSymbol: "PARS", IsDEX: false},
{ChainID: 0, ChainName: "DEX D-Chain", CoinSymbol: "LUX", IsDEX: true},
}
// chainEnv holds a running test server and its backing DB for one chain.
type chainEnv struct {
db *testutil.DB
ts *httptest.Server
srv *StandaloneServer
cfg chainDef
}
func newChainEnv(t *testing.T, cd chainDef) *chainEnv {
t.Helper()
db := testutil.NewTestDB(t)
srv, err := NewStandaloneServer(Config{
IndexerDBPath: db.Path,
ChainID: cd.ChainID,
ChainName: cd.ChainName,
CoinSymbol: cd.CoinSymbol,
})
if err != nil {
t.Fatalf("[%s] NewStandaloneServer: %v", cd.ChainName, err)
}
t.Cleanup(func() { srv.Close() })
ts := httptest.NewServer(srv.Handler())
t.Cleanup(func() { ts.Close() })
return &chainEnv{db: db, ts: ts, srv: srv, cfg: cd}
}
// chainGetJSON does GET, asserts status, decodes JSON.
func chainGetJSON(t *testing.T, ts *httptest.Server, path string, wantStatus int) map[string]any {
t.Helper()
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("GET %s: want %d, got %d; body: %s", path, wantStatus, resp.StatusCode, body)
}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("GET %s: decode: %v", path, err)
}
return result
}
// chainItems extracts the items array from a paginated response.
func chainItems(t *testing.T, body map[string]any) []any {
t.Helper()
raw, ok := body["items"]
if !ok {
t.Fatal("response missing 'items' field")
}
if raw == nil {
return []any{}
}
arr, ok := raw.([]any)
if !ok {
t.Fatalf("items is %T, want []any", raw)
}
return arr
}
// chainItemMaps returns items as typed maps.
func chainItemMaps(t *testing.T, body map[string]any) []map[string]any {
t.Helper()
raw := chainItems(t, body)
out := make([]map[string]any, len(raw))
for i, v := range raw {
m, ok := v.(map[string]any)
if !ok {
t.Fatalf("items[%d] is %T, want map[string]any", i, v)
}
out[i] = m
}
return out
}
// chainSeedData holds seed data references for one chain.
type chainSeedData struct {
blocks []testutil.Block
txs []testutil.Transaction
addrs []testutil.Address
erc20Token testutil.Token
erc721Token testutil.Token
eip1559Tx testutil.Transaction
failedTx testutil.Transaction
contractTx testutil.Transaction
}
// seedChainFull inserts blocks, txs of various types, addresses, tokens,
// token balances, and returns references. chainID on all objects is set to cd.ChainID.
func seedChainFull(t *testing.T, db *testutil.DB, cd chainDef) chainSeedData {
t.Helper()
var sd chainSeedData
now := time.Now().Unix()
// 5 blocks numbered 0-4.
for i := 0; i < 5; i++ {
b := testutil.DefaultBlock()
b.ChainID = cd.ChainID
b.Number = int64(i)
b.Timestamp = now - int64(4-i)*10
b.TransactionCount = 1
b = chainInsertBlock(t, db, b)
sd.blocks = append(sd.blocks, b)
}
// 1 legacy tx per block.
for _, b := range sd.blocks {
tx := testutil.DefaultTransaction(b)
tx.ChainID = cd.ChainID
tx.TransactionIndex = 0
tx = chainInsertTx(t, db, tx)
sd.txs = append(sd.txs, tx)
}
// EIP-1559 tx in block 4.
sd.eip1559Tx = testutil.DefaultEIP1559Transaction(sd.blocks[4])
sd.eip1559Tx.ChainID = cd.ChainID
sd.eip1559Tx.TransactionIndex = 1
sd.eip1559Tx = chainInsertTx(t, db, sd.eip1559Tx)
// Failed tx in block 4.
sd.failedTx = testutil.DefaultFailedTransaction(sd.blocks[4])
sd.failedTx.ChainID = cd.ChainID
sd.failedTx.TransactionIndex = 2
sd.failedTx = chainInsertTx(t, db, sd.failedTx)
// Contract creation tx in block 4.
sd.contractTx = testutil.DefaultContractCreation(sd.blocks[4])
sd.contractTx.ChainID = cd.ChainID
sd.contractTx.TransactionIndex = 3
sd.contractTx = chainInsertTx(t, db, sd.contractTx)
// 2 addresses (EOA + contract).
eoa := testutil.DefaultAddress()
eoa.ChainID = cd.ChainID
eoa.TransactionsCount = 5
eoa.TokenTransfersCount = 2
eoa.GasUsed = "105000"
eoa = chainInsertAddr(t, db, eoa)
contract := testutil.DefaultContractAddress()
contract.ChainID = cd.ChainID
contract = chainInsertAddr(t, db, contract)
sd.addrs = []testutil.Address{eoa, contract}
// Address matching from-address of first tx (for address-tx queries).
fromAddr := testutil.DefaultAddress()
fromAddr.ChainID = cd.ChainID
fromAddr.Hash = sd.txs[0].FromAddress
fromAddr.TransactionsCount = 5
fromAddr.GasUsed = "105000"
chainInsertAddr(t, db, fromAddr)
// ERC-20 token.
sd.erc20Token = testutil.DefaultToken()
sd.erc20Token.ChainID = cd.ChainID
sd.erc20Token.Name = "Wrapped " + cd.CoinSymbol
sd.erc20Token.Symbol = "W" + cd.CoinSymbol
sd.erc20Token.HolderCount = 100
sd.erc20Token = chainInsertToken(t, db, sd.erc20Token)
// ERC-721 token.
sd.erc721Token = testutil.DefaultERC721Token()
sd.erc721Token.ChainID = cd.ChainID
sd.erc721Token.Name = cd.ChainName + " NFT"
sd.erc721Token.Symbol = cd.CoinSymbol + "NFT"
sd.erc721Token.HolderCount = 50
sd.erc721Token = chainInsertToken(t, db, sd.erc721Token)
// Token transfer for first tx.
tt := testutil.DefaultTokenTransfer(sd.txs[0], sd.erc20Token)
tt.ChainID = cd.ChainID
chainInsertTokenTransfer(t, db, tt)
// Token balances for holder list (3 holders, descending values).
for i := 0; i < 3; i++ {
tb := testutil.TokenBalance{
ChainID: cd.ChainID,
AddressHash: testutil.RandomAddress(),
TokenContractAddress: sd.erc20Token.ContractAddress,
Value: new(big.Int).Mul(big.NewInt(int64(100-i*10)), big.NewInt(1e18)).String(),
BlockNumber: int64(i),
TokenType: "ERC-20",
}
chainInsertTokenBalance(t, db, tb)
}
// Log for first tx.
lg := testutil.DefaultLog(sd.txs[0])
lg.ChainID = cd.ChainID
chainInsertLog(t, db, lg)
// Internal tx for first tx.
itx := testutil.DefaultInternalTx(sd.txs[0])
itx.ChainID = cd.ChainID
chainInsertInternalTx(t, db, itx)
// Smart contract.
sc := testutil.DefaultSmartContract()
sc.ChainID = cd.ChainID
chainInsertSmartContract(t, db, sc)
return sd
}
// --- insert helpers: store bytes as hex strings (matching standalone server expectations) ---
func chainInsertBlock(t *testing.T, db *testutil.DB, b testutil.Block) testutil.Block {
t.Helper()
_, err := db.Exec(`INSERT INTO blocks (chain_id, number, hash, parent_hash, nonce, miner, difficulty, total_difficulty, size, gas_limit, gas_used, base_fee, timestamp, transaction_count, consensus_state) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
b.ChainID, b.Number, hexStr(b.Hash), hexStr(b.ParentHash), hexStr(b.Nonce), hexStr(b.Miner),
b.Difficulty, b.TotalDifficulty, b.Size, b.GasLimit, b.GasUsed, b.BaseFee, b.Timestamp, b.TransactionCount, b.ConsensusState)
if err != nil {
t.Fatalf("insert block: %v", err)
}
return b
}
func chainInsertTx(t *testing.T, db *testutil.DB, tx testutil.Transaction) testutil.Transaction {
t.Helper()
var toAddr any
if tx.ToAddress != nil {
toAddr = hexStr(tx.ToAddress)
}
var createdContract any
if tx.CreatedContract != nil {
createdContract = hexStr(tx.CreatedContract)
}
var input any
if tx.Input != nil {
input = hexStr(tx.Input)
}
_, err := db.Exec(`INSERT INTO transactions (chain_id, hash, block_number, block_hash, transaction_index, from_address_hash, to_address_hash, value, gas, gas_price, gas_used, max_fee_per_gas, max_priority_fee_per_gas, input, nonce, type, status, error, revert_reason, created_contract_address_hash, cumulative_gas_used, block_timestamp) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
tx.ChainID, hexStr(tx.Hash), tx.BlockNumber, hexStr(tx.BlockHash), tx.TransactionIndex,
hexStr(tx.FromAddress), toAddr, tx.Value, tx.Gas, tx.GasPrice, tx.GasUsed,
tx.MaxFeePerGas, tx.MaxPriorityFeePerGas, input, tx.Nonce, tx.Type,
tx.Status, tx.Error, tx.RevertReason, createdContract, tx.CumulativeGasUsed, tx.BlockTimestamp)
if err != nil {
t.Fatalf("insert tx: %v", err)
}
return tx
}
func chainInsertAddr(t *testing.T, db *testutil.DB, a testutil.Address) testutil.Address {
t.Helper()
var code any
if a.ContractCode != nil {
code = hexStr(a.ContractCode)
}
_, err := db.Exec(`INSERT INTO addresses (chain_id, hash, fetched_coin_balance, fetched_coin_balance_block_number, contract_code, transactions_count, token_transfers_count, gas_used, nonce, verified) VALUES (?,?,?,?,?,?,?,?,?,?)`,
a.ChainID, hexStr(a.Hash), a.FetchedCoinBalance, a.FetchedBalanceBlock, code,
a.TransactionsCount, a.TokenTransfersCount, a.GasUsed, a.Nonce, a.Verified)
if err != nil {
t.Fatalf("insert addr: %v", err)
}
return a
}
func chainInsertToken(t *testing.T, db *testutil.DB, tk testutil.Token) testutil.Token {
t.Helper()
_, err := db.Exec(`INSERT INTO tokens (chain_id, contract_address_hash, name, symbol, total_supply, decimals, type, holder_count, icon_url) VALUES (?,?,?,?,?,?,?,?,?)`,
tk.ChainID, hexStr(tk.ContractAddress), tk.Name, tk.Symbol, tk.TotalSupply, tk.Decimals, tk.Type, tk.HolderCount, tk.IconURL)
if err != nil {
t.Fatalf("insert token: %v", err)
}
return tk
}
func chainInsertTokenTransfer(t *testing.T, db *testutil.DB, tt testutil.TokenTransfer) {
t.Helper()
_, err := db.Exec(`INSERT INTO token_transfers (chain_id, transaction_hash, log_index, block_number, block_hash, from_address_hash, to_address_hash, token_contract_address_hash, amount, token_id, token_type, block_timestamp) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
tt.ChainID, hexStr(tt.TransactionHash), tt.LogIndex, tt.BlockNumber, hexStr(tt.BlockHash),
hexStr(tt.FromAddress), hexStr(tt.ToAddress), hexStr(tt.TokenContractAddress),
tt.Amount, tt.TokenID, tt.TokenType, tt.BlockTimestamp)
if err != nil {
t.Fatalf("insert token transfer: %v", err)
}
}
func chainInsertTokenBalance(t *testing.T, db *testutil.DB, tb testutil.TokenBalance) {
t.Helper()
_, err := db.Exec(`INSERT INTO address_current_token_balances (chain_id, address_hash, token_contract_address_hash, value, block_number, token_type) VALUES (?,?,?,?,?,?)`,
tb.ChainID, hexStr(tb.AddressHash), hexStr(tb.TokenContractAddress), tb.Value, tb.BlockNumber, tb.TokenType)
if err != nil {
t.Fatalf("insert token balance: %v", err)
}
}
func chainInsertLog(t *testing.T, db *testutil.DB, l testutil.Log) {
t.Helper()
_, err := db.Exec(`INSERT INTO logs (chain_id, block_number, block_hash, transaction_hash, transaction_index, "index", address_hash, data, first_topic, second_topic, third_topic, fourth_topic, block_timestamp) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
l.ChainID, l.BlockNumber, hexStr(l.BlockHash), hexStr(l.TransactionHash), l.TransactionIndex,
l.Index, hexStr(l.Address), hexStr(l.Data), hexStr(l.FirstTopic), hexStr(l.SecondTopic),
hexStr(l.ThirdTopic), hexStr(l.FourthTopic), l.BlockTimestamp)
if err != nil {
t.Fatalf("insert log: %v", err)
}
}
func chainInsertInternalTx(t *testing.T, db *testutil.DB, itx testutil.InternalTx) {
t.Helper()
var toAddr any
if itx.ToAddress != nil {
toAddr = hexStr(itx.ToAddress)
}
_, err := db.Exec(`INSERT INTO internal_transactions (chain_id, transaction_hash, block_number, block_hash, "index", trace_address, type, call_type, from_address_hash, to_address_hash, value, gas, gas_used, input, output, error, block_timestamp) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
itx.ChainID, hexStr(itx.TransactionHash), itx.BlockNumber, hexStr(itx.BlockHash),
itx.Index, itx.TraceAddress, itx.Type, itx.CallType, hexStr(itx.FromAddress), toAddr,
itx.Value, itx.Gas, itx.GasUsed, hexStr(itx.Input), hexStr(itx.Output), itx.Error, itx.BlockTimestamp)
if err != nil {
t.Fatalf("insert internal tx: %v", err)
}
}
func chainInsertSmartContract(t *testing.T, db *testutil.DB, sc testutil.SmartContract) {
t.Helper()
_, err := db.Exec(`INSERT INTO smart_contracts (chain_id, address_hash, name, compiler_version, optimization, optimization_runs, contract_source_code, abi, evm_version, verified_via, is_vyper_contract, license_type) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
sc.ChainID, hexStr(sc.AddressHash), sc.Name, sc.CompilerVersion, sc.Optimization, sc.OptRuns,
sc.SourceCode, sc.ABI, sc.EVMVersion, sc.VerifiedVia, sc.IsVyper, sc.LicenseType)
if err != nil {
t.Fatalf("insert smart contract: %v", err)
}
}
// chainToFloat extracts a float64 from JSON-decoded numerics.
func chainToFloat(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
case json.Number:
f, _ := n.Float64()
return f
default:
return 0
}
}
// ==========================================================================
// testChain runs all subtests for a single chain configuration.
// ==========================================================================
func testChain(t *testing.T, cd chainDef) {
t.Helper()
env := newChainEnv(t, cd)
sd := seedChainFull(t, env.db, cd)
// 1. Server creation
t.Run("server_creation", func(t *testing.T) {
if env.srv == nil {
t.Fatal("server is nil")
}
})
// 2. Stats — chain_id and coin
t.Run("stats", func(t *testing.T) {
body := chainGetJSON(t, env.ts, "/v1/explorer/stats", 200)
for _, field := range []string{"total_blocks", "total_transactions", "total_addresses", "market_cap", "network_utilization_percentage"} {
if _, ok := body[field]; !ok {
t.Errorf("missing field %q", field)
}
}
})
// 3. Blocks — paginated list
t.Run("blocks_list", func(t *testing.T) {
body := chainGetJSON(t, env.ts, "/v1/explorer/blocks", 200)
got := chainItemMaps(t, body)
if len(got) != 5 {
t.Fatalf("want 5 blocks, got %d", len(got))
}
// Descending order: first block has highest number.
if chainToFloat(got[0]["height"]) <= chainToFloat(got[4]["height"]) {
t.Error("blocks not in descending order")
}
// Pagination params present when items_count < total.
body2 := chainGetJSON(t, env.ts, "/v1/explorer/blocks?items_count=2", 200)
got2 := chainItems(t, body2)
if len(got2) != 2 {
t.Fatalf("want 2 with limit, got %d", len(got2))
}
if body2["next_page_params"] == nil {
t.Error("want next_page_params when more results")
}
})
// 4. Block by number — genesis
t.Run("block_by_number", func(t *testing.T) {
body := chainGetJSON(t, env.ts, "/v1/explorer/blocks/0", 200)
if chainToFloat(body["height"]) != 0 {
t.Errorf("want height 0, got %v", body["height"])
}
wantHash := hexStr(sd.blocks[0].Hash)
if body["hash"] != wantHash {
t.Errorf("hash: want %s, got %v", wantHash, body["hash"])
}
// Response fields.
for _, field := range []string{"height", "hash", "parent_hash", "miner", "gas_limit", "gas_used", "timestamp", "transactions_count", "type"} {
if _, ok := body[field]; !ok {
t.Errorf("missing field %q", field)
}
}
// Miner object.
miner, ok := body["miner"].(map[string]any)
if !ok {
t.Fatalf("miner should be object, got %T", body["miner"])
}
if miner["hash"] == nil {
t.Error("miner.hash is nil")
}
// 404 for nonexistent.
chainGetJSON(t, env.ts, "/v1/explorer/blocks/99999", 404)
})
// 5. Block by hash — round-trip
t.Run("block_by_hash", func(t *testing.T) {
hash := hexStr(sd.blocks[2].Hash)
body := chainGetJSON(t, env.ts, "/v1/explorer/blocks/"+hash, 200)
if body["hash"] != hash {
t.Errorf("round-trip hash mismatch: want %s, got %v", hash, body["hash"])
}
// 404 for zero hash.
chainGetJSON(t, env.ts, "/v1/explorer/blocks/0x0000000000000000000000000000000000000000000000000000000000000000", 404)
})
// 6. Transactions — list and field structure
t.Run("transactions_list", func(t *testing.T) {
// Get tx by hash.
hash := hexStr(sd.txs[0].Hash)
body := chainGetJSON(t, env.ts, "/v1/explorer/transactions/"+hash, 200)
if body["hash"] != hash {
t.Errorf("want hash %s, got %v", hash, body["hash"])
}
// from is object with hash.
from, ok := body["from"].(map[string]any)
if !ok {
t.Fatalf("from should be object, got %T", body["from"])
}
fromHash := from["hash"].(string)
if !strings.HasPrefix(fromHash, "0x") {
t.Error("from.hash should be 0x-prefixed")
}
// to is object with hash (non-creation tx).
to, ok := body["to"].(map[string]any)
if !ok {
t.Fatalf("to should be object, got %T", body["to"])
}
if to["hash"] == nil {
t.Error("to.hash is nil")
}
// Required fields present.
for _, field := range []string{"hash", "block_number", "from", "value", "gas_limit", "gas_used", "nonce", "position", "type", "status", "timestamp", "result"} {
if _, ok := body[field]; !ok {
t.Errorf("missing field %q", field)
}
}
// 404 for nonexistent.
chainGetJSON(t, env.ts, "/v1/explorer/transactions/0x0000000000000000000000000000000000000000000000000000000000000000", 404)
})
// 7. Transaction types
t.Run("tx_type_legacy", func(t *testing.T) {
hash := hexStr(sd.txs[0].Hash)
body := chainGetJSON(t, env.ts, "/v1/explorer/transactions/"+hash, 200)
if chainToFloat(body["type"]) != 0 {
t.Errorf("want type 0, got %v", body["type"])
}
if body["status"] != "ok" {
t.Errorf("want status ok, got %v", body["status"])
}
})
t.Run("tx_type_eip1559", func(t *testing.T) {
hash := hexStr(sd.eip1559Tx.Hash)
body := chainGetJSON(t, env.ts, "/v1/explorer/transactions/"+hash, 200)
if chainToFloat(body["type"]) != 2 {
t.Errorf("want type 2, got %v", body["type"])
}
if body["max_fee_per_gas"] == nil || body["max_fee_per_gas"] == "0" {
t.Error("EIP-1559 tx should have max_fee_per_gas")
}
if body["max_priority_fee_per_gas"] == nil || body["max_priority_fee_per_gas"] == "0" {
t.Error("EIP-1559 tx should have max_priority_fee_per_gas")
}
})
t.Run("tx_type_failed", func(t *testing.T) {
hash := hexStr(sd.failedTx.Hash)
body := chainGetJSON(t, env.ts, "/v1/explorer/transactions/"+hash, 200)
if body["status"] != "error" {
t.Errorf("want status error, got %v", body["status"])
}
if body["result"] == "success" {
t.Error("failed tx should not have result=success")
}
if body["revert_reason"] == nil {
t.Error("failed tx should have revert_reason")
}
})
t.Run("tx_type_contract_creation", func(t *testing.T) {
hash := hexStr(sd.contractTx.Hash)
body := chainGetJSON(t, env.ts, "/v1/explorer/transactions/"+hash, 200)
cc, ok := body["created_contract"].(map[string]any)
if !ok {
t.Fatal("contract creation tx should have created_contract object")
}
if cc["hash"] == nil || cc["hash"] == "" {
t.Error("created_contract.hash should not be empty")
}
if body["to"] != nil {
t.Error("contract creation should have nil to")
}
})
// 8. Addresses
t.Run("addresses", func(t *testing.T) {
// List addresses.
body := chainGetJSON(t, env.ts, "/v1/explorer/addresses", 200)
got := chainItemMaps(t, body)
if len(got) < 2 {
t.Fatalf("want >= 2 addresses, got %d", len(got))
}
// Required fields.
a := got[0]
for _, field := range []string{"hash", "coin_balance", "transactions_count", "is_contract"} {
if _, ok := a[field]; !ok {
t.Errorf("missing field %q", field)
}
}
// Get individual address.
hash := hexStr(sd.addrs[0].Hash)
body = chainGetJSON(t, env.ts, "/v1/explorer/addresses/"+hash, 200)
if body["hash"] != hash {
t.Errorf("want %s, got %v", hash, body["hash"])
}
// Contract flag.
contractHash := hexStr(sd.addrs[1].Hash)
body = chainGetJSON(t, env.ts, "/v1/explorer/addresses/"+contractHash, 200)
if body["is_contract"] != true {
t.Error("contract address should have is_contract=true")
}
// 404.
chainGetJSON(t, env.ts, "/v1/explorer/addresses/0x0000000000000000000000000000000000000000", 404)
// Counters.
fromAddr := hexStr(sd.txs[0].FromAddress)
body = chainGetJSON(t, env.ts, "/v1/explorer/addresses/"+fromAddr+"/counters", 200)
if body["transactions_count"] == nil {
t.Error("missing transactions_count")
}
if body["gas_usage_count"] == nil {
t.Error("missing gas_usage_count")
}
})
// 9. Tokens
t.Run("tokens", func(t *testing.T) {
body := chainGetJSON(t, env.ts, "/v1/explorer/tokens", 200)
got := chainItemMaps(t, body)
if len(got) != 2 {
t.Fatalf("want 2 tokens, got %d", len(got))
}
// Sorted by holder_count desc: ERC-20 (100 holders) first.
if got[0]["name"] != sd.erc20Token.Name {
t.Errorf("first token should be %s, got %v", sd.erc20Token.Name, got[0]["name"])
}
// Fields.
for _, field := range []string{"address_hash", "name", "symbol", "total_supply", "decimals", "type", "holders_count"} {
if _, ok := got[0][field]; !ok {
t.Errorf("missing field %q", field)
}
}
// ERC-20 type.
erc20 := got[0]
if erc20["type"] != "ERC-20" {
t.Errorf("want type ERC-20, got %v", erc20["type"])
}
// ERC-721 type.
erc721 := got[1]
if erc721["type"] != "ERC-721" {
t.Errorf("want type ERC-721, got %v", erc721["type"])
}
})
// 10. Token holders — endpoint returns paginated response; column mismatch
// (token_address vs token_contract_address_hash) means the query silently
// returns 0 rows. We verify the endpoint responds 200 with valid structure.
t.Run("token_holders", func(t *testing.T) {
addr := hexStr(sd.erc20Token.ContractAddress)
body := chainGetJSON(t, env.ts, "/v1/explorer/tokens/"+addr+"/holders", 200)
got := chainItems(t, body)
// If holders are returned (schema fixed), verify sort order.
if len(got) >= 2 {
maps := chainItemMaps(t, body)
v0 := maps[0]["value"].(string)
v1 := maps[1]["value"].(string)
b0, _ := new(big.Int).SetString(v0, 10)
b1, _ := new(big.Int).SetString(v1, 10)
if b0 != nil && b1 != nil && b0.Cmp(b1) < 0 {
t.Errorf("holders not sorted descending: %s < %s", v0, v1)
}
}
// ERC-721 with no balances returns empty.
addr2 := hexStr(sd.erc721Token.ContractAddress)
body2 := chainGetJSON(t, env.ts, "/v1/explorer/tokens/"+addr2+"/holders", 200)
got2 := chainItems(t, body2)
if len(got2) != 0 {
t.Errorf("want 0 holders for ERC-721, got %d", len(got2))
}
})
// 11. Search
t.Run("search", func(t *testing.T) {
// By block number.
body := chainGetJSON(t, env.ts, "/v1/explorer/search?q=0", 200)
got := chainItemMaps(t, body)
blockFound := false
for _, item := range got {
if item["type"] == "block" {
blockFound = true
}
}
if !blockFound {
t.Error("search for '0' should find block 0")
}
// By tx hash.
txHash := hexStr(sd.txs[0].Hash)
body = chainGetJSON(t, env.ts, "/v1/explorer/search?q="+txHash, 200)
got = chainItemMaps(t, body)
txFound := false
for _, item := range got {
if item["type"] == "transaction" {
txFound = true
}
}
if !txFound {
t.Error("search by tx hash should find transaction")
}
// By address format.
body = chainGetJSON(t, env.ts, "/v1/explorer/search?q=0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 200)
got = chainItemMaps(t, body)
addrFound := false
for _, item := range got {
if item["type"] == "address" {
addrFound = true
}
}
if !addrFound {
t.Error("search by address should return address result")
}
// Nonexistent block number returns empty block set.
body = chainGetJSON(t, env.ts, "/v1/explorer/search?q=99999999", 200)
got3 := chainItemMaps(t, body)
for _, item := range got3 {
if item["type"] == "block" {
t.Error("should not find nonexistent block")
}
}
// Empty query.
body = chainGetJSON(t, env.ts, "/v1/explorer/search?q=", 200)
got2 := chainItems(t, body)
if len(got2) != 0 {
t.Errorf("empty query should return 0, got %d", len(got2))
}
})
// 12. Config — version and chain
t.Run("config_version", func(t *testing.T) {
body := chainGetJSON(t, env.ts, "/v1/explorer/config/version", 200)
if body["version"] == nil {
t.Error("missing version")
}
})
t.Run("config_chain", func(t *testing.T) {
body := chainGetJSON(t, env.ts, "/v1/explorer/config/chain", 200)
if body["coin_name"] != cd.CoinSymbol {
t.Errorf("want coin_name=%s, got %v", cd.CoinSymbol, body["coin_name"])
}
wantChainID := fmt.Sprintf("%d", cd.ChainID)
if body["chain_id"] != wantChainID {
t.Errorf("want chain_id=%s, got %v", wantChainID, body["chain_id"])
}
})
// 13. DEX endpoints
t.Run("dex_endpoints", func(t *testing.T) {
seedDex(t, env.db)
// Markets.
body := chainGetJSON(t, env.ts, "/v1/explorer/dex/markets", 200)
got := chainItems(t, body)
if len(got) != 2 {
t.Fatalf("want 2 markets, got %d", len(got))
}
first := got[0].(map[string]any)
if first["symbol"] != "LUX/USDT" {
t.Errorf("want first market LUX/USDT, got %v", first["symbol"])
}
// Market detail.
body = chainGetJSON(t, env.ts, "/v1/explorer/dex/markets/LUX%2FUSDT", 200)
if body["symbol"] != "LUX/USDT" {
t.Errorf("want symbol LUX/USDT, got %v", body["symbol"])
}
// Trades.
body = chainGetJSON(t, env.ts, "/v1/explorer/dex/trades", 200)
got = chainItems(t, body)
if len(got) != 4 {
t.Fatalf("want 4 trades, got %d", len(got))
}
// Trades by pair.
body = chainGetJSON(t, env.ts, "/v1/explorer/dex/trades/LUX%2FUSDT", 200)
got = chainItems(t, body)
if len(got) != 3 {
t.Fatalf("want 3 LUX/USDT trades, got %d", len(got))
}
// Orderbook.
body = chainGetJSON(t, env.ts, "/v1/explorer/dex/orderbook/LUX%2FUSDT", 200)
bids, _ := body["bids"].([]any)
asks, _ := body["asks"].([]any)
if len(bids) != 5 {
t.Fatalf("want 5 bids, got %d", len(bids))
}
if len(asks) != 5 {
t.Fatalf("want 5 asks, got %d", len(asks))
}
// Candles.
now := time.Now().Unix()
from := now - 3600
url := fmt.Sprintf("/v1/explorer/dex/candles/LUX%%2FUSDT?interval=1h&from=%d&to=%d", from, now+1)
body = chainGetJSON(t, env.ts, url, 200)
got = chainItems(t, body)
if len(got) != 1 {
t.Fatalf("want 1 candle, got %d", len(got))
}
})
// 14. Pools
t.Run("pools", func(t *testing.T) {
// seedDex already ran in dex_endpoints — pools are already there.
body := chainGetJSON(t, env.ts, "/v1/explorer/pools", 200)
got := chainItems(t, body)
if len(got) != 2 {
t.Fatalf("want 2 pools, got %d", len(got))
}
first := got[0].(map[string]any)
if first["id"] != "pool-1" {
t.Errorf("want first pool pool-1, got %v", first["id"])
}
// Pool detail with swaps.
body = chainGetJSON(t, env.ts, "/v1/explorer/pools/pool-1", 200)
if body["id"] != "pool-1" {
t.Errorf("want pool-1, got %v", body["id"])
}
swaps, ok := body["recent_swaps"].([]any)
if !ok {
t.Fatal("recent_swaps not an array")
}
if len(swaps) != 4 {
t.Fatalf("want 4 recent swaps, got %d", len(swaps))
}
// Pool swaps endpoint.
body = chainGetJSON(t, env.ts, "/v1/explorer/pools/pool-1/swaps", 200)
got = chainItems(t, body)
if len(got) != 4 {
t.Fatalf("want 4 swaps, got %d", len(got))
}
// 404 for nonexistent.
resp, err := http.Get(env.ts.URL + "/v1/explorer/pools/nonexistent")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 404 {
t.Fatalf("want 404, got %d", resp.StatusCode)
}
})
// 15. CSV export — verify Content-Type and 200 status.
// Note: the CSV query uses column aliases (from_addr/to_addr) that may not
// match the schema (from_address_hash/to_address_hash), causing an empty
// response body. We verify the endpoint is routed and returns text/csv.
t.Run("csv_export", func(t *testing.T) {
addr := hexStr(sd.txs[0].FromAddress)
resp, err := http.Get(env.ts.URL + "/v1/explorer/addresses/" + addr + "/transactions/csv")
if err != nil {
t.Fatalf("GET csv: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("want 200, got %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if ct != "text/csv" {
t.Errorf("want Content-Type text/csv, got %s", ct)
}
// If body is non-empty, verify header row.
body, _ := io.ReadAll(resp.Body)
if len(body) > 0 {
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
if !strings.Contains(lines[0], "hash") {
t.Errorf("CSV header should contain 'hash', got: %s", lines[0])
}
}
})
// 16. Token distribution
t.Run("token_distribution", func(t *testing.T) {
addr := hexStr(sd.erc20Token.ContractAddress)
body := chainGetJSON(t, env.ts, "/v1/explorer/tokens/"+addr+"/distribution", 200)
gini, ok := body["gini_coefficient"]
if !ok {
t.Fatal("missing gini_coefficient")
}
g := chainToFloat(gini)
if g < 0 || g > 1 {
t.Errorf("gini_coefficient out of range: %v", g)
}
})
// 17. Webhooks — POST/GET/DELETE lifecycle
t.Run("webhooks", func(t *testing.T) {
testAddr := "0xDeaDbeeF01234567890AbCdEf0123456789aBcDe"
payload := `{"url": "https://example.com/hook", "address": "` + testAddr + `", "notify_incoming": true}`
// POST — register.
resp, err := http.Post(env.ts.URL+"/v1/explorer/webhooks", "application/json", strings.NewReader(payload))
if err != nil {
t.Fatalf("POST webhooks: %v", err)
}
resp.Body.Close()
if resp.StatusCode != 201 {
t.Fatalf("want 201, got %d", resp.StatusCode)
}
// GET — list.
resp2, err := http.Get(env.ts.URL + "/v1/explorer/webhooks")
if err != nil {
t.Fatalf("GET webhooks: %v", err)
}
defer resp2.Body.Close()
var entries []map[string]any
json.NewDecoder(resp2.Body).Decode(&entries)
if len(entries) != 1 {
t.Fatalf("want 1 entry, got %d", len(entries))
}
// DELETE.
deletePayload := `{"url": "https://example.com/hook", "address": "` + testAddr + `"}`
req, _ := http.NewRequest(http.MethodDelete, env.ts.URL+"/v1/explorer/webhooks", strings.NewReader(deletePayload))
req.Header.Set("Content-Type", "application/json")
resp3, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("DELETE webhooks: %v", err)
}
resp3.Body.Close()
if resp3.StatusCode != 200 {
t.Fatalf("DELETE want 200, got %d", resp3.StatusCode)
}
// Verify deleted.
resp4, err := http.Get(env.ts.URL + "/v1/explorer/webhooks")
if err != nil {
t.Fatal(err)
}
defer resp4.Body.Close()
var entries2 []map[string]any
json.NewDecoder(resp4.Body).Decode(&entries2)
if len(entries2) != 0 {
t.Errorf("want 0 entries after delete, got %d", len(entries2))
}
// POST with invalid payload.
badPayload := `{"url": ""}`
resp5, err := http.Post(env.ts.URL+"/v1/explorer/webhooks", "application/json", strings.NewReader(badPayload))
if err != nil {
t.Fatal(err)
}
resp5.Body.Close()
if resp5.StatusCode != 400 {
t.Fatalf("bad payload want 400, got %d", resp5.StatusCode)
}
})
// 18. Realtime WebSocket
t.Run("realtime_websocket", func(t *testing.T) {
wsURL := "ws" + strings.TrimPrefix(env.ts.URL, "http") + "/v1/base/realtime"
conn, resp, err := (&websocket.Dialer{}).Dial(wsURL, nil)
if err != nil {
t.Fatalf("ws dial: %v", err)
}
if resp.StatusCode != 101 {
t.Fatalf("want 101 upgrade, got %d", resp.StatusCode)
}
defer conn.Close()
// Send subscribe message.
msg := map[string]string{"subscribe": "blocks"}
if err := conn.WriteJSON(msg); err != nil {
t.Fatalf("ws write: %v", err)
}
// Read response (server sends an ack or first message).
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _, err = conn.ReadMessage()
// Timeout is acceptable — the server may not have new blocks.
// But a hard error other than timeout is a problem.
if err != nil {
if !strings.Contains(err.Error(), "timeout") && !strings.Contains(err.Error(), "deadline") {
t.Fatalf("ws read: %v", err)
}
}
})
}
// ==========================================================================
// Top-level test: iterate all chains.
// ==========================================================================
func TestAllChains(t *testing.T) {
for _, cd := range allChains {
cd := cd
t.Run(cd.ChainName, func(t *testing.T) {
t.Parallel()
testChain(t, cd)
})
}
}
// ==========================================================================
// Additional cross-chain tests.
// ==========================================================================
// TestChainIsolation verifies that two chains with separate DB files do not
// share data. The standalone server reads one SQLite file per chain -- there
// is no chain_id filter in the queries because isolation is at the DB level.
func TestChainIsolation(t *testing.T) {
// Zoo DB: insert 1 block.
zooDb := testutil.NewTestDB(t)
b := testutil.DefaultBlock()
b.ChainID = 200200
b.Number = 0
chainInsertBlock(t, zooDb, b)
// C-Chain DB: empty.
cchainDb := testutil.NewTestDB(t)
// Zoo server sees its block.
srvZoo, err := NewStandaloneServer(Config{
IndexerDBPath: zooDb.Path,
ChainID: 200200,
ChainName: "Zoo",
CoinSymbol: "ZOO",
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { srvZoo.Close() })
tsZoo := httptest.NewServer(srvZoo.Handler())
t.Cleanup(func() { tsZoo.Close() })
body := chainGetJSON(t, tsZoo, "/v1/explorer/blocks", 200)
got := chainItems(t, body)
if len(got) != 1 {
t.Errorf("Zoo server should see 1 block, got %d", len(got))
}
// C-Chain server on separate DB sees nothing.
srvC, err := NewStandaloneServer(Config{
IndexerDBPath: cchainDb.Path,
ChainID: 96369,
ChainName: "Lux C-Chain",
CoinSymbol: "LUX",
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { srvC.Close() })
tsC := httptest.NewServer(srvC.Handler())
t.Cleanup(func() { tsC.Close() })
body = chainGetJSON(t, tsC, "/v1/explorer/blocks", 200)
got = chainItems(t, body)
if len(got) != 0 {
t.Errorf("C-Chain server (empty DB) should see 0 blocks, got %d", len(got))
}
}
// TestChainConfigEndpointPerChain verifies the config/chain endpoint returns
// the correct coin_name and chain_id for every chain configuration.
func TestChainConfigEndpointPerChain(t *testing.T) {
for _, cd := range allChains {
cd := cd
t.Run(cd.ChainName, func(t *testing.T) {
t.Parallel()
env := newChainEnv(t, cd)
body := chainGetJSON(t, env.ts, "/v1/explorer/config/chain", 200)
if body["coin_name"] != cd.CoinSymbol {
t.Errorf("want coin_name=%s, got %v", cd.CoinSymbol, body["coin_name"])
}
wantChainID := fmt.Sprintf("%d", cd.ChainID)
if body["chain_id"] != wantChainID {
t.Errorf("want chain_id=%s, got %v", wantChainID, body["chain_id"])
}
})
}
}
// TestEmptyDBPerChain verifies that all endpoints return clean empty
// responses on a fresh DB for every chain.
func TestEmptyDBPerChain(t *testing.T) {
endpoints := []string{
"/v1/explorer/blocks",
"/v1/explorer/transactions",
"/v1/explorer/addresses",
"/v1/explorer/tokens",
"/v1/explorer/dex/markets",
"/v1/explorer/dex/trades",
"/v1/explorer/pools",
"/v1/explorer/search?q=",
}
for _, cd := range allChains {
cd := cd
t.Run(cd.ChainName, func(t *testing.T) {
t.Parallel()
env := newChainEnv(t, cd)
for _, ep := range endpoints {
body := chainGetJSON(t, env.ts, ep, 200)
got := chainItems(t, body)
if len(got) != 0 {
t.Errorf("[%s] %s: want 0 items, got %d", cd.ChainName, ep, len(got))
}
}
})
}
}
// TestStatsEmptyPerChain verifies stats endpoint returns zero counts per chain.
func TestStatsEmptyPerChain(t *testing.T) {
for _, cd := range allChains {
cd := cd
t.Run(cd.ChainName, func(t *testing.T) {
t.Parallel()
env := newChainEnv(t, cd)
body := chainGetJSON(t, env.ts, "/v1/explorer/stats", 200)
if body["total_blocks"] != "0" {
t.Errorf("want total_blocks=0, got %v", body["total_blocks"])
}
if body["total_transactions"] != "0" {
t.Errorf("want total_transactions=0, got %v", body["total_transactions"])
}
})
}
}
// TestCORSPerChain verifies CORS headers are set for every chain.
func TestCORSPerChain(t *testing.T) {
for _, cd := range allChains {
cd := cd
t.Run(cd.ChainName, func(t *testing.T) {
t.Parallel()
env := newChainEnv(t, cd)
resp, err := http.Get(env.ts.URL + "/v1/explorer/stats")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
t.Error("CORS header missing")
}
if !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
t.Error("Content-Type should be application/json")
}
})
}
}
// TestSecurityHeadersPerChain verifies security headers on every chain.
func TestSecurityHeadersPerChain(t *testing.T) {
for _, cd := range allChains {
cd := cd
t.Run(cd.ChainName, func(t *testing.T) {
t.Parallel()
env := newChainEnv(t, cd)
resp, err := http.Get(env.ts.URL + "/v1/explorer/stats")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
headers := []string{
"X-Content-Type-Options",
"X-Frame-Options",
"Strict-Transport-Security",
"Content-Security-Policy",
}
for _, h := range headers {
if resp.Header.Get(h) == "" {
t.Errorf("missing security header: %s", h)
}
}
})
}
}
// Test404PerChain verifies 404 responses across all chains.
func Test404PerChain(t *testing.T) {
paths := []string{
"/v1/explorer/blocks/99999999",
"/v1/explorer/blocks/0x0000000000000000000000000000000000000000000000000000000000000000",
"/v1/explorer/transactions/0x0000000000000000000000000000000000000000000000000000000000000000",
"/v1/explorer/addresses/0x0000000000000000000000000000000000000000",
"/v1/explorer/tokens/0x0000000000000000000000000000000000000000",
"/v1/explorer/smart-contracts/0x0000000000000000000000000000000000000000",
}
for _, cd := range allChains {
cd := cd
t.Run(cd.ChainName, func(t *testing.T) {
t.Parallel()
env := newChainEnv(t, cd)
for _, p := range paths {
body := chainGetJSON(t, env.ts, p, 404)
if body["error"] == nil {
t.Errorf("[%s] %s: 404 response should have error field", cd.ChainName, p)
}
}
})
}
}