mirror of
https://github.com/luxfi/indexer.git
synced 2026-09-24 00:25:37 +00:00
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]>
1182 lines
38 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|