mirror of
https://github.com/luxfi/indexer.git
synced 2026-09-24 00:25:37 +00:00
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]>
2481 lines
82 KiB
Go
2481 lines
82 KiB
Go
// Copyright (c) 2025 Lux Industries Inc
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
// Package evm provides a unified EVM indexer for all Lux EVM chains.
|
|
// Indexes blocks, transactions, addresses, tokens, smart contracts, and more.
|
|
// Indexes blocks, transactions, addresses, tokens, smart contracts, and more.
|
|
//
|
|
// Supported chains:
|
|
// - Lux C-Chain (mainnet: 96369, testnet: 96368)
|
|
// - Zoo EVM (mainnet: 200200, testnet: 200201)
|
|
// - Hanzo AI Chain (36963)
|
|
// - Any EVM-compatible chain
|
|
package evm
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/indexer/chain"
|
|
"github.com/luxfi/indexer/evm/transport"
|
|
"github.com/luxfi/indexer/storage"
|
|
)
|
|
|
|
const (
|
|
// DefaultPort for EVM indexer API
|
|
DefaultPort = 4000
|
|
// DefaultDatabase name
|
|
DefaultDatabase = "explorer_evm"
|
|
// ChainID for Lux C-Chain mainnet
|
|
ChainID = 96369
|
|
)
|
|
|
|
// Transaction represents an EVM transaction
|
|
type Transaction struct {
|
|
Hash string `json:"hash"`
|
|
BlockHash string `json:"blockHash"`
|
|
BlockNumber uint64 `json:"blockNumber"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Value string `json:"value"`
|
|
Gas uint64 `json:"gas"`
|
|
GasPrice string `json:"gasPrice"`
|
|
GasUsed uint64 `json:"gasUsed"`
|
|
Nonce uint64 `json:"nonce"`
|
|
Input string `json:"input"`
|
|
TransactionIndex uint64 `json:"transactionIndex"`
|
|
Type uint8 `json:"type"`
|
|
Status *int `json:"status"` // nil = pending, 1 = success, 0 = fail
|
|
ContractAddress string `json:"contractAddress,omitempty"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
// Address represents an account or contract
|
|
type Address struct {
|
|
Hash string `json:"hash"`
|
|
Balance string `json:"balance"`
|
|
TxCount uint64 `json:"txCount"`
|
|
IsContract bool `json:"isContract"`
|
|
ContractCode string `json:"contractCode,omitempty"`
|
|
ContractCreator string `json:"contractCreator,omitempty"`
|
|
ContractTxHash string `json:"contractTxHash,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
// TokenTransfer represents an ERC20/721/1155 transfer
|
|
type TokenTransfer struct {
|
|
ID string `json:"id"`
|
|
TxHash string `json:"txHash"`
|
|
LogIndex uint64 `json:"logIndex"`
|
|
BlockNumber uint64 `json:"blockNumber"`
|
|
TokenAddress string `json:"tokenAddress"`
|
|
TokenType string `json:"tokenType"` // ERC20, ERC721, ERC1155
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Value string `json:"value"` // amount for ERC20, tokenId for NFT
|
|
TokenID string `json:"tokenId"` // for ERC721/1155
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
// Token represents an ERC20/721/1155 token contract
|
|
type Token struct {
|
|
Address string `json:"address"`
|
|
Name string `json:"name"`
|
|
Symbol string `json:"symbol"`
|
|
Decimals uint8 `json:"decimals"`
|
|
TotalSupply string `json:"totalSupply"`
|
|
TokenType string `json:"tokenType"` // ERC20, ERC721, ERC1155
|
|
HolderCount uint64 `json:"holderCount"`
|
|
TxCount uint64 `json:"txCount"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
// Log represents an EVM event log
|
|
type Log struct {
|
|
TxHash string `json:"txHash"`
|
|
LogIndex uint64 `json:"logIndex"`
|
|
BlockNumber uint64 `json:"blockNumber"`
|
|
Address string `json:"address"`
|
|
Topics []string `json:"topics"`
|
|
Data string `json:"data"`
|
|
Removed bool `json:"removed"`
|
|
}
|
|
|
|
// InternalTransaction represents an internal call trace
|
|
type InternalTransaction struct {
|
|
ID string `json:"id"`
|
|
TxHash string `json:"txHash"`
|
|
BlockNumber uint64 `json:"blockNumber"`
|
|
TraceIndex uint64 `json:"traceIndex"`
|
|
TraceAddress []int `json:"traceAddress"` // Position in call tree [0], [0,1], [0,1,0], etc.
|
|
CallType string `json:"callType"` // call, delegatecall, staticcall, callcode, create, create2, selfdestruct
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Value string `json:"value"`
|
|
Gas uint64 `json:"gas"`
|
|
GasUsed uint64 `json:"gasUsed"`
|
|
Input string `json:"input"`
|
|
Output string `json:"output"`
|
|
Error string `json:"error,omitempty"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
// For create/create2
|
|
CreatedContractAddress string `json:"createdContractAddress,omitempty"`
|
|
CreatedContractCode string `json:"createdContractCode,omitempty"`
|
|
Init string `json:"init,omitempty"` // Contract init code for creates
|
|
}
|
|
|
|
// TokenBalance represents a token balance for an address
|
|
type TokenBalance struct {
|
|
TokenAddress string `json:"tokenAddress"`
|
|
HolderAddress string `json:"holderAddress"`
|
|
Balance string `json:"balance"`
|
|
TokenID string `json:"tokenId,omitempty"` // For ERC721/1155
|
|
BlockNumber uint64 `json:"blockNumber"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
// AddressCoinBalance represents native coin balance history
|
|
type AddressCoinBalance struct {
|
|
AddressHash string `json:"addressHash"`
|
|
BlockNumber uint64 `json:"blockNumber"`
|
|
Value string `json:"value"`
|
|
ValueFetchedAt time.Time `json:"valueFetchedAt"`
|
|
}
|
|
|
|
// blockData is the parsed block content
|
|
type blockData struct {
|
|
Transactions []Transaction `json:"transactions,omitempty"`
|
|
Logs []Log `json:"logs,omitempty"`
|
|
TokenTransfers []TokenTransfer `json:"tokenTransfers,omitempty"`
|
|
InternalTransactions []InternalTransaction `json:"internalTransactions,omitempty"`
|
|
}
|
|
|
|
// Well-known event signatures
|
|
var (
|
|
// ERC20 Transfer(address,address,uint256)
|
|
TopicTransferERC20 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
|
// ERC721 Transfer(address,address,uint256)
|
|
TopicTransferERC721 = TopicTransferERC20 // Same signature, different indexed params
|
|
// ERC1155 TransferSingle(address,address,address,uint256,uint256)
|
|
TopicTransferSingle = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
|
|
// ERC1155 TransferBatch(address,address,address,uint256[],uint256[])
|
|
TopicTransferBatch = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb"
|
|
// ERC-404 ERC20Transfer(address,address,uint256)
|
|
TopicERC404ERC20Transfer = "0xe59fdd36d0d223c0c7d996db7ad796571571382a6e90d1a1bcc1b3d98f725407"
|
|
// ERC-404 ERC721Transfer
|
|
TopicERC404ERC721Transfer = "0xe5f815dc84b8cecdfd4a3a44d5ebb970e4a227bada3008d09589bdb082a5551f"
|
|
// WETH Deposit(address indexed dst, uint256 wad)
|
|
TopicWETHDeposit = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c"
|
|
// WETH Withdrawal(address indexed src, uint256 wad)
|
|
TopicWETHWithdrawal = "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65"
|
|
)
|
|
|
|
// TracerType specifies which tracer to use
|
|
type TracerType string
|
|
|
|
const (
|
|
// TracerCallTracer uses geth's built-in callTracer (recommended)
|
|
TracerCallTracer TracerType = "callTracer"
|
|
// TracerJS uses custom JavaScript tracer for internal transaction tracing
|
|
TracerJS TracerType = "js"
|
|
// TracerParity uses trace_replayBlockTransactions (Parity/Nethermind)
|
|
TracerParity TracerType = "parity"
|
|
)
|
|
|
|
// Adapter implements the chain.Adapter interface for C-Chain
|
|
type Adapter struct {
|
|
rpcEndpoint string
|
|
transport transport.Transport
|
|
httpClient *http.Client // kept for legacy callers; unused when transport is set
|
|
tracerType TracerType
|
|
traceTimeout string // e.g. "120s"
|
|
prefix string // table name prefix, e.g. "cchain", "zoo", "hanzo"
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// Tbl returns the prefixed table name, e.g. Tbl("blocks") returns "cchain_blocks"
|
|
func (a *Adapter) Tbl(name string) string {
|
|
return a.prefix + "_" + name
|
|
}
|
|
|
|
// Idx returns the prefixed index name, e.g. Idx("tx_block") returns "idx_cchain_tx_block"
|
|
func (a *Adapter) Idx(name string) string {
|
|
return "idx_" + a.prefix + "_" + name
|
|
}
|
|
|
|
// AdapterOption configures the adapter
|
|
type AdapterOption func(*Adapter)
|
|
|
|
// WithTracerType sets the tracer type
|
|
func WithTracerType(t TracerType) AdapterOption {
|
|
return func(a *Adapter) {
|
|
a.tracerType = t
|
|
}
|
|
}
|
|
|
|
// WithTraceTimeout sets the trace timeout
|
|
func WithTraceTimeout(timeout string) AdapterOption {
|
|
return func(a *Adapter) {
|
|
a.traceTimeout = timeout
|
|
}
|
|
}
|
|
|
|
// WithPrefix sets the table name prefix (e.g. "zoo", "hanzo", "spc", "pars")
|
|
func WithPrefix(prefix string) AdapterOption {
|
|
return func(a *Adapter) {
|
|
a.prefix = prefix
|
|
}
|
|
}
|
|
|
|
// WithTransport sets an explicit transport (ZAP or HTTP).
|
|
// If not set, the constructor auto-detects based on the endpoint URL.
|
|
func WithTransport(t transport.Transport) AdapterOption {
|
|
return func(a *Adapter) {
|
|
a.transport = t
|
|
}
|
|
}
|
|
|
|
// New creates a new EVM chain adapter.
|
|
// Defaults to "cchain" prefix when chainSlug is omitted.
|
|
// Auto-detects ZAP transport for luxd endpoints; falls back to HTTP.
|
|
func New(rpcEndpoint string, opts ...AdapterOption) *Adapter {
|
|
a := &Adapter{
|
|
rpcEndpoint: rpcEndpoint,
|
|
tracerType: TracerCallTracer,
|
|
traceTimeout: "120s",
|
|
prefix: "cchain",
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
for _, opt := range opts {
|
|
opt(a)
|
|
}
|
|
// Auto-detect transport if not explicitly set
|
|
if a.transport == nil {
|
|
a.transport = transport.New(rpcEndpoint)
|
|
}
|
|
return a
|
|
}
|
|
|
|
// ParseBlock parses C-Chain block data
|
|
func (a *Adapter) ParseBlock(data json.RawMessage) (*chain.Block, error) {
|
|
var raw struct {
|
|
Hash string `json:"hash"`
|
|
ParentHash string `json:"parentHash"`
|
|
Number string `json:"number"`
|
|
Timestamp string `json:"timestamp"`
|
|
Transactions json.RawMessage `json:"transactions"`
|
|
GasUsed string `json:"gasUsed"`
|
|
GasLimit string `json:"gasLimit"`
|
|
Miner string `json:"miner"`
|
|
BaseFee string `json:"baseFeePerGas"`
|
|
Size string `json:"size"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return nil, fmt.Errorf("unmarshal block: %w", err)
|
|
}
|
|
|
|
height := hexToUint64(raw.Number)
|
|
ts := hexToUint64(raw.Timestamp)
|
|
|
|
// Count transactions
|
|
var txCount int
|
|
var txHashes []string
|
|
|
|
// Try as array of hashes first
|
|
if err := json.Unmarshal(raw.Transactions, &txHashes); err != nil {
|
|
// Reset txHashes (unmarshal may have partially populated it with empty values)
|
|
txHashes = nil
|
|
// Try as array of objects
|
|
var txs []struct {
|
|
Hash string `json:"hash"`
|
|
}
|
|
if err := json.Unmarshal(raw.Transactions, &txs); err == nil {
|
|
for _, tx := range txs {
|
|
txHashes = append(txHashes, tx.Hash)
|
|
}
|
|
}
|
|
}
|
|
txCount = len(txHashes)
|
|
|
|
block := &chain.Block{
|
|
ID: raw.Hash,
|
|
ParentID: raw.ParentHash,
|
|
Height: height,
|
|
Timestamp: time.Unix(int64(ts), 0),
|
|
Status: chain.StatusAccepted,
|
|
TxCount: txCount,
|
|
TxIDs: txHashes,
|
|
Data: data,
|
|
Metadata: map[string]interface{}{
|
|
"gasUsed": hexToUint64(raw.GasUsed),
|
|
"gasLimit": hexToUint64(raw.GasLimit),
|
|
"miner": raw.Miner,
|
|
"baseFee": raw.BaseFee,
|
|
"size": hexToUint64(raw.Size),
|
|
},
|
|
}
|
|
|
|
return block, nil
|
|
}
|
|
|
|
// GetRecentBlocks fetches recent blocks from C-Chain RPC. Block fetches
|
|
// run in parallel (bounded fan-out) because eth_getBlockByNumber over
|
|
// HTTP is dominated by RTT — N serial calls at p50=50ms = N*50ms wall;
|
|
// the same N calls in parallel finish in ~50ms+queueing overhead.
|
|
func (a *Adapter) GetRecentBlocks(ctx context.Context, limit int) ([]json.RawMessage, error) {
|
|
// Get latest block number
|
|
latestResult, err := a.call(ctx, "eth_blockNumber", []interface{}{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var latestHex string
|
|
if err := json.Unmarshal(latestResult, &latestHex); err != nil {
|
|
return nil, fmt.Errorf("parse block number: %w", err)
|
|
}
|
|
latest := hexToUint64(latestHex)
|
|
|
|
// How many blocks we can actually fetch (clamped to genesis).
|
|
want := limit
|
|
if uint64(want) > latest+1 {
|
|
want = int(latest + 1)
|
|
}
|
|
if want <= 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Fixed-position results so block order matches the legacy
|
|
// serial loop (newest first). nil entries are dropped at the end
|
|
// so a single bad block doesn't poison the batch.
|
|
results := make([]json.RawMessage, want)
|
|
|
|
// Bound the fan-out so a single GetRecentBlocks call with limit=200
|
|
// doesn't open 200 simultaneous HTTP connections to the upstream
|
|
// RPC. 16 is enough to saturate p50-50ms latency * 25 RPS upstreams.
|
|
const maxInFlight = 16
|
|
sem := make(chan struct{}, maxInFlight)
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < want; i++ {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
blockNum := fmt.Sprintf("0x%x", latest-uint64(i))
|
|
res, err := a.call(ctx, "eth_getBlockByNumber", []interface{}{blockNum, true})
|
|
if err != nil {
|
|
return // leave nil; caller-side filtering drops it
|
|
}
|
|
results[i] = res
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
// Compact: drop nil holes (failed fetches) while preserving order.
|
|
blocks := results[:0]
|
|
for _, r := range results {
|
|
if r != nil {
|
|
blocks = append(blocks, r)
|
|
}
|
|
}
|
|
return blocks, nil
|
|
}
|
|
|
|
// GetBlockByID fetches a specific block by hash
|
|
func (a *Adapter) GetBlockByID(ctx context.Context, id string) (json.RawMessage, error) {
|
|
return a.call(ctx, "eth_getBlockByHash", []interface{}{id, true})
|
|
}
|
|
|
|
// GetBlockByHeight fetches a specific block by number
|
|
func (a *Adapter) GetBlockByHeight(ctx context.Context, height uint64) (json.RawMessage, error) {
|
|
blockNum := fmt.Sprintf("0x%x", height)
|
|
return a.call(ctx, "eth_getBlockByNumber", []interface{}{blockNum, true})
|
|
}
|
|
|
|
// GetLatestBlock fetches the latest block and returns a parsed EVMBlock
|
|
func (a *Adapter) GetLatestBlock(ctx context.Context) (*EVMBlock, error) {
|
|
result, err := a.call(ctx, "eth_getBlockByNumber", []interface{}{"latest", true})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.parseEVMBlock(result)
|
|
}
|
|
|
|
// GetBlockByNumber fetches a specific block by number and returns a parsed EVMBlock
|
|
func (a *Adapter) GetBlockByNumber(ctx context.Context, number uint64) (*EVMBlock, error) {
|
|
blockNum := fmt.Sprintf("0x%x", number)
|
|
result, err := a.call(ctx, "eth_getBlockByNumber", []interface{}{blockNum, true})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return a.parseEVMBlock(result)
|
|
}
|
|
|
|
// parseEVMBlock parses raw JSON into an EVMBlock
|
|
func (a *Adapter) parseEVMBlock(data json.RawMessage) (*EVMBlock, error) {
|
|
var raw struct {
|
|
Hash string `json:"hash"`
|
|
ParentHash string `json:"parentHash"`
|
|
Number string `json:"number"`
|
|
Nonce string `json:"nonce"`
|
|
Miner string `json:"miner"`
|
|
Difficulty string `json:"difficulty"`
|
|
GasLimit string `json:"gasLimit"`
|
|
GasUsed string `json:"gasUsed"`
|
|
Timestamp string `json:"timestamp"`
|
|
BaseFee string `json:"baseFeePerGas"`
|
|
Size string `json:"size"`
|
|
Transactions json.RawMessage `json:"transactions"`
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return nil, fmt.Errorf("unmarshal block: %w", err)
|
|
}
|
|
|
|
// A block carries its transactions as full objects when the call asked
|
|
// for them and as bare hashes when it did not. Both shapes land in the
|
|
// same field; the hash-only shape leaves the remaining fields empty.
|
|
var txs []Transaction
|
|
var bodies []txBody
|
|
if err := json.Unmarshal(raw.Transactions, &bodies); err == nil {
|
|
for _, b := range bodies {
|
|
txs = append(txs, b.transaction())
|
|
}
|
|
} else {
|
|
var hashes []string
|
|
if err := json.Unmarshal(raw.Transactions, &hashes); err == nil {
|
|
for _, h := range hashes {
|
|
txs = append(txs, Transaction{Hash: h})
|
|
}
|
|
}
|
|
}
|
|
|
|
return &EVMBlock{
|
|
Number: hexToUint64(raw.Number),
|
|
Hash: raw.Hash,
|
|
ParentHash: raw.ParentHash,
|
|
Nonce: raw.Nonce,
|
|
Miner: raw.Miner,
|
|
Difficulty: raw.Difficulty,
|
|
GasLimit: hexToUint64(raw.GasLimit),
|
|
GasUsed: hexToUint64(raw.GasUsed),
|
|
Timestamp: time.Unix(int64(hexToUint64(raw.Timestamp)), 0),
|
|
TxCount: len(txs),
|
|
BaseFee: raw.BaseFee,
|
|
Size: hexToUint64(raw.Size),
|
|
Transactions: txs,
|
|
}, nil
|
|
}
|
|
|
|
// txBody is a transaction as a block carries it: everything the sender
|
|
// submitted, and nothing about what happened when it ran.
|
|
type txBody struct {
|
|
Hash string `json:"hash"`
|
|
BlockHash string `json:"blockHash"`
|
|
BlockNumber string `json:"blockNumber"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Value string `json:"value"`
|
|
Gas string `json:"gas"`
|
|
GasPrice string `json:"gasPrice"`
|
|
Nonce string `json:"nonce"`
|
|
Input string `json:"input"`
|
|
TransactionIndex string `json:"transactionIndex"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
func (b txBody) transaction() Transaction {
|
|
return Transaction{
|
|
Hash: b.Hash,
|
|
BlockHash: b.BlockHash,
|
|
BlockNumber: hexToUint64(b.BlockNumber),
|
|
From: strings.ToLower(b.From),
|
|
To: strings.ToLower(b.To),
|
|
Value: b.Value,
|
|
Gas: hexToUint64(b.Gas),
|
|
GasPrice: b.GasPrice,
|
|
Nonce: hexToUint64(b.Nonce),
|
|
Input: b.Input,
|
|
TransactionIndex: hexToUint64(b.TransactionIndex),
|
|
Type: uint8(hexToUint64(b.Type)),
|
|
}
|
|
}
|
|
|
|
// Receipt is what running a transaction produced: the fields the block's
|
|
// own copy cannot carry, plus the logs it emitted.
|
|
type Receipt struct {
|
|
TxHash string
|
|
TxIndex uint64
|
|
BlockHash string
|
|
BlockNumber uint64
|
|
From string
|
|
To string
|
|
Status *int
|
|
GasUsed uint64
|
|
ContractAddress string
|
|
Logs []Log
|
|
}
|
|
|
|
// receiptJSON is the wire shape of a receipt, shared by the hash-keyed and
|
|
// block-keyed reads so both understand a receipt the same way.
|
|
type receiptJSON struct {
|
|
TransactionHash string `json:"transactionHash"`
|
|
BlockHash string `json:"blockHash"`
|
|
BlockNumber string `json:"blockNumber"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
GasUsed string `json:"gasUsed"`
|
|
Status string `json:"status"`
|
|
ContractAddress string `json:"contractAddress"`
|
|
TransactionIndex string `json:"transactionIndex"`
|
|
Logs []struct {
|
|
Address string `json:"address"`
|
|
Topics []string `json:"topics"`
|
|
Data string `json:"data"`
|
|
LogIndex string `json:"logIndex"`
|
|
Removed bool `json:"removed"`
|
|
} `json:"logs"`
|
|
}
|
|
|
|
func (r receiptJSON) receipt() Receipt {
|
|
out := Receipt{
|
|
TxHash: r.TransactionHash,
|
|
TxIndex: hexToUint64(r.TransactionIndex),
|
|
BlockHash: r.BlockHash,
|
|
BlockNumber: hexToUint64(r.BlockNumber),
|
|
From: strings.ToLower(r.From),
|
|
To: strings.ToLower(r.To),
|
|
GasUsed: hexToUint64(r.GasUsed),
|
|
ContractAddress: strings.ToLower(r.ContractAddress),
|
|
}
|
|
switch r.Status {
|
|
case "0x1":
|
|
v := 1
|
|
out.Status = &v
|
|
case "0x0":
|
|
v := 0
|
|
out.Status = &v
|
|
default:
|
|
// missing or empty = pending (nil)
|
|
}
|
|
for _, l := range r.Logs {
|
|
out.Logs = append(out.Logs, Log{
|
|
TxHash: r.TransactionHash,
|
|
LogIndex: hexToUint64(l.LogIndex),
|
|
BlockNumber: out.BlockNumber,
|
|
Address: strings.ToLower(l.Address),
|
|
Topics: l.Topics,
|
|
Data: l.Data,
|
|
Removed: l.Removed,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// BlockReceipts returns the execution result of every transaction in a
|
|
// block, read by block number.
|
|
//
|
|
// eth_getTransactionReceipt and eth_getTransactionByHash both resolve a
|
|
// transaction through the hash-to-block index. A node restored from an RLP
|
|
// import serves blocks and receipts but builds no such index, so those two
|
|
// answer null for history that is plainly present. Reading by block number
|
|
// needs no index, and costs one round trip per block instead of two per
|
|
// transaction.
|
|
func (a *Adapter) BlockReceipts(ctx context.Context, number uint64) ([]Receipt, error) {
|
|
result, err := a.call(ctx, "eth_getBlockReceipts", []interface{}{fmt.Sprintf("0x%x", number)})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var raw []receiptJSON
|
|
if err := json.Unmarshal(result, &raw); err != nil {
|
|
return nil, fmt.Errorf("parse block receipts: %w", err)
|
|
}
|
|
out := make([]Receipt, 0, len(raw))
|
|
for _, r := range raw {
|
|
out = append(out, r.receipt())
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetTransactionReceipt fetches one transaction by hash, combining its
|
|
// receipt with the submitted fields the receipt omits. A node that cannot
|
|
// resolve the hash answers null; that is a miss, reported as a nil
|
|
// transaction, never an empty one.
|
|
func (a *Adapter) GetTransactionReceipt(ctx context.Context, txHash string) (*Transaction, []Log, error) {
|
|
result, err := a.call(ctx, "eth_getTransactionReceipt", []interface{}{txHash})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var raw receiptJSON
|
|
if err := json.Unmarshal(result, &raw); err != nil {
|
|
return nil, nil, fmt.Errorf("parse receipt: %w", err)
|
|
}
|
|
if raw.TransactionHash == "" {
|
|
return nil, nil, nil
|
|
}
|
|
r := raw.receipt()
|
|
|
|
tx := &Transaction{
|
|
Hash: r.TxHash,
|
|
BlockHash: r.BlockHash,
|
|
BlockNumber: r.BlockNumber,
|
|
From: r.From,
|
|
To: r.To,
|
|
GasUsed: r.GasUsed,
|
|
TransactionIndex: r.TxIndex,
|
|
ContractAddress: r.ContractAddress,
|
|
Status: r.Status,
|
|
}
|
|
|
|
// Second hop: the receipt has the post-execution fields (status,
|
|
// gasUsed, logs) but not the calldata, nor the original-submission
|
|
// fields (value, gasPrice, gas limit, nonce, type).
|
|
if byHash, err := a.call(ctx, "eth_getTransactionByHash", []interface{}{txHash}); err == nil {
|
|
var b txBody
|
|
if json.Unmarshal(byHash, &b) == nil {
|
|
tx.Input = b.Input
|
|
tx.Value = b.Value
|
|
tx.GasPrice = b.GasPrice
|
|
tx.Gas = hexToUint64(b.Gas)
|
|
tx.Nonce = hexToUint64(b.Nonce)
|
|
tx.Type = uint8(hexToUint64(b.Type))
|
|
}
|
|
}
|
|
|
|
return tx, r.Logs, nil
|
|
}
|
|
|
|
// TraceTransaction traces internal calls for a transaction using debug_traceTransaction
|
|
func (a *Adapter) TraceTransaction(ctx context.Context, txHash string, blockNumber uint64, timestamp time.Time) ([]InternalTransaction, error) {
|
|
a.mu.RLock()
|
|
tracerType := a.tracerType
|
|
timeout := a.traceTimeout
|
|
a.mu.RUnlock()
|
|
|
|
switch tracerType {
|
|
case TracerParity:
|
|
return a.traceTransactionParity(ctx, txHash, blockNumber, timestamp)
|
|
case TracerCallTracer:
|
|
return a.traceTransactionCallTracer(ctx, txHash, blockNumber, timestamp, timeout)
|
|
case TracerJS:
|
|
return a.traceTransactionJS(ctx, txHash, blockNumber, timestamp, timeout)
|
|
default:
|
|
return a.traceTransactionCallTracer(ctx, txHash, blockNumber, timestamp, timeout)
|
|
}
|
|
}
|
|
|
|
// traceTransactionCallTracer uses geth's built-in callTracer
|
|
func (a *Adapter) traceTransactionCallTracer(ctx context.Context, txHash string, blockNumber uint64, timestamp time.Time, timeout string) ([]InternalTransaction, error) {
|
|
params := map[string]interface{}{
|
|
"tracer": "callTracer",
|
|
"tracerConfig": map[string]interface{}{
|
|
"withLog": false,
|
|
},
|
|
}
|
|
if timeout != "" {
|
|
params["timeout"] = timeout
|
|
}
|
|
|
|
result, err := a.call(ctx, "debug_traceTransaction", []interface{}{txHash, params})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("debug_traceTransaction: %w", err)
|
|
}
|
|
|
|
var callFrame CallFrame
|
|
if err := json.Unmarshal(result, &callFrame); err != nil {
|
|
return nil, fmt.Errorf("parse trace result: %w", err)
|
|
}
|
|
|
|
// Convert call tree to flat list with trace addresses
|
|
var traces []InternalTransaction
|
|
traces = flattenCallFrame(&callFrame, txHash, blockNumber, timestamp, []int{}, &traces, 0)
|
|
|
|
return traces, nil
|
|
}
|
|
|
|
// CallFrame represents a call in the callTracer output
|
|
type CallFrame struct {
|
|
Type string `json:"type"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Value string `json:"value,omitempty"`
|
|
Gas string `json:"gas"`
|
|
GasUsed string `json:"gasUsed"`
|
|
Input string `json:"input"`
|
|
Output string `json:"output,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Calls []*CallFrame `json:"calls,omitempty"`
|
|
}
|
|
|
|
// flattenCallFrame converts a call tree to a flat list of internal transactions
|
|
func flattenCallFrame(frame *CallFrame, txHash string, blockNumber uint64, timestamp time.Time, traceAddr []int, traces *[]InternalTransaction, index int) []InternalTransaction {
|
|
callType := normalizeCallType(frame.Type)
|
|
|
|
itx := InternalTransaction{
|
|
ID: fmt.Sprintf("%s-%d", txHash, index),
|
|
TxHash: txHash,
|
|
BlockNumber: blockNumber,
|
|
TraceIndex: uint64(index),
|
|
TraceAddress: make([]int, len(traceAddr)),
|
|
CallType: callType,
|
|
From: strings.ToLower(frame.From),
|
|
To: strings.ToLower(frame.To),
|
|
Value: normalizeValue(frame.Value),
|
|
Gas: hexToUint64(frame.Gas),
|
|
GasUsed: hexToUint64(frame.GasUsed),
|
|
Input: frame.Input,
|
|
Output: frame.Output,
|
|
Error: frame.Error,
|
|
Timestamp: timestamp,
|
|
}
|
|
copy(itx.TraceAddress, traceAddr)
|
|
|
|
// Handle create opcodes -- only set contract code if creation succeeded.
|
|
// A failed create (frame.Error set) produces no deployed code.
|
|
if callType == "create" || callType == "create2" {
|
|
itx.Init = frame.Input
|
|
itx.Output = ""
|
|
if frame.Error == "" {
|
|
itx.CreatedContractAddress = itx.To
|
|
itx.CreatedContractCode = frame.Output
|
|
}
|
|
}
|
|
|
|
*traces = append(*traces, itx)
|
|
index++
|
|
|
|
// Recursively process child calls
|
|
for i, child := range frame.Calls {
|
|
childAddr := append(append([]int{}, traceAddr...), i)
|
|
flattenCallFrame(child, txHash, blockNumber, timestamp, childAddr, traces, index)
|
|
index = len(*traces)
|
|
}
|
|
|
|
return *traces
|
|
}
|
|
|
|
// normalizeCallType normalizes call type from geth to standard explorer format
|
|
func normalizeCallType(t string) string {
|
|
switch strings.ToLower(t) {
|
|
case "call":
|
|
return "call"
|
|
case "delegatecall":
|
|
return "delegatecall"
|
|
case "staticcall":
|
|
return "staticcall"
|
|
case "callcode":
|
|
return "callcode"
|
|
case "create":
|
|
return "create"
|
|
case "create2":
|
|
return "create2"
|
|
case "selfdestruct", "suicide":
|
|
return "selfdestruct"
|
|
default:
|
|
return strings.ToLower(t)
|
|
}
|
|
}
|
|
|
|
// normalizeValue ensures value is a valid hex string
|
|
func normalizeValue(v string) string {
|
|
if v == "" {
|
|
return "0x0"
|
|
}
|
|
return v
|
|
}
|
|
|
|
// traceTransactionJS uses custom JavaScript tracer for internal transaction tracing
|
|
func (a *Adapter) traceTransactionJS(ctx context.Context, txHash string, blockNumber uint64, timestamp time.Time, timeout string) ([]InternalTransaction, error) {
|
|
// Standard JavaScript tracer for internal call tracing
|
|
tracer := `{
|
|
callStack: [{}],
|
|
descended: false,
|
|
step: function(log, db) {
|
|
var error = log.getError();
|
|
if (error !== undefined) {
|
|
this.fault(log, db);
|
|
} else {
|
|
this.success(log, db);
|
|
}
|
|
},
|
|
fault: function(log, db) {
|
|
if (this.topCall().error === undefined) {
|
|
this.putError(log);
|
|
}
|
|
},
|
|
putError: function(log) {
|
|
if (this.callStack.length > 1) {
|
|
var call = this.callStack.pop();
|
|
call.error = log.getError();
|
|
if (call.gasBigInt !== undefined) {
|
|
call.gasUsedBigInt = call.gasBigInt;
|
|
}
|
|
delete call.outputOffset;
|
|
delete call.outputLength;
|
|
this.pushChildCall(call);
|
|
} else {
|
|
var call = this.bottomCall();
|
|
call.error = log.getError();
|
|
}
|
|
},
|
|
topCall: function() { return this.callStack[this.callStack.length - 1]; },
|
|
bottomCall: function() { return this.callStack[0]; },
|
|
pushChildCall: function(childCall) {
|
|
var topCall = this.topCall();
|
|
if (topCall.calls === undefined) { topCall.calls = []; }
|
|
topCall.calls.push(childCall);
|
|
},
|
|
success: function(log, db) {
|
|
var op = log.op.toString();
|
|
if (this.descended) {
|
|
this.topCall().gasBigInt = log.getGas();
|
|
this.descended = false;
|
|
}
|
|
this.beforeOp(log, db);
|
|
switch (op) {
|
|
case 'CREATE': this.createOp(log); break;
|
|
case 'CREATE2': this.create2Op(log); break;
|
|
case 'SELFDESTRUCT': this.selfDestructOp(log, db); break;
|
|
case 'CALL':
|
|
case 'CALLCODE':
|
|
case 'DELEGATECALL':
|
|
case 'STATICCALL': this.callOp(log, op); break;
|
|
case 'REVERT': this.topCall().error = 'execution reverted'; break;
|
|
}
|
|
},
|
|
beforeOp: function(log, db) {
|
|
var logDepth = log.getDepth();
|
|
var callStackDepth = this.callStack.length;
|
|
if (logDepth < callStackDepth) {
|
|
var call = this.callStack.pop();
|
|
var ret = log.stack.peek(0);
|
|
if (!ret.equals(0)) {
|
|
if (call.type === 'create' || call.type === 'create2') {
|
|
call.createdContractAddressHash = toHex(toAddress(ret.toString(16)));
|
|
call.createdContractCode = toHex(db.getCode(toAddress(ret.toString(16))));
|
|
} else {
|
|
call.output = toHex(log.memory.slice(call.outputOffset, call.outputOffset + call.outputLength));
|
|
}
|
|
} else if (call.error === undefined) {
|
|
call.error = 'internal failure';
|
|
}
|
|
delete call.outputOffset;
|
|
delete call.outputLength;
|
|
this.pushChildCall(call);
|
|
} else {
|
|
var topCall = this.topCall();
|
|
if (topCall.gasBigInt === undefined) { topCall.gasBigInt = log.getGas(); }
|
|
topCall.gasUsedBigInt = topCall.gasBigInt - log.getGas() - log.getCost();
|
|
}
|
|
},
|
|
createOp: function(log) {
|
|
var inputOffset = log.stack.peek(1).valueOf();
|
|
var inputLength = log.stack.peek(2).valueOf();
|
|
var call = {
|
|
type: 'create',
|
|
from: toHex(log.contract.getAddress()),
|
|
init: toHex(log.memory.slice(inputOffset, inputOffset + inputLength)),
|
|
valueBigInt: bigInt(log.stack.peek(0).toString(10))
|
|
};
|
|
this.callStack.push(call);
|
|
this.descended = true;
|
|
},
|
|
create2Op: function(log) {
|
|
var inputOffset = log.stack.peek(1).valueOf();
|
|
var inputLength = log.stack.peek(2).valueOf();
|
|
var call = {
|
|
type: 'create2',
|
|
from: toHex(log.contract.getAddress()),
|
|
init: toHex(log.memory.slice(inputOffset, inputOffset + inputLength)),
|
|
valueBigInt: bigInt(log.stack.peek(0).toString(10))
|
|
};
|
|
this.callStack.push(call);
|
|
this.descended = true;
|
|
},
|
|
selfDestructOp: function(log, db) {
|
|
this.pushChildCall({
|
|
type: 'selfdestruct',
|
|
from: toHex(log.contract.getAddress()),
|
|
to: toHex(toAddress(log.stack.peek(0).toString(16))),
|
|
gasBigInt: log.getGas(),
|
|
gasUsedBigInt: log.getCost(),
|
|
valueBigInt: db.getBalance(log.contract.getAddress())
|
|
});
|
|
},
|
|
callOp: function(log, op) {
|
|
var to = toAddress(log.stack.peek(1).toString(16));
|
|
if (!isPrecompiled(to)) {
|
|
var stackOffset = (op === 'DELEGATECALL' || op === 'STATICCALL' ? 0 : 1);
|
|
var inputOffset = log.stack.peek(2 + stackOffset).valueOf();
|
|
var inputLength = log.stack.peek(3 + stackOffset).valueOf();
|
|
var inputEnd = Math.min(inputOffset + inputLength, log.memory.length());
|
|
var call = {
|
|
type: 'call',
|
|
callType: op.toLowerCase(),
|
|
from: toHex(log.contract.getAddress()),
|
|
to: toHex(to),
|
|
input: (inputLength == 0 ? '0x' : toHex(log.memory.slice(inputOffset, inputEnd))),
|
|
outputOffset: log.stack.peek(4 + stackOffset).valueOf(),
|
|
outputLength: log.stack.peek(5 + stackOffset).valueOf()
|
|
};
|
|
switch (op) {
|
|
case 'CALL':
|
|
case 'CALLCODE': call.valueBigInt = bigInt(log.stack.peek(2)); break;
|
|
case 'STATICCALL': call.valueBigInt = bigInt.zero; break;
|
|
}
|
|
this.callStack.push(call);
|
|
this.descended = true;
|
|
}
|
|
},
|
|
result: function(ctx, db) {
|
|
var result = this.ctxToResult(ctx, db);
|
|
var callSequence = this.sequence(result, [], result.valueBigInt, []).callSequence;
|
|
return this.encodeCallSequence(callSequence);
|
|
},
|
|
ctxToResult: function(ctx, db) {
|
|
var result;
|
|
switch (ctx.type) {
|
|
case 'CALL':
|
|
result = {
|
|
type: 'call', callType: 'call',
|
|
from: toHex(ctx.from), to: toHex(ctx.to),
|
|
valueBigInt: bigInt(ctx.value.toString(10)),
|
|
gasBigInt: bigInt(ctx.gas), gasUsedBigInt: bigInt(ctx.gasUsed),
|
|
input: toHex(ctx.input)
|
|
};
|
|
break;
|
|
case 'CREATE':
|
|
case 'CREATE2':
|
|
result = {
|
|
type: ctx.type.toLowerCase(),
|
|
from: toHex(ctx.from),
|
|
init: toHex(ctx.input),
|
|
valueBigInt: bigInt(ctx.value.toString(10)),
|
|
gasBigInt: bigInt(ctx.gas), gasUsedBigInt: bigInt(ctx.gasUsed)
|
|
};
|
|
if (!ctx.error) {
|
|
result.createdContractAddressHash = toHex(ctx.to);
|
|
result.createdContractCode = toHex(db.getCode(ctx.to));
|
|
}
|
|
break;
|
|
}
|
|
var bottomCall = this.bottomCall();
|
|
if (bottomCall.calls !== undefined) { result.calls = bottomCall.calls; }
|
|
if (bottomCall.error !== undefined) { result.error = bottomCall.error; }
|
|
else if (ctx.error !== undefined) { result.error = ctx.error; }
|
|
else if (result.type === 'call') { result.output = toHex(ctx.output); }
|
|
return result;
|
|
},
|
|
sequence: function(call, callSequence, availableValueBigInt, traceAddress) {
|
|
var subcalls = call.calls;
|
|
delete call.calls;
|
|
call.traceAddress = traceAddress;
|
|
if (call.type === 'call' && call.callType === 'delegatecall') {
|
|
call.valueBigInt = availableValueBigInt;
|
|
}
|
|
var newCallSequence = callSequence.concat([call]);
|
|
if (subcalls !== undefined) {
|
|
for (var i = 0; i < subcalls.length; i++) {
|
|
var nestedSequenced = this.sequence(subcalls[i], newCallSequence, call.valueBigInt, traceAddress.concat([i]));
|
|
newCallSequence = nestedSequenced.callSequence;
|
|
}
|
|
}
|
|
return { callSequence: newCallSequence };
|
|
},
|
|
encodeCallSequence: function(calls) {
|
|
for (var i = 0; i < calls.length; i++) {
|
|
var call = calls[i];
|
|
call.value = '0x' + (call.valueBigInt || bigInt.zero).toString(16);
|
|
delete call.valueBigInt;
|
|
call.gas = '0x' + (call.gasBigInt || bigInt.zero).toString(16);
|
|
delete call.gasBigInt;
|
|
call.gasUsed = '0x' + (call.gasUsedBigInt || bigInt.zero).toString(16);
|
|
delete call.gasUsedBigInt;
|
|
}
|
|
return calls;
|
|
}
|
|
}`
|
|
|
|
params := map[string]interface{}{
|
|
"tracer": tracer,
|
|
}
|
|
if timeout != "" {
|
|
params["timeout"] = timeout
|
|
}
|
|
|
|
result, err := a.call(ctx, "debug_traceTransaction", []interface{}{txHash, params})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("debug_traceTransaction: %w", err)
|
|
}
|
|
|
|
var jsTraces []struct {
|
|
Type string `json:"type"`
|
|
CallType string `json:"callType,omitempty"`
|
|
From string `json:"from"`
|
|
To string `json:"to,omitempty"`
|
|
Value string `json:"value"`
|
|
Gas string `json:"gas"`
|
|
GasUsed string `json:"gasUsed"`
|
|
Input string `json:"input,omitempty"`
|
|
Init string `json:"init,omitempty"`
|
|
Output string `json:"output,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
TraceAddress []int `json:"traceAddress"`
|
|
CreatedContractAddressHash string `json:"createdContractAddressHash,omitempty"`
|
|
CreatedContractCode string `json:"createdContractCode,omitempty"`
|
|
}
|
|
|
|
if err := json.Unmarshal(result, &jsTraces); err != nil {
|
|
return nil, fmt.Errorf("parse JS trace result: %w", err)
|
|
}
|
|
|
|
var traces []InternalTransaction
|
|
for i, t := range jsTraces {
|
|
callType := t.Type
|
|
if t.CallType != "" {
|
|
callType = t.CallType
|
|
}
|
|
|
|
itx := InternalTransaction{
|
|
ID: fmt.Sprintf("%s-%d", txHash, i),
|
|
TxHash: txHash,
|
|
BlockNumber: blockNumber,
|
|
TraceIndex: uint64(i),
|
|
TraceAddress: t.TraceAddress,
|
|
CallType: normalizeCallType(callType),
|
|
From: strings.ToLower(t.From),
|
|
To: strings.ToLower(t.To),
|
|
Value: normalizeValue(t.Value),
|
|
Gas: hexToUint64(t.Gas),
|
|
GasUsed: hexToUint64(t.GasUsed),
|
|
Input: t.Input,
|
|
Output: t.Output,
|
|
Error: t.Error,
|
|
Timestamp: timestamp,
|
|
CreatedContractAddress: strings.ToLower(t.CreatedContractAddressHash),
|
|
CreatedContractCode: t.CreatedContractCode,
|
|
Init: t.Init,
|
|
}
|
|
|
|
traces = append(traces, itx)
|
|
}
|
|
|
|
return traces, nil
|
|
}
|
|
|
|
// traceTransactionParity uses trace_replayBlockTransactions (Parity/Nethermind)
|
|
func (a *Adapter) traceTransactionParity(ctx context.Context, txHash string, blockNumber uint64, timestamp time.Time) ([]InternalTransaction, error) {
|
|
// First, get the block with transactions to find transaction index
|
|
blockNumHex := fmt.Sprintf("0x%x", blockNumber)
|
|
result, err := a.call(ctx, "trace_replayBlockTransactions", []interface{}{blockNumHex, []string{"trace"}})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("trace_replayBlockTransactions: %w", err)
|
|
}
|
|
|
|
var blockTraces []struct {
|
|
TransactionHash string `json:"transactionHash"`
|
|
Trace []struct {
|
|
Action struct {
|
|
CallType string `json:"callType,omitempty"`
|
|
From string `json:"from"`
|
|
To string `json:"to,omitempty"`
|
|
Value string `json:"value,omitempty"`
|
|
Gas string `json:"gas"`
|
|
Input string `json:"input,omitempty"`
|
|
Init string `json:"init,omitempty"`
|
|
Address string `json:"address,omitempty"` // selfdestruct
|
|
RefundAddress string `json:"refundAddress,omitempty"` // selfdestruct
|
|
Balance string `json:"balance,omitempty"` // selfdestruct
|
|
CreationMethod string `json:"creationMethod,omitempty"` // create, create2
|
|
} `json:"action"`
|
|
Result struct {
|
|
GasUsed string `json:"gasUsed"`
|
|
Output string `json:"output,omitempty"`
|
|
Address string `json:"address,omitempty"` // created contract
|
|
Code string `json:"code,omitempty"` // created contract code
|
|
} `json:"result"`
|
|
Error string `json:"error,omitempty"`
|
|
Type string `json:"type"` // call, create, suicide
|
|
Subtraces int `json:"subtraces"`
|
|
TraceAddress []int `json:"traceAddress"`
|
|
} `json:"trace"`
|
|
}
|
|
|
|
if err := json.Unmarshal(result, &blockTraces); err != nil {
|
|
return nil, fmt.Errorf("parse trace result: %w", err)
|
|
}
|
|
|
|
// Find traces for our transaction
|
|
var traces []InternalTransaction
|
|
for _, bt := range blockTraces {
|
|
if strings.EqualFold(bt.TransactionHash, txHash) {
|
|
for i, t := range bt.Trace {
|
|
callType := normalizeCallType(t.Type)
|
|
if t.Action.CallType != "" {
|
|
callType = normalizeCallType(t.Action.CallType)
|
|
}
|
|
if t.Action.CreationMethod != "" {
|
|
callType = normalizeCallType(t.Action.CreationMethod)
|
|
}
|
|
|
|
to := strings.ToLower(t.Action.To)
|
|
if t.Type == "suicide" || t.Type == "selfdestruct" {
|
|
to = strings.ToLower(t.Action.RefundAddress)
|
|
}
|
|
|
|
value := normalizeValue(t.Action.Value)
|
|
if t.Action.Balance != "" {
|
|
value = t.Action.Balance
|
|
}
|
|
|
|
itx := InternalTransaction{
|
|
ID: fmt.Sprintf("%s-%d", txHash, i),
|
|
TxHash: txHash,
|
|
BlockNumber: blockNumber,
|
|
TraceIndex: uint64(i),
|
|
TraceAddress: t.TraceAddress,
|
|
CallType: callType,
|
|
From: strings.ToLower(t.Action.From),
|
|
To: to,
|
|
Value: value,
|
|
Gas: hexToUint64(t.Action.Gas),
|
|
GasUsed: hexToUint64(t.Result.GasUsed),
|
|
Input: t.Action.Input,
|
|
Output: t.Result.Output,
|
|
Error: t.Error,
|
|
Timestamp: timestamp,
|
|
CreatedContractAddress: strings.ToLower(t.Result.Address),
|
|
CreatedContractCode: t.Result.Code,
|
|
Init: t.Action.Init,
|
|
}
|
|
|
|
if t.Type == "suicide" || t.Type == "selfdestruct" {
|
|
itx.From = strings.ToLower(t.Action.Address)
|
|
}
|
|
|
|
traces = append(traces, itx)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
return traces, nil
|
|
}
|
|
|
|
// TraceBlock traces all transactions in a block
|
|
func (a *Adapter) TraceBlock(ctx context.Context, blockNumber uint64, timestamp time.Time) ([]InternalTransaction, error) {
|
|
blockNumHex := fmt.Sprintf("0x%x", blockNumber)
|
|
|
|
a.mu.RLock()
|
|
tracerType := a.tracerType
|
|
timeout := a.traceTimeout
|
|
a.mu.RUnlock()
|
|
|
|
if tracerType == TracerParity {
|
|
// Use trace_replayBlockTransactions for all transactions at once
|
|
return a.traceBlockParity(ctx, blockNumHex, blockNumber, timestamp)
|
|
}
|
|
|
|
// For callTracer, trace each transaction individually
|
|
params := map[string]interface{}{
|
|
"tracer": "callTracer",
|
|
"tracerConfig": map[string]interface{}{
|
|
"withLog": false,
|
|
},
|
|
}
|
|
if timeout != "" {
|
|
params["timeout"] = timeout
|
|
}
|
|
|
|
result, err := a.call(ctx, "debug_traceBlockByNumber", []interface{}{blockNumHex, params})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("debug_traceBlockByNumber: %w", err)
|
|
}
|
|
|
|
var blockTraces []struct {
|
|
TxHash string `json:"txHash"`
|
|
Result CallFrame `json:"result"`
|
|
}
|
|
|
|
if err := json.Unmarshal(result, &blockTraces); err != nil {
|
|
return nil, fmt.Errorf("parse block trace result: %w", err)
|
|
}
|
|
|
|
var allTraces []InternalTransaction
|
|
for _, bt := range blockTraces {
|
|
var traces []InternalTransaction
|
|
flattenCallFrame(&bt.Result, bt.TxHash, blockNumber, timestamp, []int{}, &traces, 0)
|
|
allTraces = append(allTraces, traces...)
|
|
}
|
|
|
|
return allTraces, nil
|
|
}
|
|
|
|
// traceBlockParity traces a block using trace_replayBlockTransactions
|
|
func (a *Adapter) traceBlockParity(ctx context.Context, blockNumHex string, blockNumber uint64, timestamp time.Time) ([]InternalTransaction, error) {
|
|
result, err := a.call(ctx, "trace_replayBlockTransactions", []interface{}{blockNumHex, []string{"trace"}})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("trace_replayBlockTransactions: %w", err)
|
|
}
|
|
|
|
var blockTraces []struct {
|
|
TransactionHash string `json:"transactionHash"`
|
|
Trace []struct {
|
|
Action struct {
|
|
CallType string `json:"callType,omitempty"`
|
|
From string `json:"from"`
|
|
To string `json:"to,omitempty"`
|
|
Value string `json:"value,omitempty"`
|
|
Gas string `json:"gas"`
|
|
Input string `json:"input,omitempty"`
|
|
Init string `json:"init,omitempty"`
|
|
Address string `json:"address,omitempty"`
|
|
RefundAddress string `json:"refundAddress,omitempty"`
|
|
Balance string `json:"balance,omitempty"`
|
|
CreationMethod string `json:"creationMethod,omitempty"`
|
|
} `json:"action"`
|
|
Result struct {
|
|
GasUsed string `json:"gasUsed"`
|
|
Output string `json:"output,omitempty"`
|
|
Address string `json:"address,omitempty"`
|
|
Code string `json:"code,omitempty"`
|
|
} `json:"result"`
|
|
Error string `json:"error,omitempty"`
|
|
Type string `json:"type"`
|
|
Subtraces int `json:"subtraces"`
|
|
TraceAddress []int `json:"traceAddress"`
|
|
} `json:"trace"`
|
|
}
|
|
|
|
if err := json.Unmarshal(result, &blockTraces); err != nil {
|
|
return nil, fmt.Errorf("parse trace result: %w", err)
|
|
}
|
|
|
|
var allTraces []InternalTransaction
|
|
for _, bt := range blockTraces {
|
|
for i, t := range bt.Trace {
|
|
callType := normalizeCallType(t.Type)
|
|
if t.Action.CallType != "" {
|
|
callType = normalizeCallType(t.Action.CallType)
|
|
}
|
|
if t.Action.CreationMethod != "" {
|
|
callType = normalizeCallType(t.Action.CreationMethod)
|
|
}
|
|
|
|
to := strings.ToLower(t.Action.To)
|
|
if t.Type == "suicide" || t.Type == "selfdestruct" {
|
|
to = strings.ToLower(t.Action.RefundAddress)
|
|
}
|
|
|
|
value := normalizeValue(t.Action.Value)
|
|
if t.Action.Balance != "" {
|
|
value = t.Action.Balance
|
|
}
|
|
|
|
itx := InternalTransaction{
|
|
ID: fmt.Sprintf("%s-%d", bt.TransactionHash, i),
|
|
TxHash: bt.TransactionHash,
|
|
BlockNumber: blockNumber,
|
|
TraceIndex: uint64(i),
|
|
TraceAddress: t.TraceAddress,
|
|
CallType: callType,
|
|
From: strings.ToLower(t.Action.From),
|
|
To: to,
|
|
Value: value,
|
|
Gas: hexToUint64(t.Action.Gas),
|
|
GasUsed: hexToUint64(t.Result.GasUsed),
|
|
Input: t.Action.Input,
|
|
Output: t.Result.Output,
|
|
Error: t.Error,
|
|
Timestamp: timestamp,
|
|
CreatedContractAddress: strings.ToLower(t.Result.Address),
|
|
CreatedContractCode: t.Result.Code,
|
|
Init: t.Action.Init,
|
|
}
|
|
|
|
if t.Type == "suicide" || t.Type == "selfdestruct" {
|
|
itx.From = strings.ToLower(t.Action.Address)
|
|
}
|
|
|
|
allTraces = append(allTraces, itx)
|
|
}
|
|
}
|
|
|
|
return allTraces, nil
|
|
}
|
|
|
|
// GetTokenBalance fetches ERC20 token balance for an address
|
|
func (a *Adapter) GetTokenBalance(ctx context.Context, tokenAddress, holderAddress string, blockNumber uint64) (*big.Int, error) {
|
|
// ERC20 balanceOf(address) = 0x70a08231 + padded address
|
|
data := "0x70a08231" + strings.Repeat("0", 24) + strings.TrimPrefix(strings.ToLower(holderAddress), "0x")
|
|
|
|
blockTag := "latest"
|
|
if blockNumber > 0 {
|
|
blockTag = fmt.Sprintf("0x%x", blockNumber)
|
|
}
|
|
|
|
result, err := a.call(ctx, "eth_call", []interface{}{
|
|
map[string]string{"to": tokenAddress, "data": data},
|
|
blockTag,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var balanceHex string
|
|
if err := json.Unmarshal(result, &balanceHex); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return hexToBigInt(balanceHex), nil
|
|
}
|
|
|
|
// GetERC721Owner fetches the owner of an ERC721 token
|
|
func (a *Adapter) GetERC721Owner(ctx context.Context, tokenAddress string, tokenID *big.Int) (string, error) {
|
|
// ERC721 ownerOf(uint256) = 0x6352211e + padded tokenId
|
|
tokenIDHex := fmt.Sprintf("%064x", tokenID)
|
|
data := "0x6352211e" + tokenIDHex
|
|
|
|
result, err := a.call(ctx, "eth_call", []interface{}{
|
|
map[string]string{"to": tokenAddress, "data": data},
|
|
"latest",
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var ownerHex string
|
|
if err := json.Unmarshal(result, &ownerHex); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return topicToAddress(ownerHex), nil
|
|
}
|
|
|
|
// GetERC1155Balance fetches ERC1155 token balance
|
|
func (a *Adapter) GetERC1155Balance(ctx context.Context, tokenAddress, holderAddress string, tokenID *big.Int) (*big.Int, error) {
|
|
// ERC1155 balanceOf(address,uint256) = 0x00fdd58e + padded address + padded tokenId
|
|
addressPadded := strings.Repeat("0", 24) + strings.TrimPrefix(strings.ToLower(holderAddress), "0x")
|
|
tokenIDHex := fmt.Sprintf("%064x", tokenID)
|
|
data := "0x00fdd58e" + addressPadded + tokenIDHex
|
|
|
|
result, err := a.call(ctx, "eth_call", []interface{}{
|
|
map[string]string{"to": tokenAddress, "data": data},
|
|
"latest",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var balanceHex string
|
|
if err := json.Unmarshal(result, &balanceHex); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return hexToBigInt(balanceHex), nil
|
|
}
|
|
|
|
// GetBalanceAtBlock fetches native coin balance at a specific block
|
|
func (a *Adapter) GetBalanceAtBlock(ctx context.Context, address string, blockNumber uint64) (*big.Int, error) {
|
|
blockTag := "latest"
|
|
if blockNumber > 0 {
|
|
blockTag = fmt.Sprintf("0x%x", blockNumber)
|
|
}
|
|
|
|
result, err := a.call(ctx, "eth_getBalance", []interface{}{address, blockTag})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var balanceHex string
|
|
if err := json.Unmarshal(result, &balanceHex); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return hexToBigInt(balanceHex), nil
|
|
}
|
|
|
|
// call makes an RPC call to the EVM node via the configured transport (ZAP or HTTP).
|
|
func (a *Adapter) call(ctx context.Context, method string, params interface{}) (json.RawMessage, error) {
|
|
// Convert params to []any for the transport interface
|
|
if p, ok := params.([]interface{}); ok {
|
|
return a.transport.Call(ctx, method, p...)
|
|
}
|
|
return a.transport.Call(ctx, method, params)
|
|
}
|
|
|
|
// InitSchema creates chain-specific database tables using unified storage
|
|
func (a *Adapter) InitSchema(ctx context.Context, store storage.Store) error {
|
|
schema := storage.Schema{
|
|
Name: a.prefix,
|
|
Tables: []storage.Table{
|
|
{
|
|
Name: a.Tbl("transactions"),
|
|
Columns: []storage.Column{
|
|
{Name: "hash", Type: storage.TypeText, Primary: true},
|
|
{Name: "block_hash", Type: storage.TypeText, Nullable: false},
|
|
{Name: "block_number", Type: storage.TypeBigInt, Nullable: false},
|
|
{Name: "tx_from", Type: storage.TypeText, Nullable: false},
|
|
{Name: "tx_to", Type: storage.TypeText},
|
|
{Name: "value", Type: storage.TypeText, Default: "'0'"},
|
|
{Name: "gas", Type: storage.TypeBigInt, Nullable: false},
|
|
{Name: "gas_price", Type: storage.TypeText},
|
|
{Name: "gas_used", Type: storage.TypeBigInt},
|
|
{Name: "nonce", Type: storage.TypeBigInt, Nullable: false},
|
|
{Name: "input", Type: storage.TypeText},
|
|
{Name: "tx_index", Type: storage.TypeInt, Nullable: false},
|
|
{Name: "tx_type", Type: storage.TypeInt, Default: "0"},
|
|
{Name: "status", Type: storage.TypeInt, Default: "1"},
|
|
{Name: "contract_address", Type: storage.TypeText},
|
|
{Name: "timestamp", Type: storage.TypeTimestamp, Nullable: false},
|
|
},
|
|
},
|
|
{
|
|
Name: a.Tbl("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: "contract_code", Type: storage.TypeText},
|
|
{Name: "contract_creator", Type: storage.TypeText},
|
|
{Name: "contract_tx_hash", Type: storage.TypeText},
|
|
{Name: "created_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
|
|
{Name: "updated_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
|
|
},
|
|
},
|
|
{
|
|
Name: a.Tbl("token_transfers"),
|
|
Columns: []storage.Column{
|
|
{Name: "id", Type: storage.TypeText, Primary: true},
|
|
{Name: "tx_hash", Type: storage.TypeText, Nullable: false},
|
|
{Name: "log_index", Type: storage.TypeBigInt, Nullable: false},
|
|
{Name: "block_number", Type: storage.TypeBigInt, Nullable: false},
|
|
{Name: "token_address", Type: storage.TypeText, Nullable: false},
|
|
{Name: "token_type", Type: storage.TypeText, Nullable: false},
|
|
{Name: "tx_from", Type: storage.TypeText, Nullable: false},
|
|
{Name: "tx_to", Type: storage.TypeText, Nullable: false},
|
|
{Name: "value", Type: storage.TypeText, Nullable: false},
|
|
{Name: "token_id", Type: storage.TypeText},
|
|
{Name: "timestamp", Type: storage.TypeTimestamp, Nullable: false},
|
|
},
|
|
},
|
|
{
|
|
Name: a.Tbl("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, Default: "18"},
|
|
{Name: "total_supply", Type: storage.TypeText},
|
|
{Name: "token_type", Type: storage.TypeText, Nullable: false},
|
|
{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: a.Tbl("logs"),
|
|
Columns: []storage.Column{
|
|
{Name: "tx_hash", Type: storage.TypeText, Nullable: false},
|
|
{Name: "log_index", Type: storage.TypeBigInt, Nullable: false},
|
|
{Name: "block_number", Type: storage.TypeBigInt, Nullable: false},
|
|
{Name: "address", Type: storage.TypeText, Nullable: false},
|
|
{Name: "topics", Type: storage.TypeText, Nullable: false},
|
|
{Name: "data", Type: storage.TypeText},
|
|
{Name: "removed", Type: storage.TypeBool, Default: "false"},
|
|
},
|
|
},
|
|
{
|
|
Name: a.Tbl("internal_transactions"),
|
|
Columns: []storage.Column{
|
|
{Name: "id", Type: storage.TypeText, Primary: true},
|
|
{Name: "tx_hash", Type: storage.TypeText, Nullable: false},
|
|
{Name: "block_number", Type: storage.TypeBigInt, Nullable: false},
|
|
{Name: "trace_index", Type: storage.TypeBigInt, Nullable: false},
|
|
{Name: "trace_address", Type: storage.TypeText, Default: "''"},
|
|
{Name: "call_type", Type: storage.TypeText, Nullable: false},
|
|
{Name: "tx_from", Type: storage.TypeText, Nullable: false},
|
|
{Name: "tx_to", Type: storage.TypeText},
|
|
{Name: "value", Type: storage.TypeText, Default: "'0'"},
|
|
{Name: "gas", Type: storage.TypeBigInt},
|
|
{Name: "gas_used", Type: storage.TypeBigInt},
|
|
{Name: "input", Type: storage.TypeText},
|
|
{Name: "output", Type: storage.TypeText},
|
|
{Name: "error", Type: storage.TypeText},
|
|
{Name: "created_contract_address", Type: storage.TypeText},
|
|
{Name: "created_contract_code", Type: storage.TypeText},
|
|
{Name: "init", Type: storage.TypeText},
|
|
{Name: "timestamp", Type: storage.TypeTimestamp, Nullable: false},
|
|
},
|
|
},
|
|
{
|
|
Name: a.Tbl("token_balances"),
|
|
Columns: []storage.Column{
|
|
{Name: "token_address", Type: storage.TypeText, Nullable: false},
|
|
{Name: "holder_address", Type: storage.TypeText, Nullable: false},
|
|
{Name: "balance", Type: storage.TypeText, Default: "'0'"},
|
|
{Name: "token_id", Type: storage.TypeText},
|
|
{Name: "block_number", Type: storage.TypeBigInt, Default: "0"},
|
|
{Name: "updated_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
|
|
},
|
|
},
|
|
{
|
|
Name: a.Tbl("extended_stats"),
|
|
Columns: []storage.Column{
|
|
{Name: "id", Type: storage.TypeInt, Primary: true},
|
|
{Name: "total_transactions", Type: storage.TypeBigInt, Default: "0"},
|
|
{Name: "total_addresses", Type: storage.TypeBigInt, Default: "0"},
|
|
{Name: "total_contracts", Type: storage.TypeBigInt, Default: "0"},
|
|
{Name: "total_tokens", Type: storage.TypeBigInt, Default: "0"},
|
|
{Name: "total_token_transfers", Type: storage.TypeBigInt, Default: "0"},
|
|
{Name: "total_internal_transactions", Type: storage.TypeBigInt, Default: "0"},
|
|
{Name: "total_gas_used", Type: storage.TypeBigInt, Default: "0"},
|
|
{Name: "avg_gas_price", Type: storage.TypeText, Default: "'0'"},
|
|
{Name: "avg_block_time", Type: storage.TypeFloat, Default: "0"},
|
|
{Name: "tps_24h", Type: storage.TypeFloat, Default: "0"},
|
|
{Name: "updated_at", Type: storage.TypeTimestamp, Default: "CURRENT_TIMESTAMP"},
|
|
},
|
|
},
|
|
},
|
|
Indexes: []storage.Index{
|
|
{Name: a.Idx("tx_block"), Table: a.Tbl("transactions"), Columns: []string{"block_number"}},
|
|
{Name: a.Idx("tx_from"), Table: a.Tbl("transactions"), Columns: []string{"tx_from"}},
|
|
{Name: a.Idx("tx_to"), Table: a.Tbl("transactions"), Columns: []string{"tx_to"}},
|
|
{Name: a.Idx("tx_timestamp"), Table: a.Tbl("transactions"), Columns: []string{"timestamp"}},
|
|
{Name: a.Idx("addr_balance"), Table: a.Tbl("addresses"), Columns: []string{"balance"}},
|
|
{Name: a.Idx("transfer_token"), Table: a.Tbl("token_transfers"), Columns: []string{"token_address"}},
|
|
{Name: a.Idx("transfer_from"), Table: a.Tbl("token_transfers"), Columns: []string{"tx_from"}},
|
|
{Name: a.Idx("transfer_to"), Table: a.Tbl("token_transfers"), Columns: []string{"tx_to"}},
|
|
{Name: a.Idx("transfer_block"), Table: a.Tbl("token_transfers"), Columns: []string{"block_number"}},
|
|
{Name: a.Idx("token_type"), Table: a.Tbl("tokens"), Columns: []string{"token_type"}},
|
|
{Name: a.Idx("logs_address"), Table: a.Tbl("logs"), Columns: []string{"address"}},
|
|
{Name: a.Idx("logs_block"), Table: a.Tbl("logs"), Columns: []string{"block_number"}},
|
|
{Name: a.Idx("internal_tx"), Table: a.Tbl("internal_transactions"), Columns: []string{"tx_hash"}},
|
|
{Name: a.Idx("internal_from"), Table: a.Tbl("internal_transactions"), Columns: []string{"tx_from"}},
|
|
{Name: a.Idx("internal_to"), Table: a.Tbl("internal_transactions"), Columns: []string{"tx_to"}},
|
|
{Name: a.Idx("internal_block"), Table: a.Tbl("internal_transactions"), Columns: []string{"block_number"}},
|
|
{Name: a.Idx("balance_holder"), Table: a.Tbl("token_balances"), Columns: []string{"holder_address"}},
|
|
{Name: a.Idx("balance_token"), Table: a.Tbl("token_balances"), Columns: []string{"token_address"}},
|
|
},
|
|
}
|
|
|
|
if err := store.InitSchema(ctx, schema); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Initialize stats row
|
|
return store.Exec(ctx, fmt.Sprintf("INSERT INTO %s (id) VALUES (1) ON CONFLICT DO NOTHING", a.Tbl("extended_stats")))
|
|
}
|
|
|
|
// GetStats returns chain-specific statistics using unified storage
|
|
func (a *Adapter) GetStats(ctx context.Context, store storage.Store) (map[string]interface{}, error) {
|
|
stats := make(map[string]interface{})
|
|
|
|
// Get extended stats
|
|
rows, err := store.Query(ctx, fmt.Sprintf(`
|
|
SELECT total_transactions, total_addresses, total_contracts, total_tokens,
|
|
total_token_transfers, total_internal_transactions, total_gas_used,
|
|
avg_gas_price, avg_block_time, tps_24h, updated_at
|
|
FROM %s WHERE id = 1
|
|
`, a.Tbl("extended_stats")))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(rows) > 0 {
|
|
row := rows[0]
|
|
stats["total_transactions"] = row["total_transactions"]
|
|
stats["total_addresses"] = row["total_addresses"]
|
|
stats["total_contracts"] = row["total_contracts"]
|
|
stats["total_tokens"] = row["total_tokens"]
|
|
stats["total_token_transfers"] = row["total_token_transfers"]
|
|
stats["total_internal_transactions"] = row["total_internal_transactions"]
|
|
stats["total_gas_used"] = row["total_gas_used"]
|
|
stats["avg_gas_price"] = row["avg_gas_price"]
|
|
stats["avg_block_time_seconds"] = row["avg_block_time"]
|
|
stats["tps_24h"] = row["tps_24h"]
|
|
stats["updated_at"] = row["updated_at"]
|
|
}
|
|
stats["chain_id"] = ChainID
|
|
|
|
// Get top tokens by holder count
|
|
tokenRows, err := store.Query(ctx, fmt.Sprintf(`
|
|
SELECT address, name, symbol, token_type, holder_count, tx_count
|
|
FROM %s
|
|
ORDER BY holder_count DESC
|
|
LIMIT 10
|
|
`, a.Tbl("tokens")))
|
|
if err != nil {
|
|
return stats, nil // Return stats without tokens on error
|
|
}
|
|
|
|
var topTokens []map[string]interface{}
|
|
for _, row := range tokenRows {
|
|
topTokens = append(topTokens, map[string]interface{}{
|
|
"address": row["address"],
|
|
"name": row["name"],
|
|
"symbol": row["symbol"],
|
|
"token_type": row["token_type"],
|
|
"holder_count": row["holder_count"],
|
|
"tx_count": row["tx_count"],
|
|
})
|
|
}
|
|
stats["top_tokens"] = topTokens
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
// StoreTransaction stores a transaction
|
|
func (a *Adapter) StoreTransaction(ctx context.Context, db *sql.DB, tx Transaction) error {
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
INSERT INTO %s
|
|
(hash, block_hash, block_number, tx_from, tx_to, value, gas, gas_price, gas_used,
|
|
nonce, input, tx_index, tx_type, status, contract_address, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
|
ON CONFLICT (hash) DO UPDATE SET
|
|
status = EXCLUDED.status,
|
|
gas_used = EXCLUDED.gas_used
|
|
`, a.Tbl("transactions")), tx.Hash, tx.BlockHash, tx.BlockNumber, tx.From, tx.To, tx.Value, tx.Gas, tx.GasPrice,
|
|
tx.GasUsed, tx.Nonce, tx.Input, tx.TransactionIndex, tx.Type, tx.Status, tx.ContractAddress, tx.Timestamp)
|
|
return err
|
|
}
|
|
|
|
// StoreAddress stores an address
|
|
func (a *Adapter) StoreAddress(ctx context.Context, db *sql.DB, addr Address) error {
|
|
tbl := a.Tbl("addresses")
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
INSERT INTO %s
|
|
(hash, balance, tx_count, is_contract, contract_code, contract_creator, contract_tx_hash, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
ON CONFLICT (hash) DO UPDATE SET
|
|
balance = EXCLUDED.balance,
|
|
tx_count = %s.tx_count + 1,
|
|
updated_at = EXCLUDED.updated_at
|
|
`, tbl, tbl), addr.Hash, addr.Balance, addr.TxCount, addr.IsContract, addr.ContractCode,
|
|
addr.ContractCreator, addr.ContractTxHash, addr.CreatedAt, addr.UpdatedAt)
|
|
return err
|
|
}
|
|
|
|
// StoreTokenTransfer stores a token transfer
|
|
func (a *Adapter) StoreTokenTransfer(ctx context.Context, db *sql.DB, t TokenTransfer) error {
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
INSERT INTO %s
|
|
(id, tx_hash, log_index, block_number, token_address, token_type, tx_from, tx_to, value, token_id, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
ON CONFLICT (id) DO NOTHING
|
|
`, a.Tbl("token_transfers")), t.ID, t.TxHash, t.LogIndex, t.BlockNumber, t.TokenAddress, t.TokenType, t.From, t.To, t.Value, t.TokenID, t.Timestamp)
|
|
return err
|
|
}
|
|
|
|
// StoreToken stores a token
|
|
func (a *Adapter) StoreToken(ctx context.Context, db *sql.DB, t Token) error {
|
|
tbl := a.Tbl("tokens")
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
INSERT INTO %s
|
|
(address, name, symbol, decimals, total_supply, token_type, holder_count, tx_count, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
ON CONFLICT (address) DO UPDATE SET
|
|
total_supply = EXCLUDED.total_supply,
|
|
holder_count = EXCLUDED.holder_count,
|
|
tx_count = %s.tx_count + 1,
|
|
updated_at = EXCLUDED.updated_at
|
|
`, tbl, tbl), t.Address, t.Name, t.Symbol, t.Decimals, t.TotalSupply, t.TokenType, t.HolderCount, t.TxCount, t.CreatedAt, t.UpdatedAt)
|
|
return err
|
|
}
|
|
|
|
// StoreLog stores an event log
|
|
func (a *Adapter) StoreLog(ctx context.Context, db *sql.DB, l Log) error {
|
|
topicsJSON, _ := json.Marshal(l.Topics)
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
INSERT INTO %s (tx_hash, log_index, block_number, address, topics, data, removed)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (tx_hash, log_index) DO NOTHING
|
|
`, a.Tbl("logs")), l.TxHash, l.LogIndex, l.BlockNumber, l.Address, topicsJSON, l.Data, l.Removed)
|
|
return err
|
|
}
|
|
|
|
// StoreInternalTransaction stores an internal transaction
|
|
func (a *Adapter) StoreInternalTransaction(ctx context.Context, db *sql.DB, itx InternalTransaction) error {
|
|
// Convert trace address to PostgreSQL array format
|
|
traceAddrStr := intSliceToPostgresArray(itx.TraceAddress)
|
|
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
INSERT INTO %s
|
|
(id, tx_hash, block_number, trace_index, trace_address, call_type, tx_from, tx_to,
|
|
value, gas, gas_used, input, output, error, created_contract_address,
|
|
created_contract_code, init, timestamp)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
|
ON CONFLICT (id) DO NOTHING
|
|
`, a.Tbl("internal_transactions")), itx.ID, itx.TxHash, itx.BlockNumber, itx.TraceIndex, traceAddrStr, itx.CallType,
|
|
itx.From, itx.To, itx.Value, itx.Gas, itx.GasUsed, itx.Input, itx.Output, itx.Error,
|
|
itx.CreatedContractAddress, itx.CreatedContractCode, itx.Init, itx.Timestamp)
|
|
return err
|
|
}
|
|
|
|
// intSliceToPostgresArray converts an int slice to PostgreSQL array format
|
|
func intSliceToPostgresArray(arr []int) string {
|
|
if len(arr) == 0 {
|
|
return "{}"
|
|
}
|
|
strs := make([]string, len(arr))
|
|
for i, v := range arr {
|
|
strs[i] = fmt.Sprintf("%d", v)
|
|
}
|
|
return "{" + strings.Join(strs, ",") + "}"
|
|
}
|
|
|
|
// StoreTokenBalance stores or updates a token balance
|
|
func (a *Adapter) StoreTokenBalance(ctx context.Context, db *sql.DB, tb TokenBalance) error {
|
|
tokenID := tb.TokenID
|
|
if tokenID == "" {
|
|
tokenID = ""
|
|
}
|
|
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
INSERT INTO %s
|
|
(token_address, holder_address, balance, token_id, block_number, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (token_address, holder_address, COALESCE(token_id, '')) DO UPDATE SET
|
|
balance = EXCLUDED.balance,
|
|
block_number = EXCLUDED.block_number,
|
|
updated_at = EXCLUDED.updated_at
|
|
`, a.Tbl("token_balances")), tb.TokenAddress, tb.HolderAddress, tb.Balance, tokenID, tb.BlockNumber, tb.UpdatedAt)
|
|
return err
|
|
}
|
|
|
|
// StoreTokenBalanceHistory stores token balance history
|
|
func (a *Adapter) StoreTokenBalanceHistory(ctx context.Context, db *sql.DB, tb TokenBalance) error {
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
INSERT INTO %s
|
|
(token_address, holder_address, balance, token_id, block_number)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`, a.Tbl("token_balance_history")), tb.TokenAddress, tb.HolderAddress, tb.Balance, tb.TokenID, tb.BlockNumber)
|
|
return err
|
|
}
|
|
|
|
// StoreAddressCoinBalance stores native coin balance for an address at a block
|
|
func (a *Adapter) StoreAddressCoinBalance(ctx context.Context, db *sql.DB, acb AddressCoinBalance) error {
|
|
tbl := a.Tbl("address_coin_balances")
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
INSERT INTO %s
|
|
(address_hash, block_number, value, value_fetched_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, NOW())
|
|
ON CONFLICT (address_hash, block_number) DO UPDATE SET
|
|
value = CASE
|
|
WHEN EXCLUDED.value IS NOT NULL AND (%s.value_fetched_at IS NULL
|
|
OR EXCLUDED.value_fetched_at > %s.value_fetched_at)
|
|
THEN EXCLUDED.value
|
|
ELSE %s.value
|
|
END,
|
|
value_fetched_at = CASE
|
|
WHEN EXCLUDED.value IS NOT NULL AND (%s.value_fetched_at IS NULL
|
|
OR EXCLUDED.value_fetched_at > %s.value_fetched_at)
|
|
THEN EXCLUDED.value_fetched_at
|
|
ELSE %s.value_fetched_at
|
|
END,
|
|
updated_at = NOW()
|
|
`, tbl, tbl, tbl, tbl, tbl, tbl, tbl), acb.AddressHash, acb.BlockNumber, acb.Value, acb.ValueFetchedAt)
|
|
return err
|
|
}
|
|
|
|
// UpdateTokenHolderCount updates the holder count for a token
|
|
func (a *Adapter) UpdateTokenHolderCount(ctx context.Context, db *sql.DB, tokenAddress string) error {
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
UPDATE %s
|
|
SET holder_count = (
|
|
SELECT COUNT(DISTINCT holder_address)
|
|
FROM %s
|
|
WHERE token_address = $1 AND balance != '0'
|
|
),
|
|
updated_at = NOW()
|
|
WHERE address = $1
|
|
`, a.Tbl("tokens"), a.Tbl("token_balances")), tokenAddress)
|
|
return err
|
|
}
|
|
|
|
// ProcessBlock processes a block and extracts all data
|
|
func (a *Adapter) ProcessBlock(ctx context.Context, db *sql.DB, block *chain.Block) error {
|
|
timestamp := block.Timestamp
|
|
now := time.Now()
|
|
|
|
// Collect addresses involved in this block for balance tracking
|
|
addressSet := make(map[string]struct{})
|
|
|
|
// Process transactions from block data
|
|
for _, txHash := range block.TxIDs {
|
|
// Get receipt for full transaction details
|
|
tx, logs, err := a.GetTransactionReceipt(ctx, txHash)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
tx.Timestamp = timestamp
|
|
|
|
// Store transaction
|
|
if err := a.StoreTransaction(ctx, db, *tx); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Track addresses for balance fetching
|
|
if tx.From != "" {
|
|
addressSet[strings.ToLower(tx.From)] = struct{}{}
|
|
}
|
|
if tx.To != "" {
|
|
addressSet[strings.ToLower(tx.To)] = struct{}{}
|
|
}
|
|
|
|
// Store logs and process token transfers
|
|
for _, log := range logs {
|
|
if err := a.StoreLog(ctx, db, log); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Parse token transfers from logs
|
|
transfers := parseTokenTransfers(log, timestamp)
|
|
for _, transfer := range transfers {
|
|
if err := a.StoreTokenTransfer(ctx, db, transfer); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Update token balances
|
|
a.updateTokenBalances(ctx, db, transfer, block.Height)
|
|
}
|
|
}
|
|
|
|
// Trace internal transactions
|
|
internalTxs, err := a.TraceTransaction(ctx, txHash, block.Height, timestamp)
|
|
if err == nil {
|
|
for _, itx := range internalTxs {
|
|
if err := a.StoreInternalTransaction(ctx, db, itx); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Track addresses from internal transactions
|
|
if itx.From != "" {
|
|
addressSet[strings.ToLower(itx.From)] = struct{}{}
|
|
}
|
|
if itx.To != "" {
|
|
addressSet[strings.ToLower(itx.To)] = struct{}{}
|
|
}
|
|
if itx.CreatedContractAddress != "" {
|
|
addressSet[strings.ToLower(itx.CreatedContractAddress)] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update addresses
|
|
if tx.From != "" {
|
|
a.StoreAddress(ctx, db, Address{
|
|
Hash: strings.ToLower(tx.From),
|
|
TxCount: 1,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
if tx.To != "" {
|
|
a.StoreAddress(ctx, db, Address{
|
|
Hash: strings.ToLower(tx.To),
|
|
TxCount: 1,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
// Handle contract creation
|
|
if tx.ContractAddress != "" {
|
|
a.StoreAddress(ctx, db, Address{
|
|
Hash: strings.ToLower(tx.ContractAddress),
|
|
IsContract: true,
|
|
ContractCreator: tx.From,
|
|
ContractTxHash: tx.Hash,
|
|
CreatedAt: timestamp,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Fetch and store coin balances for all involved addresses
|
|
a.fetchAndStoreCoinBalances(ctx, db, addressSet, block.Height)
|
|
|
|
return nil
|
|
}
|
|
|
|
// updateTokenBalances updates token balances after a transfer
|
|
func (a *Adapter) updateTokenBalances(ctx context.Context, db *sql.DB, transfer TokenTransfer, blockNumber uint64) {
|
|
now := time.Now()
|
|
|
|
// Fetch and update sender balance (if not zero address)
|
|
if transfer.From != "" && transfer.From != "0x0000000000000000000000000000000000000000" {
|
|
var balance *big.Int
|
|
var err error
|
|
|
|
switch transfer.TokenType {
|
|
case TypeERC20:
|
|
balance, err = a.GetTokenBalance(ctx, transfer.TokenAddress, transfer.From, blockNumber)
|
|
case TypeERC721:
|
|
// For ERC721, balance is 0 or 1 based on ownership
|
|
owner, ownerErr := a.GetERC721Owner(ctx, transfer.TokenAddress, hexToBigInt(transfer.TokenID))
|
|
if ownerErr == nil && strings.EqualFold(owner, transfer.From) {
|
|
balance = big.NewInt(1)
|
|
} else {
|
|
balance = big.NewInt(0)
|
|
}
|
|
err = ownerErr
|
|
case TypeERC1155:
|
|
balance, err = a.GetERC1155Balance(ctx, transfer.TokenAddress, transfer.From, hexToBigInt(transfer.TokenID))
|
|
}
|
|
|
|
if err == nil && balance != nil {
|
|
tb := TokenBalance{
|
|
TokenAddress: transfer.TokenAddress,
|
|
HolderAddress: transfer.From,
|
|
Balance: balance.String(),
|
|
TokenID: transfer.TokenID,
|
|
BlockNumber: blockNumber,
|
|
UpdatedAt: now,
|
|
}
|
|
a.StoreTokenBalance(ctx, db, tb)
|
|
a.StoreTokenBalanceHistory(ctx, db, tb)
|
|
}
|
|
}
|
|
|
|
// Fetch and update receiver balance
|
|
if transfer.To != "" && transfer.To != "0x0000000000000000000000000000000000000000" {
|
|
var balance *big.Int
|
|
var err error
|
|
|
|
switch transfer.TokenType {
|
|
case TypeERC20:
|
|
balance, err = a.GetTokenBalance(ctx, transfer.TokenAddress, transfer.To, blockNumber)
|
|
case TypeERC721:
|
|
owner, ownerErr := a.GetERC721Owner(ctx, transfer.TokenAddress, hexToBigInt(transfer.TokenID))
|
|
if ownerErr == nil && strings.EqualFold(owner, transfer.To) {
|
|
balance = big.NewInt(1)
|
|
} else {
|
|
balance = big.NewInt(0)
|
|
}
|
|
err = ownerErr
|
|
case TypeERC1155:
|
|
balance, err = a.GetERC1155Balance(ctx, transfer.TokenAddress, transfer.To, hexToBigInt(transfer.TokenID))
|
|
}
|
|
|
|
if err == nil && balance != nil {
|
|
tb := TokenBalance{
|
|
TokenAddress: transfer.TokenAddress,
|
|
HolderAddress: transfer.To,
|
|
Balance: balance.String(),
|
|
TokenID: transfer.TokenID,
|
|
BlockNumber: blockNumber,
|
|
UpdatedAt: now,
|
|
}
|
|
a.StoreTokenBalance(ctx, db, tb)
|
|
a.StoreTokenBalanceHistory(ctx, db, tb)
|
|
}
|
|
}
|
|
|
|
// Update holder count for the token
|
|
a.UpdateTokenHolderCount(ctx, db, transfer.TokenAddress)
|
|
}
|
|
|
|
// fetchAndStoreCoinBalances fetches and stores native coin balances for addresses
|
|
func (a *Adapter) fetchAndStoreCoinBalances(ctx context.Context, db *sql.DB, addresses map[string]struct{}, blockNumber uint64) {
|
|
now := time.Now()
|
|
|
|
for addr := range addresses {
|
|
balance, err := a.GetBalanceAtBlock(ctx, addr, blockNumber)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
acb := AddressCoinBalance{
|
|
AddressHash: addr,
|
|
BlockNumber: blockNumber,
|
|
Value: balance.String(),
|
|
ValueFetchedAt: now,
|
|
}
|
|
|
|
a.StoreAddressCoinBalance(ctx, db, acb)
|
|
|
|
// Also update the main address balance
|
|
a.StoreAddress(ctx, db, Address{
|
|
Hash: addr,
|
|
Balance: balance.String(),
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
}
|
|
|
|
// UpdateExtendedStats updates chain-specific statistics
|
|
func (a *Adapter) UpdateExtendedStats(ctx context.Context, db *sql.DB) error {
|
|
_, err := db.ExecContext(ctx, fmt.Sprintf(`
|
|
UPDATE %s SET
|
|
total_transactions = (SELECT COUNT(*) FROM %s),
|
|
total_addresses = (SELECT COUNT(*) FROM %s),
|
|
total_contracts = (SELECT COUNT(*) FROM %s WHERE is_contract = TRUE),
|
|
total_tokens = (SELECT COUNT(*) FROM %s),
|
|
total_token_transfers = (SELECT COUNT(*) FROM %s),
|
|
total_internal_transactions = (SELECT COUNT(*) FROM %s),
|
|
total_gas_used = COALESCE((SELECT SUM(gas_used) FROM %s), 0),
|
|
avg_gas_price = COALESCE((SELECT AVG(CAST(gas_price AS NUMERIC)) FROM %s WHERE gas_price IS NOT NULL), 0)::TEXT,
|
|
tps_24h = COALESCE((
|
|
SELECT COUNT(*)::FLOAT / 86400
|
|
FROM %s
|
|
WHERE timestamp > NOW() - INTERVAL '24 hours'
|
|
), 0),
|
|
updated_at = NOW()
|
|
WHERE id = 1
|
|
`,
|
|
a.Tbl("extended_stats"),
|
|
a.Tbl("transactions"),
|
|
a.Tbl("addresses"),
|
|
a.Tbl("addresses"),
|
|
a.Tbl("tokens"),
|
|
a.Tbl("token_transfers"),
|
|
a.Tbl("internal_transactions"),
|
|
a.Tbl("transactions"),
|
|
a.Tbl("transactions"),
|
|
a.Tbl("transactions"),
|
|
))
|
|
return err
|
|
}
|
|
|
|
// parseTokenTransfers extracts token movements from a log on the Adapter
|
|
// path. Transfer events are read by decodeTransfers, the one decoder both
|
|
// indexing paths share; the wrapper mint/burn events below are this path's
|
|
// own and are not transfers in the ERC sense — a WETH deposit creates supply
|
|
// rather than moving it.
|
|
func parseTokenTransfers(log Log, timestamp time.Time) []TokenTransfer {
|
|
if t := decodeTransfers(log, timestamp); len(t) > 0 {
|
|
return t
|
|
}
|
|
if len(log.Topics) == 0 {
|
|
return nil
|
|
}
|
|
zero := "0x0000000000000000000000000000000000000000"
|
|
wrap := func(from, to string) []TokenTransfer {
|
|
return []TokenTransfer{{
|
|
ID: fmt.Sprintf("%s-%d", log.TxHash, log.LogIndex),
|
|
TxHash: log.TxHash,
|
|
LogIndex: log.LogIndex,
|
|
BlockNumber: log.BlockNumber,
|
|
TokenAddress: log.Address,
|
|
TokenType: TypeERC20,
|
|
From: from,
|
|
To: to,
|
|
Value: hexToBigInt(log.Data).String(),
|
|
Timestamp: timestamp,
|
|
}}
|
|
}
|
|
switch log.Topics[0] {
|
|
case TopicWETHDeposit:
|
|
// Deposit(address indexed dst, uint256 wad)
|
|
if len(log.Topics) >= 2 {
|
|
return wrap(zero, topicToAddress(log.Topics[1]))
|
|
}
|
|
case TopicWETHWithdrawal:
|
|
// Withdrawal(address indexed src, uint256 wad)
|
|
if len(log.Topics) >= 2 {
|
|
return wrap(topicToAddress(log.Topics[1]), zero)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decodeBatchData ABI-decodes (uint256[], uint256[]) from hex log data.
|
|
func decodeBatchData(data string) (ids []string, values []string) {
|
|
d := strings.TrimPrefix(data, "0x")
|
|
if len(d) < 256 {
|
|
return nil, nil
|
|
}
|
|
idsOffsetBytes := hexToUint64("0x" + d[:64])
|
|
valsOffsetBytes := hexToUint64("0x" + d[64:128])
|
|
idsOffset := idsOffsetBytes * 2
|
|
valsOffset := valsOffsetBytes * 2
|
|
|
|
ids = decodeUint256Array(d, idsOffset)
|
|
values = decodeUint256Array(d, valsOffset)
|
|
return ids, values
|
|
}
|
|
|
|
// decodeUint256Array reads an ABI-encoded uint256[] at the given hex-char offset.
|
|
func decodeUint256Array(data string, offset uint64) []string {
|
|
if offset+64 > uint64(len(data)) {
|
|
return nil
|
|
}
|
|
count := hexToUint64("0x" + data[offset:offset+64])
|
|
start := offset + 64
|
|
result := make([]string, 0, count)
|
|
for i := uint64(0); i < count; i++ {
|
|
pos := start + i*64
|
|
if pos+64 > uint64(len(data)) {
|
|
break
|
|
}
|
|
result = append(result, hexToBigInt("0x"+data[pos:pos+64]).String())
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func hexToUint64(s string) uint64 {
|
|
s = strings.TrimPrefix(s, "0x")
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
n := new(big.Int)
|
|
n.SetString(s, 16)
|
|
return n.Uint64()
|
|
}
|
|
|
|
func hexToBigInt(s string) *big.Int {
|
|
s = strings.TrimPrefix(s, "0x")
|
|
if s == "" {
|
|
return big.NewInt(0)
|
|
}
|
|
n := new(big.Int)
|
|
n.SetString(s, 16)
|
|
return n
|
|
}
|
|
|
|
func topicToAddress(topic string) string {
|
|
topic = strings.TrimPrefix(topic, "0x")
|
|
if len(topic) >= 40 {
|
|
return strings.ToLower("0x" + topic[len(topic)-40:])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func addressToTopic(addr string) string {
|
|
addr = strings.TrimPrefix(strings.ToLower(addr), "0x")
|
|
return "0x" + strings.Repeat("0", 24) + addr
|
|
}
|
|
|
|
// GetBalance fetches the balance of an address
|
|
func (a *Adapter) GetBalance(ctx context.Context, address string) (*big.Int, error) {
|
|
result, err := a.call(ctx, "eth_getBalance", []interface{}{address, "latest"})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var balanceHex string
|
|
if err := json.Unmarshal(result, &balanceHex); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return hexToBigInt(balanceHex), nil
|
|
}
|
|
|
|
// GetCode fetches the code of a contract
|
|
func (a *Adapter) GetCode(ctx context.Context, address string) (string, error) {
|
|
result, err := a.call(ctx, "eth_getCode", []interface{}{address, "latest"})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var code string
|
|
if err := json.Unmarshal(result, &code); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return code, nil
|
|
}
|
|
|
|
// GetTokenInfo fetches ERC20 token metadata
|
|
func (a *Adapter) GetTokenInfo(ctx context.Context, tokenAddress string) (*Token, error) {
|
|
token := &Token{
|
|
Address: strings.ToLower(tokenAddress),
|
|
TokenType: TypeERC20,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
// Get name
|
|
if result, err := a.callContract(ctx, tokenAddress, "0x06fdde03"); err == nil { // name()
|
|
token.Name = decodeString(result)
|
|
}
|
|
|
|
// Get symbol
|
|
if result, err := a.callContract(ctx, tokenAddress, "0x95d89b41"); err == nil { // symbol()
|
|
token.Symbol = decodeString(result)
|
|
}
|
|
|
|
// Get decimals
|
|
if result, err := a.callContract(ctx, tokenAddress, "0x313ce567"); err == nil { // decimals()
|
|
token.Decimals = uint8(hexToUint64(result))
|
|
}
|
|
|
|
// Get totalSupply
|
|
if result, err := a.callContract(ctx, tokenAddress, "0x18160ddd"); err == nil { // totalSupply()
|
|
token.TotalSupply = hexToBigInt(result).String()
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// callContract makes an eth_call to a contract
|
|
func (a *Adapter) callContract(ctx context.Context, to, data string) (string, error) {
|
|
result, err := a.call(ctx, "eth_call", []interface{}{
|
|
map[string]string{"to": to, "data": data},
|
|
"latest",
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var resultHex string
|
|
if err := json.Unmarshal(result, &resultHex); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return resultHex, nil
|
|
}
|
|
|
|
// decodeString decodes an ABI-encoded string
|
|
func decodeString(data string) string {
|
|
data = strings.TrimPrefix(data, "0x")
|
|
if len(data) < 128 {
|
|
return ""
|
|
}
|
|
|
|
// Skip offset (32 bytes) and get length
|
|
lengthHex := data[64:128]
|
|
length := hexToUint64("0x" + lengthHex)
|
|
|
|
if length == 0 || len(data) < 128+int(length*2) {
|
|
return ""
|
|
}
|
|
|
|
// Decode string bytes
|
|
strData := data[128 : 128+length*2]
|
|
decoded, err := hex.DecodeString(strData)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
return string(decoded)
|
|
}
|
|
|
|
// GetInternalTransactions returns internal transactions for a transaction
|
|
func (a *Adapter) GetInternalTransactions(ctx context.Context, db *sql.DB, txHash string) ([]InternalTransaction, error) {
|
|
rows, err := db.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT id, tx_hash, block_number, trace_index, trace_address, call_type,
|
|
tx_from, tx_to, value, gas, gas_used, input, output, error,
|
|
created_contract_address, created_contract_code, init, timestamp
|
|
FROM %s
|
|
WHERE tx_hash = $1
|
|
ORDER BY trace_index
|
|
`, a.Tbl("internal_transactions")), txHash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var itxs []InternalTransaction
|
|
for rows.Next() {
|
|
var itx InternalTransaction
|
|
var traceAddrStr string
|
|
var createdAddr, createdCode, init sql.NullString
|
|
var errStr sql.NullString
|
|
|
|
err := rows.Scan(
|
|
&itx.ID, &itx.TxHash, &itx.BlockNumber, &itx.TraceIndex, &traceAddrStr,
|
|
&itx.CallType, &itx.From, &itx.To, &itx.Value, &itx.Gas, &itx.GasUsed,
|
|
&itx.Input, &itx.Output, &errStr, &createdAddr, &createdCode, &init, &itx.Timestamp,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
itx.TraceAddress = postgresArrayToIntSlice(traceAddrStr)
|
|
if errStr.Valid {
|
|
itx.Error = errStr.String
|
|
}
|
|
if createdAddr.Valid {
|
|
itx.CreatedContractAddress = createdAddr.String
|
|
}
|
|
if createdCode.Valid {
|
|
itx.CreatedContractCode = createdCode.String
|
|
}
|
|
if init.Valid {
|
|
itx.Init = init.String
|
|
}
|
|
|
|
itxs = append(itxs, itx)
|
|
}
|
|
|
|
return itxs, nil
|
|
}
|
|
|
|
// postgresArrayToIntSlice converts a PostgreSQL array string to an int slice
|
|
func postgresArrayToIntSlice(s string) []int {
|
|
s = strings.Trim(s, "{}")
|
|
if s == "" {
|
|
return []int{}
|
|
}
|
|
|
|
parts := strings.Split(s, ",")
|
|
result := make([]int, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
var n int
|
|
fmt.Sscanf(p, "%d", &n)
|
|
result = append(result, n)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetAddressCoinBalanceHistory returns coin balance history for an address
|
|
func (a *Adapter) GetAddressCoinBalanceHistory(ctx context.Context, db *sql.DB, address string, limit int) ([]AddressCoinBalance, error) {
|
|
rows, err := db.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT address_hash, block_number, value, value_fetched_at
|
|
FROM %s
|
|
WHERE address_hash = $1 AND value IS NOT NULL
|
|
ORDER BY block_number DESC
|
|
LIMIT $2
|
|
`, a.Tbl("address_coin_balances")), strings.ToLower(address), limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var balances []AddressCoinBalance
|
|
for rows.Next() {
|
|
var acb AddressCoinBalance
|
|
var value sql.NullString
|
|
var fetchedAt sql.NullTime
|
|
|
|
err := rows.Scan(&acb.AddressHash, &acb.BlockNumber, &value, &fetchedAt)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if value.Valid {
|
|
acb.Value = value.String
|
|
}
|
|
if fetchedAt.Valid {
|
|
acb.ValueFetchedAt = fetchedAt.Time
|
|
}
|
|
|
|
balances = append(balances, acb)
|
|
}
|
|
|
|
return balances, nil
|
|
}
|
|
|
|
// GetTokenBalanceHistory returns token balance history for an address
|
|
func (a *Adapter) GetTokenBalanceHistory(ctx context.Context, db *sql.DB, address string, limit int) ([]TokenBalance, error) {
|
|
rows, err := db.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT token_address, holder_address, balance, token_id, block_number, created_at
|
|
FROM %s
|
|
WHERE holder_address = $1
|
|
ORDER BY block_number DESC
|
|
LIMIT $2
|
|
`, a.Tbl("token_balance_history")), strings.ToLower(address), limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var balances []TokenBalance
|
|
for rows.Next() {
|
|
var tb TokenBalance
|
|
var tokenID sql.NullString
|
|
var createdAt time.Time
|
|
|
|
err := rows.Scan(&tb.TokenAddress, &tb.HolderAddress, &tb.Balance, &tokenID, &tb.BlockNumber, &createdAt)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if tokenID.Valid {
|
|
tb.TokenID = tokenID.String
|
|
}
|
|
tb.UpdatedAt = createdAt
|
|
|
|
balances = append(balances, tb)
|
|
}
|
|
|
|
return balances, nil
|
|
}
|
|
|
|
// GetTokenHolders returns all holders of a token with non-zero balances
|
|
func (a *Adapter) GetTokenHolders(ctx context.Context, db *sql.DB, tokenAddress string, limit, offset int) ([]TokenBalance, error) {
|
|
rows, err := db.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT token_address, holder_address, balance, token_id, block_number, updated_at
|
|
FROM %s
|
|
WHERE token_address = $1 AND balance != '0'
|
|
ORDER BY CAST(balance AS NUMERIC) DESC
|
|
LIMIT $2 OFFSET $3
|
|
`, a.Tbl("token_balances")), strings.ToLower(tokenAddress), limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var holders []TokenBalance
|
|
for rows.Next() {
|
|
var tb TokenBalance
|
|
var tokenID sql.NullString
|
|
|
|
err := rows.Scan(&tb.TokenAddress, &tb.HolderAddress, &tb.Balance, &tokenID, &tb.BlockNumber, &tb.UpdatedAt)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if tokenID.Valid {
|
|
tb.TokenID = tokenID.String
|
|
}
|
|
|
|
holders = append(holders, tb)
|
|
}
|
|
|
|
return holders, nil
|
|
}
|
|
|
|
// TraceAddressToString converts a trace address to a string representation
|
|
func TraceAddressToString(addr []int) string {
|
|
if len(addr) == 0 {
|
|
return "[]"
|
|
}
|
|
strs := make([]string, len(addr))
|
|
for i, v := range addr {
|
|
strs[i] = fmt.Sprintf("%d", v)
|
|
}
|
|
return "[" + strings.Join(strs, ",") + "]"
|
|
}
|
|
|
|
// SortInternalTransactionsByTraceAddress sorts internal transactions by trace address
|
|
func SortInternalTransactionsByTraceAddress(itxs []InternalTransaction) {
|
|
sort.Slice(itxs, func(i, j int) bool {
|
|
a, b := itxs[i].TraceAddress, itxs[j].TraceAddress
|
|
minLen := len(a)
|
|
if len(b) < minLen {
|
|
minLen = len(b)
|
|
}
|
|
for k := 0; k < minLen; k++ {
|
|
if a[k] != b[k] {
|
|
return a[k] < b[k]
|
|
}
|
|
}
|
|
return len(a) < len(b)
|
|
})
|
|
}
|