diff --git a/benchmarks/perp-protocol-longevity.yml b/benchmarks/perp-protocol-longevity.yml index efedea82..f17f01b3 100644 --- a/benchmarks/perp-protocol-longevity.yml +++ b/benchmarks/perp-protocol-longevity.yml @@ -59,7 +59,7 @@ methodology: - "Incident criteria: direct theft or permanent loss of user funds via a documented smart contract vulnerability or privileged key compromise. Oracle manipulation without contract exploit is excluded. Market-structure events (liquidation cascades, large-position forced close) are excluded." - "gains.trade: launched 2021-12-01. No incidents in tracked sources as of registry date 2026-08-02. Clean streak = days since 2021-12-01." - "GMX v2: launched 2023-08-01. Incident 2025-07-09: reentrancy in vault and order-flow logic, estimated loss USD 42 000 000, source coinperps.xyz post-mortem. Clean streak = days since 2025-07-09." - - "Hyperliquid: launched 2023-11-01. No protocol exploit in tracked sources. Note: the March 2025 JellyJelly forced close was a market-structure event (large-position liquidation cascade triggering a governance emergency), not a smart contract exploit, and is excluded per criteria." + - "Hyperliquid: launched 2023-11-01. No protocol exploit in tracked sources. Note: the March 2025 JellyJelly forced close was a market-structure event, not a smart contract exploit, and is excluded per criteria. DeFiLlama enrichment filters entries with no confirmed financial loss (amount null or 0) to avoid false positives such as unrelated projects that share a name substring." - "dYdX v4: launched on the Cosmos appchain 2023-10-01. No protocol exploit on v4. dYdX v1 and v2 on Ethereum had oracle manipulation events that are excluded here (different contract, different era)." - "Lighter: launched 2023-07-01. No incidents in tracked sources." - "Paradex: launched 2023-10-01. No incidents in tracked sources." diff --git a/harnesses/evm-exec/Dockerfile b/harnesses/evm-exec/Dockerfile new file mode 100644 index 00000000..4058acc2 --- /dev/null +++ b/harnesses/evm-exec/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /app/collector ./cmd/collector +RUN CGO_ENABLED=0 go build -o /app/materializer ./cmd/materializer + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /app/collector /app/collector +COPY --from=builder /app/materializer /app/materializer +COPY migrations/ /app/migrations/ diff --git a/harnesses/evm-exec/cmd/collector/main.go b/harnesses/evm-exec/cmd/collector/main.go new file mode 100644 index 00000000..188c68cc --- /dev/null +++ b/harnesses/evm-exec/cmd/collector/main.go @@ -0,0 +1,316 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/platform" + "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/source" + "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/store" +) + +var chainRPC map[string]string + +func main() { + chainRPC = map[string]string{ + "ethereum": envOrDefault("ETH_RPC_URL", "https://eth.drpc.org"), + "bsc": bscRPC(), + "base": envOrDefault("BASE_RPC_URL", "https://base.drpc.org"), + } + + ctx := context.Background() + db, err := store.New(ctx, mustEnv("DATABASE_URL")) + if err != nil { + log.Fatalf("collector: %v", err) + } + defer db.Close() + + etherscanKey := mustEnv("ETHERSCAN_API_KEY") + + if err := bootstrap(ctx, db); err != nil { + log.Fatalf("collector: bootstrap: %v", err) + } + log.Printf("collector: ready") + + for { + for plt, chains := range platform.PlatformConfig { + for chain, cfg := range chains { + if err := collectPlatform(ctx, db, etherscanKey, plt, chain, cfg); err != nil { + log.Printf("collector: %s/%s: %v", plt, chain, err) + } + } + } + time.Sleep(10 * time.Minute) + } +} + +// bootstrap seeds cursors at (head - 30d) for each (platform, chain, asset) that has no cursor. +func bootstrap(ctx context.Context, db *store.DB) error { + for plt, chains := range platform.PlatformConfig { + for chain, cfg := range chains { + rpc := chainRPC[chain] + head, err := source.HeadBlock(ctx, rpc) + if err != nil { + return err + } + bpd := platform.BlocksPerDay[chain] + start := uint64(0) + if head > bpd*uint64(cfg.BootstrapDays) { + start = head - bpd*uint64(cfg.BootstrapDays) + } + + for _, token := range cfg.ERC20Tokens { + if err := db.SeedCursorIfAbsent(ctx, chain, plt, token, start); err != nil { + return err + } + } + if cfg.NativeEnabled { + if err := db.SeedCursorIfAbsent(ctx, chain, plt, "native", start); err != nil { + return err + } + } + log.Printf("collector: bootstrap %s/%s start=%d", plt, chain, start) + } + } + return nil +} + +func collectPlatform(ctx context.Context, db *store.DB, etherscanKey, plt, chain string, cfg platform.EVMPlatform) error { + rpc := chainRPC[chain] + toBlock, err := finalityBlock(ctx, rpc, chain) + if err != nil { + return err + } + + for _, token := range cfg.ERC20Tokens { + if err := collectERC20(ctx, db, rpc, plt, chain, cfg.FeeCollector, token, toBlock); err != nil { + log.Printf("collector: %s/%s ERC20 %s...: %v", plt, chain, token[:8], err) + } + } + + if cfg.NativeEnabled { + switch chain { + case "ethereum": + if err := collectNativeETH(ctx, db, etherscanKey, plt, chain, cfg.FeeCollector, toBlock); err != nil { + log.Printf("collector: %s/%s native ETH: %v", plt, chain, err) + } + case "bsc": + if err := collectNativeBSC(ctx, db, plt, chain, cfg.FeeCollector, toBlock); err != nil { + log.Printf("collector: %s/%s native BNB: %v", plt, chain, err) + } + } + } + return nil +} + +func collectERC20(ctx context.Context, db *store.DB, rpc, plt, chain, collector, token string, toBlock uint64) error { + cursor, _, err := db.GetCursor(ctx, chain, plt, token) + if err != nil { + return fmt.Errorf("get cursor: %w", err) + } + if cursor >= toBlock { + return nil + } + decimals := platform.TokenDecimals[token] + + transfers, lastBlock, err := source.GetERC20TransfersAdaptive(ctx, rpc, token, collector, cursor+1, toBlock) + if err != nil { + return err + } + + events := make([]store.EVMEvent, 0, len(transfers)) + for _, t := range transfers { + var bt time.Time + if !t.BlockTime.IsZero() { + bt = t.BlockTime + _ = db.CacheBlockTime(ctx, chain, t.BlockNum, bt) + } else { + var err error + bt, err = resolveBlockTime(ctx, db, rpc, chain, t.BlockNum) + if err != nil { + continue + } + } + events = append(events, store.EVMEvent{ + Chain: chain, TxHash: t.TxHash, BlockNum: t.BlockNum, BlockTime: bt, + Platform: plt, Asset: token, AmountRaw: t.Amount, + Decimals: decimals, EventKey: source.LogIndexKey(t.LogIndex), + }) + } + if err := db.UpsertEvents(ctx, events); err != nil { + return err + } + if lastBlock > cursor { + _ = db.SaveCursor(ctx, chain, plt, token, lastBlock) + } + if len(events) > 0 { + log.Printf("collector: %s/%s ERC20 %d events block=%d", plt, chain, len(events), lastBlock) + } + return nil +} + +func collectNativeETH(ctx context.Context, db *store.DB, apiKey, plt, chain, collector string, toBlock uint64) error { + cursor, _, err := db.GetCursor(ctx, chain, plt, "native") + if err != nil { + return fmt.Errorf("get cursor: %w", err) + } + + internalTxs, lastInt, err := source.GetEtherscanInternalTxs(ctx, apiKey, collector, cursor) + if err != nil { + return err + } + normalTxs, lastNorm, err := source.GetEtherscanNormalTxs(ctx, apiKey, collector, cursor) + if err != nil { + return err + } + + rpc := chainRPC[chain] + var events []store.EVMEvent + for _, tx := range append(internalTxs, normalTxs...) { + if tx.BlockNum > toBlock { + continue + } + var bt time.Time + if !tx.BlockTime.IsZero() { + bt = tx.BlockTime + _ = db.CacheBlockTime(ctx, chain, tx.BlockNum, bt) + } else { + var err error + bt, err = resolveBlockTime(ctx, db, rpc, chain, tx.BlockNum) + if err != nil { + continue + } + } + events = append(events, store.EVMEvent{ + Chain: chain, TxHash: tx.TxHash, BlockNum: tx.BlockNum, BlockTime: bt, + Platform: plt, Asset: "native", AmountRaw: tx.Amount, + Decimals: 18, EventKey: tx.EventKey, + }) + } + if err := db.UpsertEvents(ctx, events); err != nil { + return err + } + if high := max64(lastInt, lastNorm); high > cursor { + _ = db.SaveCursor(ctx, chain, plt, "native", high) + } + if len(events) > 0 { + log.Printf("collector: %s/%s native ETH %d events", plt, chain, len(events)) + } + return nil +} + +func collectNativeBSC(ctx context.Context, db *store.DB, plt, chain, collector string, toBlock uint64) error { + cursor, _, err := db.GetCursor(ctx, chain, plt, "native") + if err != nil { + return fmt.Errorf("get cursor: %w", err) + } + if cursor >= toBlock { + return nil + } + rpc := chainRPC[chain] + + transfers, err := source.GetNativeTransfers(ctx, rpc, collector, cursor+1, toBlock) + if err != nil { + return err + } + + var events []store.EVMEvent + var highBlock uint64 + for _, t := range transfers { + var bt time.Time + if !t.BlockTime.IsZero() { + bt = t.BlockTime + _ = db.CacheBlockTime(ctx, chain, t.BlockNum, bt) + } else { + var err error + bt, err = resolveBlockTime(ctx, db, rpc, chain, t.BlockNum) + if err != nil { + continue + } + } + events = append(events, store.EVMEvent{ + Chain: chain, TxHash: t.TxHash, BlockNum: t.BlockNum, BlockTime: bt, + Platform: plt, Asset: "native", AmountRaw: t.Amount, + Decimals: 18, EventKey: t.EventKey, + }) + if t.BlockNum > highBlock { + highBlock = t.BlockNum + } + } + if err := db.UpsertEvents(ctx, events); err != nil { + return err + } + if highBlock > cursor { + _ = db.SaveCursor(ctx, chain, plt, "native", highBlock) + } + if len(events) > 0 { + log.Printf("collector: %s/%s native BNB %d events block=%d", plt, chain, len(events), highBlock) + } + return nil +} + +func resolveBlockTime(ctx context.Context, db *store.DB, rpc, chain string, blockNum uint64) (time.Time, error) { + if t, ok := db.GetBlockTime(ctx, chain, blockNum); ok { + return t, nil + } + t, err := source.BlockTime(ctx, rpc, blockNum) + if err != nil { + return time.Time{}, err + } + _ = db.CacheBlockTime(ctx, chain, blockNum, t) + return t, nil +} + +func finalityBlock(ctx context.Context, rpc, chain string) (uint64, error) { + switch chain { + case "ethereum", "base": + return source.ResolveBlockTag(ctx, rpc, "finalized") + case "bsc": + head, err := source.HeadBlock(ctx, rpc) + if err != nil { + return 0, err + } + if head > 20 { + return head - 20, nil + } + return 0, nil + default: + return source.HeadBlock(ctx, rpc) + } +} + +func bscRPC() string { + if v := os.Getenv("BSC_RPC_URL"); v != "" { + return v + } + key := os.Getenv("NODEREAL_BSC_KEY") + if key == "" { + log.Fatal("collector: NODEREAL_BSC_KEY or BSC_RPC_URL is required") + } + return "https://bsc-mainnet.nodereal.io/v1/" + key +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing env: %s", key) + } + return v +} + +func max64(a, b uint64) uint64 { + if a > b { + return a + } + return b +} diff --git a/harnesses/evm-exec/cmd/materializer/main.go b/harnesses/evm-exec/cmd/materializer/main.go new file mode 100644 index 00000000..ece79c69 --- /dev/null +++ b/harnesses/evm-exec/cmd/materializer/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "log" + "os" + "time" + + "github.com/ChainBench/OpenChainBench/harnesses/evm-exec/internal/store" +) + +func main() { + ctx := context.Background() + db, err := store.New(ctx, mustEnv("DATABASE_URL")) + if err != nil { + log.Fatalf("materializer: %v", err) + } + defer db.Close() + + for { + if err := db.Materialize(ctx); err != nil { + log.Printf("materializer: %v", err) + } else { + log.Printf("materializer: done") + } + time.Sleep(5 * time.Minute) + } +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("missing env: %s", key) + } + return v +} diff --git a/harnesses/evm-exec/docker-compose.evm-exec.yml b/harnesses/evm-exec/docker-compose.evm-exec.yml new file mode 100644 index 00000000..de33a5f0 --- /dev/null +++ b/harnesses/evm-exec/docker-compose.evm-exec.yml @@ -0,0 +1,36 @@ +# Fragment to merge into /opt/ocb/docker-compose.yml on VPS. +# Requires: ocb-postgres (from docker-compose.apps.yml). +# Env: /run/ocb/.env.evm-exec +# DATABASE_URL, ETHERSCAN_API_KEY, NODEREAL_BSC_KEY +# Optional: ETH_RPC_URL (default https://eth.drpc.org) +# BASE_RPC_URL (default https://base.drpc.org) +# BSC_RPC_URL (overrides NODEREAL_BSC_KEY) + +services: + evm-exec-collector: + build: /opt/ocb/harnesses/evm-exec + container_name: ocb-evm-exec-collector + restart: unless-stopped + env_file: /run/ocb/.env.evm-exec + command: ["/app/collector"] + networks: [web] + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: { max-size: "20m", max-file: "5" } + + evm-exec-materializer: + build: /opt/ocb/harnesses/evm-exec + container_name: ocb-evm-exec-materializer + restart: unless-stopped + env_file: /run/ocb/.env.evm-exec + command: ["/app/materializer"] + networks: [web] + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: { max-size: "10m", max-file: "3" } diff --git a/harnesses/evm-exec/go.mod b/harnesses/evm-exec/go.mod new file mode 100644 index 00000000..437438a5 --- /dev/null +++ b/harnesses/evm-exec/go.mod @@ -0,0 +1,14 @@ +module github.com/ChainBench/OpenChainBench/harnesses/evm-exec + +go 1.24.0 + +require github.com/jackc/pgx/v5 v5.7.2 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/harnesses/evm-exec/go.sum b/harnesses/evm-exec/go.sum new file mode 100644 index 00000000..731b5dfd --- /dev/null +++ b/harnesses/evm-exec/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/evm-exec/internal/platform/platforms.go b/harnesses/evm-exec/internal/platform/platforms.go new file mode 100644 index 00000000..e6e318f2 --- /dev/null +++ b/harnesses/evm-exec/internal/platform/platforms.go @@ -0,0 +1,102 @@ +package platform + +import ( + "strings" +) + +// EVMPlatform describes what to monitor for one platform on one chain. +type EVMPlatform struct { + FeeCollector string // lowercase 42-char 0x address + NativeEnabled bool // collect native asset (ETH / BNB) + ERC20Tokens []string // lowercase ERC-20 contracts to monitor + BootstrapDays int // how many days back to seed cursor on first run +} + +// USDC contract addresses, lowercase. +const ( + USDC_ETH = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" // 6 decimals + USDC_BSC = "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d" // 18 decimals (Binance-Peg) + USDC_BASE = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" // 6 decimals +) + +// TokenDecimals maps known token addresses to their decimal places. +var TokenDecimals = map[string]int{ + USDC_ETH: 6, + USDC_BSC: 18, + USDC_BASE: 6, +} + +// NativeSymbol returns the native asset symbol for a chain. +func NativeSymbol(chain string) string { + if chain == "bsc" { + return "BNB" + } + return "ETH" +} + +// BlocksPerDay is the approximate number of blocks produced per day per chain. +var BlocksPerDay = map[string]uint64{ + "ethereum": 7_200, + "bsc": 28_800, + "base": 43_200, +} + +// PlatformConfig maps platform → chain → configuration. +var PlatformConfig = map[string]map[string]EVMPlatform{ + "gmgn": { + "ethereum": { + FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8", + NativeEnabled: true, + ERC20Tokens: []string{USDC_ETH}, + BootstrapDays: 30, + }, + "bsc": { + FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8", + NativeEnabled: true, + ERC20Tokens: []string{USDC_BSC}, + BootstrapDays: 30, + }, + "base": { + FeeCollector: "0xb8159ba378904f803639d274cec79f788931c9c8", + NativeEnabled: false, // no free trace API; USDC only + ERC20Tokens: []string{USDC_BASE}, + BootstrapDays: 30, + }, + }, +} + +// Coverage returns "full" if native is enabled for this platform+chain, else "stable-only". +func Coverage(platform, chain string) string { + chains, ok := PlatformConfig[platform] + if !ok { + return "stable-only" + } + cfg, ok := chains[chain] + if !ok { + return "stable-only" + } + if cfg.NativeEnabled { + return "full" + } + return "stable-only" +} + +func init() { + for plt, chains := range PlatformConfig { + for chain, cfg := range chains { + mustAddr(cfg.FeeCollector, plt+"/"+chain+"/FeeCollector") + for _, t := range cfg.ERC20Tokens { + mustAddr(t, plt+"/"+chain+"/ERC20Token") + } + } + } + for addr := range TokenDecimals { + mustAddr(addr, "TokenDecimals key") + } +} + +func mustAddr(a, label string) { + if len(a) != 42 || !strings.HasPrefix(a, "0x") || a != strings.ToLower(a) { + panic("invalid address in platform config [" + label + "]: " + a) + } +} diff --git a/harnesses/evm-exec/internal/source/blocktime.go b/harnesses/evm-exec/internal/source/blocktime.go new file mode 100644 index 00000000..83386ed5 --- /dev/null +++ b/harnesses/evm-exec/internal/source/blocktime.go @@ -0,0 +1,110 @@ +package source + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +var httpClient = &http.Client{Timeout: 20 * time.Second} + +// HeadBlock returns the current head block number via eth_blockNumber. +func HeadBlock(ctx context.Context, rpcURL string) (uint64, error) { + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "eth_blockNumber", "params": []any{}, + }) + var out struct { + Result string `json:"result"` + Error *struct{ Message string } `json:"error"` + } + if err := RpcPost(ctx, rpcURL, body, &out); err != nil { + return 0, err + } + if out.Error != nil { + return 0, fmt.Errorf("eth_blockNumber: %s", out.Error.Message) + } + return ParseHex64(out.Result) +} + +// BlockTime fetches a block's unix timestamp via eth_getBlockByNumber(n, false). +func BlockTime(ctx context.Context, rpcURL string, blockNum uint64) (time.Time, error) { + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "eth_getBlockByNumber", + "params": []any{fmt.Sprintf("0x%x", blockNum), false}, + }) + var out struct { + Result *struct { + Timestamp string `json:"timestamp"` + } `json:"result"` + Error *struct{ Message string } `json:"error"` + } + if err := RpcPost(ctx, rpcURL, body, &out); err != nil { + return time.Time{}, err + } + if out.Error != nil { + return time.Time{}, fmt.Errorf("eth_getBlockByNumber: %s", out.Error.Message) + } + if out.Result == nil { + return time.Time{}, fmt.Errorf("null block %d", blockNum) + } + ts, err := ParseHex64(out.Result.Timestamp) + if err != nil { + return time.Time{}, err + } + return time.Unix(int64(ts), 0).UTC(), nil +} + +// ResolveBlockTag resolves an eth block tag ("finalized", "latest") to a block number. +func ResolveBlockTag(ctx context.Context, rpcURL, tag string) (uint64, error) { + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "eth_getBlockByNumber", + "params": []any{tag, false}, + }) + var out struct { + Result *struct { + Number string `json:"number"` + } `json:"result"` + Error *struct{ Message string } `json:"error"` + } + if err := RpcPost(ctx, rpcURL, body, &out); err != nil { + return 0, err + } + if out.Error != nil { + return 0, fmt.Errorf("eth_getBlockByNumber %s: %s", tag, out.Error.Message) + } + if out.Result == nil { + return 0, fmt.Errorf("null block for tag %q", tag) + } + return ParseHex64(out.Result.Number) +} + +// RpcPost marshals body, POSTs to rpcURL with Content-Type: application/json, decodes into dst. +func RpcPost(ctx context.Context, rpcURL string, body []byte, dst any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d from %s", resp.StatusCode, rpcURL) + } + return json.NewDecoder(resp.Body).Decode(dst) +} + +// ParseHex64 parses a 0x-prefixed or bare hex string to uint64. +func ParseHex64(s string) (uint64, error) { + return strconv.ParseUint(strings.TrimPrefix(s, "0x"), 16, 64) +} diff --git a/harnesses/evm-exec/internal/source/etherscan.go b/harnesses/evm-exec/internal/source/etherscan.go new file mode 100644 index 00000000..feccacbe --- /dev/null +++ b/harnesses/evm-exec/internal/source/etherscan.go @@ -0,0 +1,203 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/url" + "strings" + "time" +) + +const etherscanV2 = "https://api.etherscan.io/v2/api" + +// NativeTx is an ETH transfer (internal or normal) to a monitored address. +type NativeTx struct { + TxHash string + BlockNum uint64 + BlockTime time.Time // from Etherscan timeStamp, avoids eth_getBlockByNumber + Amount *big.Int // wei, never divided + EventKey string // traceId for internal txs, "" for normal value txs +} + +type etherscanTx struct { + Hash string `json:"hash"` + BlockNumber string `json:"blockNumber"` + TimeStamp string `json:"timeStamp"` // unix seconds as decimal string + Value string `json:"value"` + To string `json:"to"` + IsError string `json:"isError"` + TraceID string `json:"traceId"` +} + +// GetEtherscanInternalTxs fetches ETH internal transfers to address starting from startBlock. +func GetEtherscanInternalTxs(ctx context.Context, apiKey, address string, startBlock uint64) ([]NativeTx, uint64, error) { + return fetchEtherscanTxs(ctx, apiKey, address, startBlock, "txlistinternal", func(tx etherscanTx) (NativeTx, bool) { + if tx.IsError == "1" || tx.Value == "0" || tx.Value == "" { + return NativeTx{}, false + } + amt, err := ParseDecAmount(tx.Value) + if err != nil || amt.Sign() <= 0 { + return NativeTx{}, false + } + blockNum, err := parseDecU64(tx.BlockNumber) + if err != nil { + return NativeTx{}, false + } + var bt time.Time + if ts, err2 := parseDecU64(tx.TimeStamp); err2 == nil && ts > 0 { + bt = time.Unix(int64(ts), 0).UTC() + } + return NativeTx{TxHash: strings.ToLower(tx.Hash), BlockNum: blockNum, BlockTime: bt, Amount: amt, EventKey: tx.TraceID}, true + }) +} + +// GetEtherscanNormalTxs fetches plain ETH value transfers to address starting from startBlock. +func GetEtherscanNormalTxs(ctx context.Context, apiKey, address string, startBlock uint64) ([]NativeTx, uint64, error) { + addrLower := strings.ToLower(address) + return fetchEtherscanTxs(ctx, apiKey, address, startBlock, "txlist", func(tx etherscanTx) (NativeTx, bool) { + if tx.IsError == "1" || tx.Value == "0" || tx.Value == "" { + return NativeTx{}, false + } + if strings.ToLower(tx.To) != addrLower { + return NativeTx{}, false + } + amt, err := ParseDecAmount(tx.Value) + if err != nil || amt.Sign() <= 0 { + return NativeTx{}, false + } + blockNum, err := parseDecU64(tx.BlockNumber) + if err != nil { + return NativeTx{}, false + } + var bt time.Time + if ts, err2 := parseDecU64(tx.TimeStamp); err2 == nil && ts > 0 { + bt = time.Unix(int64(ts), 0).UTC() + } + return NativeTx{TxHash: strings.ToLower(tx.Hash), BlockNum: blockNum, BlockTime: bt, Amount: amt, EventKey: ""}, true + }) +} + +type filterFn func(etherscanTx) (NativeTx, bool) + +// fetchEtherscanTxs handles Etherscan pagination including the 10k-result cap. +func fetchEtherscanTxs(ctx context.Context, apiKey, address string, startBlock uint64, action string, filter filterFn) ([]NativeTx, uint64, error) { + const ( + offset = 1000 + maxPage = 10 + endBlock = 99999999 + ) + + var all []NativeTx + highestBlock := startBlock + curStart := startBlock + + for { + var windowTxs []NativeTx + hitCap := false + page := 1 + + for page <= maxPage { + time.Sleep(250 * time.Millisecond) + + batch, err := etherscanPage(ctx, apiKey, address, action, curStart, endBlock, page, offset) + if err != nil { + return all, highestBlock, fmt.Errorf("etherscan %s page %d: %w", action, page, err) + } + + for _, tx := range batch { + if ntx, ok := filter(tx); ok { + windowTxs = append(windowTxs, ntx) + if ntx.BlockNum > highestBlock { + highestBlock = ntx.BlockNum + } + } + } + + if len(batch) < offset { + break // last page + } + if page == maxPage { + hitCap = true + break + } + page++ + } + + all = append(all, windowTxs...) + + if !hitCap || len(windowTxs) == 0 { + break + } + + // Advance past what we've seen and loop. + var lastBlock uint64 + for _, tx := range windowTxs { + if tx.BlockNum > lastBlock { + lastBlock = tx.BlockNum + } + } + if lastBlock <= curStart { + break + } + curStart = lastBlock + } + + return all, highestBlock, nil +} + +func etherscanPage(ctx context.Context, apiKey, address, action string, startBlock uint64, endBlock, page, offset int) ([]etherscanTx, error) { + params := url.Values{ + "chainid": {"1"}, + "module": {"account"}, + "action": {action}, + "address": {address}, + "startblock": {fmt.Sprintf("%d", startBlock)}, + "endblock": {fmt.Sprintf("%d", endBlock)}, + "page": {fmt.Sprintf("%d", page)}, + "offset": {fmt.Sprintf("%d", offset)}, + "sort": {"asc"}, + "apikey": {apiKey}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, etherscanV2+"?"+params.Encode(), nil) + if err != nil { + return nil, err + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var out struct { + Status string `json:"status"` + Message string `json:"message"` + Result json.RawMessage `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("etherscan decode: %w", err) + } + if out.Status == "0" && strings.Contains(out.Message, "No transactions") { + return nil, nil + } + if out.Status != "1" { + var msg string + json.Unmarshal(out.Result, &msg) //nolint:errcheck + return nil, fmt.Errorf("etherscan %s: %s %s", action, out.Message, msg) + } + + var txs []etherscanTx + if err := json.Unmarshal(out.Result, &txs); err != nil { + return nil, fmt.Errorf("etherscan result decode: %w", err) + } + return txs, nil +} + +func parseDecU64(s string) (uint64, error) { + var n uint64 + _, err := fmt.Sscan(s, &n) + return n, err +} diff --git a/harnesses/evm-exec/internal/source/logs.go b/harnesses/evm-exec/internal/source/logs.go new file mode 100644 index 00000000..c1e1f95f --- /dev/null +++ b/harnesses/evm-exec/internal/source/logs.go @@ -0,0 +1,176 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strconv" + "strings" + "time" +) + +const transferTopic0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + +// ERC20Transfer is one token Transfer event emitted to a recipient. +type ERC20Transfer struct { + TxHash string + BlockNum uint64 + LogIndex uint64 + BlockTime time.Time // from blockTimestamp in getLogs response; zero if not present + Amount *big.Int // raw token units (never divided in Go) +} + +// RPCError is a JSON-RPC level error; used to detect range-too-large codes. +type RPCError struct { + Code int + Message string +} + +func (e *RPCError) Error() string { return fmt.Sprintf("RPC %d: %s", e.Code, e.Message) } + +// IsRangeError returns true when the RPC error means the block range is too wide. +func IsRangeError(err error) bool { + rpcErr, ok := err.(*RPCError) + if !ok { + return false + } + // -32005: range too large (Alchemy/Infura), -32602: invalid params (geth), + // -32614: range limit exceeded (base mainnet.base.org) + return rpcErr.Code == -32005 || rpcErr.Code == -32602 || rpcErr.Code == -32614 +} + +// GetERC20Transfers fetches Transfer events from [fromBlock, toBlock] for one window. +func GetERC20Transfers(ctx context.Context, rpcURL, tokenContract, recipient string, fromBlock, toBlock uint64) ([]ERC20Transfer, error) { + // topic2: 32-byte left-padded address (12 zero bytes + 20 addr bytes). + topic2 := "0x" + strings.Repeat("0", 24) + strings.TrimPrefix(strings.ToLower(recipient), "0x") + + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "eth_getLogs", + "params": []any{map[string]any{ + "fromBlock": fmt.Sprintf("0x%x", fromBlock), + "toBlock": fmt.Sprintf("0x%x", toBlock), + "address": tokenContract, + "topics": []any{transferTopic0, nil, topic2}, + }}, + }) + + var out struct { + Result []struct { + TxHash string `json:"transactionHash"` + BlockNumber string `json:"blockNumber"` + BlockTimestamp string `json:"blockTimestamp"` // hex unix seconds; present on Base, absent on ETH/BSC + LogIndex string `json:"logIndex"` + Data string `json:"data"` + Topics []string `json:"topics"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := RpcPost(ctx, rpcURL, body, &out); err != nil { + return nil, fmt.Errorf("eth_getLogs: %w", err) + } + if out.Error != nil { + return nil, &RPCError{Code: out.Error.Code, Message: out.Error.Message} + } + + var transfers []ERC20Transfer + for _, log := range out.Result { + if len(log.Data) < 2 || len(log.Topics) < 3 { + continue + } + blockNum, err := ParseHex64(log.BlockNumber) + if err != nil { + continue + } + logIdx, err := ParseHex64(log.LogIndex) + if err != nil { + continue + } + amount, ok := new(big.Int).SetString(strings.TrimPrefix(log.Data, "0x"), 16) + if !ok || amount.Sign() <= 0 { + continue + } + var bt time.Time + if ts, err2 := ParseHex64(log.BlockTimestamp); err2 == nil && ts > 0 { + bt = time.Unix(int64(ts), 0).UTC() + } + transfers = append(transfers, ERC20Transfer{ + TxHash: strings.ToLower(log.TxHash), + BlockNum: blockNum, + LogIndex: logIdx, + BlockTime: bt, + Amount: amount, + }) + } + return transfers, nil +} + +// GetERC20TransfersAdaptive fetches all ERC-20 transfers from fromBlock to toBlock +// using adaptive range sizing (halves on range error, grows on success). +func GetERC20TransfersAdaptive(ctx context.Context, rpcURL, tokenContract, recipient string, fromBlock, toBlock uint64) ([]ERC20Transfer, uint64, error) { + const maxRange = uint64(10_000) + rangeSize := maxRange + var all []ERC20Transfer + cur := fromBlock + + for cur <= toBlock { + end := cur + rangeSize - 1 + if end > toBlock { + end = toBlock + } + + batch, err := GetERC20Transfers(ctx, rpcURL, tokenContract, recipient, cur, end) + if err != nil { + if IsRangeError(err) && rangeSize > 100 { + rangeSize /= 2 + continue + } + select { + case <-ctx.Done(): + return all, cur - 1, ctx.Err() + case <-time.After(3 * time.Second): + } + batch, err = GetERC20Transfers(ctx, rpcURL, tokenContract, recipient, cur, end) + if err != nil { + return all, cur - 1, fmt.Errorf("eth_getLogs [%d,%d]: %w", cur, end, err) + } + } + + all = append(all, batch...) + if rangeSize < maxRange { + rangeSize = rangeSize * 125 / 100 + if rangeSize > maxRange { + rangeSize = maxRange + } + } + cur = end + 1 + } + return all, toBlock, nil +} + +// ParseDecAmount parses a decimal-string wei amount (Etherscan format) to *big.Int. +func ParseDecAmount(s string) (*big.Int, error) { + v, ok := new(big.Int).SetString(s, 10) + if !ok { + return nil, fmt.Errorf("bad decimal amount: %q", s) + } + return v, nil +} + +// ParseHexAmount parses a 0x-prefixed hex wei amount (NodeReal format) to *big.Int. +func ParseHexAmount(s string) (*big.Int, error) { + v, ok := new(big.Int).SetString(strings.TrimPrefix(s, "0x"), 16) + if !ok { + return nil, fmt.Errorf("bad hex amount: %q", s) + } + return v, nil +} + +// LogIndexKey returns the event_key string for an ERC-20 log. +func LogIndexKey(logIndex uint64) string { + return strconv.FormatUint(logIndex, 10) +} diff --git a/harnesses/evm-exec/internal/source/nodereal.go b/harnesses/evm-exec/internal/source/nodereal.go new file mode 100644 index 00000000..466a7429 --- /dev/null +++ b/harnesses/evm-exec/internal/source/nodereal.go @@ -0,0 +1,133 @@ +package source + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "strings" + "time" +) + +// AssetTransfer is one native-asset (BNB/ETH) transfer from NodeReal nr_getAssetTransfers. +type AssetTransfer struct { + TxHash string + BlockNum uint64 + BlockTime time.Time // from NodeReal blockTimeStamp (avoids separate RPC call) + Amount *big.Int // wei, never divided + EventKey string // position index within (tx_hash) group for uniqueness +} + +type nrTransfer struct { + BlockNum string `json:"blockNum"` + Hash string `json:"hash"` + Value string `json:"value"` + Category string `json:"category"` + BlockTimeStamp int64 `json:"blockTimeStamp"` // unix seconds, present when withMetadata:true +} + +// GetNativeTransfers fetches all native asset (BNB) transfers to toAddress in [fromBlock, toBlock] +// using NodeReal nr_getAssetTransfers with full pagination via pageKey. +func GetNativeTransfers(ctx context.Context, rpcURL, toAddress string, fromBlock, toBlock uint64) ([]AssetTransfer, error) { + var allRaw []nrTransfer + pageKey := "" + + for { + params := map[string]any{ + "fromBlock": fmt.Sprintf("0x%x", fromBlock), + "toBlock": fmt.Sprintf("0x%x", toBlock), + "toAddress": toAddress, + "category": []string{"external", "internal"}, + "withMetadata": true, + "excludeZeroValue": true, + "maxCount": "0x3e8", // 1000 + } + if pageKey != "" { + params["pageKey"] = pageKey + } + + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "nr_getAssetTransfers", + "params": []any{params}, + }) + + var out struct { + Result *struct { + Transfers []nrTransfer `json:"transfers"` + PageKey string `json:"pageKey"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + + var lastErr error + for attempt := range 3 { + if attempt > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt*attempt) * time.Second): + } + } + if err := RpcPost(ctx, rpcURL, body, &out); err != nil { + lastErr = err + continue + } + lastErr = nil + break + } + if lastErr != nil { + return nil, fmt.Errorf("nr_getAssetTransfers: %w", lastErr) + } + if out.Error != nil { + return nil, fmt.Errorf("nr_getAssetTransfers RPC %d: %s", out.Error.Code, out.Error.Message) + } + if out.Result == nil { + return nil, fmt.Errorf("nr_getAssetTransfers: null result") + } + + allRaw = append(allRaw, out.Result.Transfers...) + pageKey = out.Result.PageKey + if pageKey == "" { + break + } + } + + // Assign event_key as position within each tx_hash group (handles multicall). + txCount := make(map[string]int, len(allRaw)) + result := make([]AssetTransfer, 0, len(allRaw)) + + for _, t := range allRaw { + if t.Value == "" || t.Value == "0x0" { + continue + } + amt, err := ParseHexAmount(t.Value) + if err != nil || amt.Sign() <= 0 { + continue + } + blockNum, err := ParseHex64(t.BlockNum) + if err != nil { + continue + } + hash := strings.ToLower(t.Hash) + idx := txCount[hash] + txCount[hash]++ + + var blockTime time.Time + if t.BlockTimeStamp > 0 { + blockTime = time.Unix(t.BlockTimeStamp, 0).UTC() + } + + result = append(result, AssetTransfer{ + TxHash: hash, + BlockNum: blockNum, + BlockTime: blockTime, + Amount: amt, + EventKey: fmt.Sprintf("%d", idx), + }) + } + return result, nil +} diff --git a/harnesses/evm-exec/internal/store/store.go b/harnesses/evm-exec/internal/store/store.go new file mode 100644 index 00000000..bdec87eb --- /dev/null +++ b/harnesses/evm-exec/internal/store/store.go @@ -0,0 +1,199 @@ +package store + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + pool *pgxpool.Pool +} + +func New(ctx context.Context, connStr string) (*DB, error) { + pool, err := pgxpool.New(ctx, connStr) + if err != nil { + return nil, fmt.Errorf("store: connect: %w", err) + } + return &DB{pool: pool}, nil +} + +func (db *DB) Close() { db.pool.Close() } + +// EVMEvent is one fee inflow to a platform's fee collector. +type EVMEvent struct { + Chain string + TxHash string + BlockNum uint64 + BlockTime time.Time + Platform string + Asset string // "native" or token contract (lowercase) + AmountRaw *big.Int // wei or raw token units; never divided in Go + Decimals int + EventKey string // log_index / traceId / transfer index / "" +} + +// UpsertEvent inserts an EVM fee event; skips silently on (chain,tx_hash,asset,event_key) conflict. +func (db *DB) UpsertEvent(ctx context.Context, e EVMEvent) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO evm_exec_events + (chain, tx_hash, block_num, block_time, platform, asset, amount_raw, decimals, event_key) + VALUES ($1,$2,$3,$4,$5,$6,$7::NUMERIC,$8,$9) + ON CONFLICT (chain, tx_hash, asset, event_key) DO NOTHING`, + e.Chain, e.TxHash, e.BlockNum, e.BlockTime, + e.Platform, e.Asset, e.AmountRaw.String(), e.Decimals, e.EventKey, + ) + if err != nil { + return fmt.Errorf("store: upsert %s/%s: %w", e.Chain, e.TxHash, err) + } + return nil +} + +// UpsertEvents bulk-upserts in a single transaction. +func (db *DB) UpsertEvents(ctx context.Context, events []EVMEvent) error { + if len(events) == 0 { + return nil + } + tx, err := db.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("store: begin: %w", err) + } + defer tx.Rollback(ctx) + + for _, e := range events { + _, err := tx.Exec(ctx, ` + INSERT INTO evm_exec_events + (chain, tx_hash, block_num, block_time, platform, asset, amount_raw, decimals, event_key) + VALUES ($1,$2,$3,$4,$5,$6,$7::NUMERIC,$8,$9) + ON CONFLICT (chain, tx_hash, asset, event_key) DO NOTHING`, + e.Chain, e.TxHash, e.BlockNum, e.BlockTime, + e.Platform, e.Asset, e.AmountRaw.String(), e.Decimals, e.EventKey, + ) + if err != nil { + return fmt.Errorf("store: upsert %s/%s: %w", e.Chain, e.TxHash, err) + } + } + return tx.Commit(ctx) +} + +// GetCursor returns the last processed block for (chain, platform, asset). +// Returns (0, false, nil) when no cursor exists yet. +func (db *DB) GetCursor(ctx context.Context, chain, platform, asset string) (uint64, bool, error) { + var lastBlock uint64 + err := db.pool.QueryRow(ctx, + `SELECT last_block FROM evm_exec_cursors WHERE chain=$1 AND platform=$2 AND asset=$3`, + chain, platform, asset, + ).Scan(&lastBlock) + if err != nil { + return 0, false, nil + } + return lastBlock, true, nil +} + +// SaveCursor upserts the last processed block. +func (db *DB) SaveCursor(ctx context.Context, chain, platform, asset string, lastBlock uint64) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO evm_exec_cursors (chain, platform, asset, last_block, updated_at) + VALUES ($1,$2,$3,$4,now()) + ON CONFLICT (chain, platform, asset) DO UPDATE SET + last_block = EXCLUDED.last_block, updated_at = now()`, + chain, platform, asset, lastBlock, + ) + return err +} + +// SeedCursorIfAbsent inserts a bootstrap cursor only when none exists (DO NOTHING on conflict). +func (db *DB) SeedCursorIfAbsent(ctx context.Context, chain, platform, asset string, bootstrapBlock uint64) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO evm_exec_cursors (chain, platform, asset, last_block, updated_at) + VALUES ($1,$2,$3,$4,now()) + ON CONFLICT (chain, platform, asset) DO NOTHING`, + chain, platform, asset, bootstrapBlock, + ) + return err +} + +// GetBlockTime returns a cached block timestamp, if present. +func (db *DB) GetBlockTime(ctx context.Context, chain string, blockNum uint64) (time.Time, bool) { + var t time.Time + err := db.pool.QueryRow(ctx, + `SELECT block_time FROM evm_block_times WHERE chain=$1 AND block_num=$2`, + chain, blockNum, + ).Scan(&t) + return t, err == nil +} + +// CacheBlockTime writes a block timestamp to the local cache table. +func (db *DB) CacheBlockTime(ctx context.Context, chain string, blockNum uint64, t time.Time) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO evm_block_times (chain, block_num, block_time) + VALUES ($1,$2,$3) + ON CONFLICT (chain, block_num) DO NOTHING`, + chain, blockNum, t, + ) + return err +} + +// Materialize recomputes hourly revenue facts from the last 48h of events. +func (db *DB) Materialize(ctx context.Context) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO evm_exec_facts + (chain, platform, bucket_start, revenue_stable, revenue_native, native_symbol, updated_at) + SELECT + chain, + platform, + date_trunc('hour', block_time) AS bucket_start, + COALESCE(SUM(amount_raw / POW(10, decimals)) FILTER (WHERE asset <> 'native'), 0), + COALESCE(SUM(amount_raw / POW(10, decimals)) FILTER (WHERE asset = 'native'), 0), + CASE WHEN chain = 'bsc' THEN 'BNB' ELSE 'ETH' END, + now() + FROM evm_exec_events + WHERE block_time > now() - INTERVAL '48 hours' + GROUP BY 1, 2, 3 + ON CONFLICT (chain, platform, bucket_start) DO UPDATE SET + revenue_stable = EXCLUDED.revenue_stable, + revenue_native = EXCLUDED.revenue_native, + native_symbol = EXCLUDED.native_symbol, + updated_at = now()`, + ) + return err +} + +// RevenueRow is one (chain, platform) 24h revenue aggregate. +type RevenueRow struct { + Chain string + Platform string + RevenueStable float64 + RevenueNative float64 + NativeSymbol string +} + +// GetRevenue24h returns aggregated revenue for the last 24h per (chain, platform). +func (db *DB) GetRevenue24h(ctx context.Context) ([]RevenueRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT chain, platform, + COALESCE(SUM(revenue_stable), 0), + COALESCE(SUM(revenue_native), 0), + COALESCE(MAX(native_symbol), '') + FROM evm_exec_facts + WHERE bucket_start >= now() - INTERVAL '24 hours' + GROUP BY chain, platform`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []RevenueRow + for rows.Next() { + var r RevenueRow + if err := rows.Scan(&r.Chain, &r.Platform, &r.RevenueStable, &r.RevenueNative, &r.NativeSymbol); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/harnesses/evm-exec/migrations/004_evm_exec.sql b/harnesses/evm-exec/migrations/004_evm_exec.sql new file mode 100644 index 00000000..ac7c879c --- /dev/null +++ b/harnesses/evm-exec/migrations/004_evm_exec.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS evm_exec_events ( + id BIGSERIAL PRIMARY KEY, + chain TEXT NOT NULL, + tx_hash TEXT NOT NULL, + block_num BIGINT NOT NULL, + block_time TIMESTAMPTZ NOT NULL, + platform TEXT NOT NULL, + asset TEXT NOT NULL, + amount_raw NUMERIC(78,0) NOT NULL, + decimals INT NOT NULL, + -- intra-tx discriminator: log_index for ERC-20, traceId for Etherscan internal, + -- position index for NodeReal transfers, "" for plain value txs. + event_key TEXT NOT NULL, + UNIQUE (chain, tx_hash, asset, event_key) +); + +CREATE INDEX IF NOT EXISTS idx_evm_events_lookup + ON evm_exec_events (platform, chain, block_time DESC); + +CREATE TABLE IF NOT EXISTS evm_block_times ( + chain TEXT NOT NULL, + block_num BIGINT NOT NULL, + block_time TIMESTAMPTZ NOT NULL, + PRIMARY KEY (chain, block_num) +); + +-- No DEFAULT on last_block: the collector seeds from bootstrap on first run. +CREATE TABLE IF NOT EXISTS evm_exec_cursors ( + chain TEXT NOT NULL, + platform TEXT NOT NULL, + asset TEXT NOT NULL, + last_block BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (chain, platform, asset) +); + +CREATE TABLE IF NOT EXISTS evm_exec_facts ( + chain TEXT NOT NULL, + platform TEXT NOT NULL, + bucket_start TIMESTAMPTZ NOT NULL, + revenue_stable NUMERIC NOT NULL DEFAULT 0, + revenue_native NUMERIC NOT NULL DEFAULT 0, + native_symbol TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (chain, platform, bucket_start) +); diff --git a/harnesses/solana-exec/cmd/api/main.go b/harnesses/solana-exec/cmd/api/main.go index 491b702e..aa7f84cb 100644 --- a/harnesses/solana-exec/cmd/api/main.go +++ b/harnesses/solana-exec/cmd/api/main.go @@ -3,10 +3,12 @@ package main import ( "context" "encoding/json" + "io" "log" "net/http" "os" "sort" + "sync" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -26,6 +28,7 @@ func main() { w.Write([]byte(`{"status":"ok"}`)) }) mux.HandleFunc("/api/exec-leaderboard", corsJSON(handleExecLeaderboard(pool))) + mux.HandleFunc("/api/evm-revenue", corsJSON(handleEVMRevenue(pool))) addr := ":2116" log.Printf("exec-api listening on %s", addr) @@ -219,3 +222,178 @@ func mustEnv(key string) string { } return v } + +// ---- EVM revenue endpoint ---- + +// evmCoverage is the static coverage map derived from the evm-exec platform config. +// "full" = native + stable; "stable-only" = only ERC-20 USDC tracked. +var evmCoverage = map[string]map[string]string{ + "gmgn": {"ethereum": "full", "bsc": "full", "base": "stable-only"}, +} + +// coinGeckoIDs maps chain name → CoinGecko asset ID for native price lookup. +var coinGeckoIDs = map[string]string{ + "ethereum": "ethereum", + "bsc": "binancecoin", + "base": "ethereum", +} + +// nativeSymbols maps chain → native asset ticker. +var nativeSymbols = map[string]string{ + "ethereum": "ETH", + "bsc": "BNB", + "base": "ETH", +} + +type priceCache struct { + mu sync.Mutex + prices map[string]float64 // coingecko id → USD price + fetchedAt time.Time +} + +var globalPrices = &priceCache{prices: make(map[string]float64)} + +func (p *priceCache) get(ctx context.Context) map[string]float64 { + p.mu.Lock() + defer p.mu.Unlock() + if time.Since(p.fetchedAt) < 15*time.Minute && len(p.prices) > 0 { + out := make(map[string]float64, len(p.prices)) + for k, v := range p.prices { + out[k] = v + } + return out + } + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, + "https://api.coingecko.com/api/v3/simple/price?ids=ethereum,binancecoin&vs_currencies=usd", nil) + if err != nil { + return nil + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var raw map[string]map[string]float64 + if err := json.Unmarshal(body, &raw); err != nil { + return nil + } + for id, vs := range raw { + if usd, ok := vs["usd"]; ok { + p.prices[id] = usd + } + } + p.fetchedAt = time.Now() + out := make(map[string]float64, len(p.prices)) + for k, v := range p.prices { + out[k] = v + } + return out +} + +type NativeRevenue struct { + Symbol string `json:"symbol"` + Amount float64 `json:"amount"` + USD *float64 `json:"usd"` +} + +type EVMChainRevenue struct { + Stable24h float64 `json:"stable24h"` + Native *NativeRevenue `json:"native"` + Coverage string `json:"coverage"` +} + +type EVMPlatformRow struct { + Platform string `json:"platform"` + Chains map[string]EVMChainRevenue `json:"chains"` +} + +type EVMRevenueResponse struct { + UpdatedAt string `json:"updatedAt"` + Platforms []EVMPlatformRow `json:"platforms"` +} + +func handleEVMRevenue(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + rows, err := pool.Query(ctx, ` + SELECT chain, platform, + COALESCE(SUM(revenue_stable), 0), + COALESCE(SUM(revenue_native), 0), + COALESCE(MAX(native_symbol), '') + FROM evm_exec_facts + WHERE bucket_start >= now() - INTERVAL '24 hours' + GROUP BY chain, platform`) + if err != nil { + log.Printf("evm-revenue: query: %v", err) + http.Error(w, "internal", http.StatusInternalServerError) + return + } + defer rows.Close() + + prices := globalPrices.get(r.Context()) + + // Group by platform. + byPlatform := make(map[string]map[string]EVMChainRevenue) + for rows.Next() { + var chain, plt, nativeSym string + var stable, native float64 + if err := rows.Scan(&chain, &plt, &stable, &native, &nativeSym); err != nil { + continue + } + if byPlatform[plt] == nil { + byPlatform[plt] = make(map[string]EVMChainRevenue) + } + + coverage := "stable-only" + if m, ok := evmCoverage[plt]; ok { + if c, ok := m[chain]; ok { + coverage = c + } + } + + var nat *NativeRevenue + if native > 0 || coverage == "full" { + sym := nativeSym + if sym == "" { + sym = nativeSymbols[chain] + } + nr := &NativeRevenue{Symbol: sym, Amount: native} + if cgID := coinGeckoIDs[chain]; cgID != "" && prices != nil { + if price, ok := prices[cgID]; ok { + usd := native * price + nr.USD = &usd + } + } + nat = nr + } + + byPlatform[plt][chain] = EVMChainRevenue{ + Stable24h: stable, + Native: nat, + Coverage: coverage, + } + } + if err := rows.Err(); err != nil { + log.Printf("evm-revenue: rows: %v", err) + } + + var platforms []EVMPlatformRow + for plt, chains := range byPlatform { + platforms = append(platforms, EVMPlatformRow{Platform: plt, Chains: chains}) + } + sort.Slice(platforms, func(i, j int) bool { + return platforms[i].Platform < platforms[j].Platform + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(EVMRevenueResponse{ + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Platforms: platforms, + }) + } +} diff --git a/src/app/apps/exec/page.tsx b/src/app/apps/exec/page.tsx index 378bb644..02ef9486 100644 --- a/src/app/apps/exec/page.tsx +++ b/src/app/apps/exec/page.tsx @@ -3,28 +3,33 @@ import { pageMetadata } from "@/lib/page-metadata"; import { safeJsonLd, buildBreadcrumbJsonLd } from "@/lib/jsonld"; import { SITE } from "@/data/site"; import { fetchExecLeaderboard } from "@/lib/solana-exec"; -import { SolanaExecTable } from "@/components/solana-exec-table"; +import { fetchEVMRevenue } from "@/lib/evm-exec"; +import { RevenueSummary } from "@/components/revenue-summary"; +import { ExecChainTabs } from "@/components/exec-chain-tabs"; const DESCRIPTION = "Solana trading platform execution quality: priority fees, Jito bundle rates, and platform fees — measured passively from on-chain data. Updated every hour."; export const metadata: Metadata = pageMetadata({ path: "/apps/exec", - title: "Solana Execution Quality — pump.fun, FOMO, Axiom, GMGN | OpenChainBench", + title: "Execution Quality — pump.fun, FOMO, Axiom, GMGN | OpenChainBench", description: DESCRIPTION, }); export const revalidate = 60; -export default async function SolanaExecPage() { - const data = await fetchExecLeaderboard(); +export default async function ExecPage() { + const [solanaData, evmData] = await Promise.all([ + fetchExecLeaderboard(), + fetchEVMRevenue(), + ]); const breadcrumb = { "@context": "https://schema.org", ...buildBreadcrumbJsonLd([ { name: "Home", item: SITE.url }, { name: "Apps", item: `${SITE.url}/apps` }, - { name: "Solana Execution", item: `${SITE.url}/apps/exec` }, + { name: "Execution", item: `${SITE.url}/apps/exec` }, ]), }; @@ -37,17 +42,20 @@ export default async function SolanaExecPage() { />
{DESCRIPTION}
+ {evmData && evmData.platforms.length > 0 && ( +Platform fee = average SOL transferred to the platform's fee account - per transaction. For pump.fun this is the 1% AMM fee recipient. + per transaction.
- Source: Helius enhanced transaction API. Passive monitoring via fee-account attribution - — no synthetic trades, no on-chain footprint. + Source: standard Solana JSON-RPC. Passive monitoring via fee-account attribution — + no synthetic trades, no on-chain footprint.
| # | +Platform | +Revenue 24h | +USDC | +Native | +Coverage | +
|---|---|---|---|---|---|
| + {i + 1} + | ++ {PLATFORM_DISPLAY[row.platform] ?? row.platform} + | ++ {fmtUSD(total)} + | ++ {data.stable24h > 0 ? fmtUSD(data.stable24h) : —} + | ++ {data.native + ? `${data.native.amount.toFixed(4)} ${data.native.symbol}` + : —} + | ++ + {data.coverage === "full" ? "full" : "USDC only"} + + | +
| + Platform + | + {EVM_CHAINS.map((chain) => ( ++ {CHAIN_LABELS[chain]} + | + ))} ++ Total + | +
|---|---|---|
|
+
+ {logo && (
+
+ |
+ {EVM_CHAINS.map((chain) => (
+ + {fmtUSD(total)} + | +
+ ° USDC only — native ETH not tracked on Base (no free trace API). + Solana revenue column pending exact measurement. +
+