Skip to content

feat: EVM internal transfer indexing via debug_traceTransaction#73

Merged
anhthii merged 17 commits intomainfrom
feat/evm-internal-transfer-tracing
Mar 19, 2026
Merged

feat: EVM internal transfer indexing via debug_traceTransaction#73
anhthii merged 17 commits intomainfrom
feat/evm-internal-transfer-tracing

Conversation

@DNK90
Copy link
Collaborator

@DNK90 DNK90 commented Mar 19, 2026

Closes #71

Summary

Add debug_traceTransaction support to detect all internal native transfers (contract-to-contract ETH, router hops, CREATE with value, etc.). Falls back to existing Safe heuristic when disabled.

TODO Checklist

Phase 1: Config + Types + RPC

  • 1.1 Add DebugTrace + TraceThrottle to ChainConfig, DebugTrace to NodeConfig
  • 1.2 Add CallTrace struct to types.go
  • 1.3 Add DebugTraceTransaction to EthereumAPI interface
  • 1.4 Implement DebugTraceTransaction in client.go
  • 1.5 Update config.example.yaml with per-node debug_trace, trace_throttle (rps/burst/concurrency), and cost documentation
  • 1.6 Build trace failover with separate Provider instances + dedicated rate limiter in factory.go
  • 1.7 Add traceFailover field + traceModeActive() method to EVMIndexer

Phase 2: Trace Extraction

  • 2.1 Create trace.goExtractInternalTransfers + walkCallTrace (CALL + CREATE/CREATE2, use native_transfer type)

Phase 3: Indexer Integration

  • 3.1 Export CalcFee, update call sites in tx.go and safe.go
  • 3.2 Bypass NeedReceipt gate (using traceModeActive) + handle pubkeyStore==nil contract-call filter + skip ERC20 prequery + fetch contract call receipts + dedup tx hashes
  • 3.3 Add fetchTraces + traceWithProviderAwareness (single-attempt, AnalyzeAndHandleError, exhaust all providers)
  • 3.3a Add AnalyzeAndHandleError + RecordSuccess + HandleCapabilityError to Failover
  • 3.4 Add traces param to buildBlockResults + convertBlock
  • 3.5 Add trace lookup + fallback + cross-source DedupTransfers in convertBlock

Phase 4: Deduplication

  • 4.0 Verify dedup (new DedupTransfers call in convertBlock, no change to DedupTransfers itself)

Phase 5: Fallback + Provider Safety

  • 5.0 Verify fallback + provider safety (traceFailover isolated, single-attempt, AnalyzeAndHandleError, dedicated rate limiter)

Verification — Unit Tests (22 tests, all pass)

  • ExtractInternalTransfers — 12 tests: CALL/CREATE/CREATE2/DELEGATECALL/STATICCALL/reverted/nil/dedup/type
  • Regression: debug_trace: false — Safe heuristic unchanged
  • Trace overrides Safe heuristic, dedup removes overlap
  • Receipt gate backward compatible when traceActive=false
  • Contract calls included when traceActive=true
  • No duplicate tx hashes (appendHash dedup)
  • Full-chain mode (pubkeyStore==nil) filters to contract calls only
  • traceModeActive() false when traceFailover nil or debug_trace disabled
  • Nil traces map passes through without panic

Verification — Integration Tests (10 tests, all pass)

  • DebugTraceTransaction returns valid CallTrace from Tenderly Sepolia (skip in -short)
  • Real Safe tx internal transfer extraction with correct from/to/amount (skip in -short)
  • Capability detection: public node rejects debug_traceTransaction (skip in -short)
  • Provider isolation: trace blacklist doesn't affect main failover pool
  • Rate limiter isolation: verified by scoped key design
  • End-to-end: block → receipt → trace → convertBlock → verify transfers (skip in -short)
  • ERC20 prequery skip logic
  • Runtime trace pool exhaustion: traceModeActive() collapses, receipts return to normal
  • Transient error resilience: first provider fails, second succeeds (mock HTTP)
  • Capability error -32601 triggers 24h blacklist (mock HTTP)

Implementation Plan

See implementation plan comment on the issue.

DNK90 added 10 commits March 19, 2026 11:10
…tion

- Add DebugTrace + TraceThrottle config (chain + per-node level)
- Add CallTrace struct and DebugTraceTransaction RPC call
- Build isolated trace failover pool with dedicated rate limiter
- Add traceModeActive() per-batch check for runtime provider availability
- Create trace.go with ExtractInternalTransfers + walkCallTrace
- Export CalcFee, add fetchTraces with bounded concurrency
- Add traceWithProviderAwareness (single-attempt, provider shuffling,
  capability error detection with -32601/string patterns)
- Add AnalyzeAndHandleError, RecordSuccess, HandleCapabilityError to Failover
- Bypass NeedReceipt gate when trace active, handle pubkeyStore==nil filtering
- Skip redundant ERC20 prequery when trace mode covers all contract calls
- Add cross-source DedupTransfers in convertBlock
- Dedup tx hashes in extractReceiptTxHashes via appendHash helper

Closes #71
trace_test.go (12 tests):
- Nested CALLs with value extraction
- Root call dedup for plain native transfers
- Root NOT skipped for contract calls with value
- CREATE/CREATE2 with value extracted
- DELEGATECALL/STATICCALL skipped
- Reverted calls and reverted parent subtree skipped
- Nil trace handling
- DedupTransfers across trace + ExtractTransfers
- All transfers use native_transfer type

evm_test.go (10 tests):
- Safe heuristic unchanged when traces=nil (regression)
- Trace overrides Safe heuristic, dedup removes overlap
- extractReceiptTxHashes backward compatible when traceActive=false
- Contract calls included when traceActive=true
- No duplicate tx hashes in receipt map
- pubkeyStore==nil + traceActive filters to contract calls only
- traceModeActive() returns false when traceFailover nil
- traceModeActive() returns false when debug_trace disabled
- Nil traces map passes through without panic
trace_integration_test.go (RPC-level, skip in -short):
- DebugTraceTransaction returns valid CallTrace from Tenderly
- ExtractInternalTransfers on real Safe tx extracts 0.1 ETH transfer
- Capability detection: public node rejects debug_traceTransaction
- Provider isolation: trace blacklist doesn't affect main pool
- Rate limiter isolation: verified by scoped key design

evm_trace_integration_test.go (indexer-level):
- End-to-end: block → receipt → trace → convertBlock → verify transfers
- ERC20 prequery skip logic when traceActive=true
- Runtime trace pool exhaustion: traceModeActive() collapses to false
- Transient error resilience: first provider fails, second succeeds
- Capability error blacklist: -32601 triggers 24h blacklist
Add test for tx 0x3659bdb7... (Revolut batch sendNative, 11 internal
ETH transfers) — the key test case proving trace catches transfers
that Safe heuristic cannot detect.

- TestExtractInternalTransfers_BatchSendNative_Integration: verifies
  trace extracts 11+ internal transfers, Safe heuristic finds nothing
- TestEndToEnd_BatchSendNative_Integration: full pipeline comparison
  with vs without trace — proves trace detects more transfers
- Fix TestTransientErrorResilience: use single server with sequential
  fail/success to avoid shuffle ordering issues
@DNK90
Copy link
Collaborator Author

DNK90 commented Mar 19, 2026

Integration Test Results — All Pass

go test ./internal/rpc/evm/ ./internal/indexer/ -v -run "Integration" -count=1

RPC-level tests (trace_integration_test.go)

Test Result Details
TestDebugTraceTransaction_Integration PASS Valid CallTrace returned from mainnet Safe tx
TestExtractInternalTransfers_RealSafeTx_Integration PASS 1 internal transfer: 0.1 ETH from Safe → recipient
TestExtractInternalTransfers_BatchSendNative_Integration PASS 12 internal transfers extracted from batch tx (issue #71 sample)
TestCapabilityDetection_Integration PASS Public node returns -32601: method does not exist
TestProviderIsolation_Integration PASS Trace blacklist doesn't affect main pool
TestRateLimiterIsolation_Integration PASS Scoped key verified

Indexer-level tests (evm_trace_integration_test.go)

Test Result Details
TestEndToEnd_TraceInConvertBlock_Integration PASS Full pipeline: receipt + trace → convertBlock → 1 native_transfer (Safe tx)
TestEndToEnd_BatchSendNative_Integration PASS With trace: 12 native transfers, Without trace: 0 — proves trace catches what Safe heuristic misses

Key result: Batch sendNative (issue #71 sample tx)

Tx 0x3659bdb7... — Revolut batch ETH distribution with 11 recipients:

Extracted 12 internal transfers from batch tx:
  [0]  from=0x2b3FeD49...  to=0x09c30cdC...  amount=1341896910000000000  (1.34 ETH root call)
  [1]  from=0x09c30cdC...  to=0xc12b101c...  amount=746840000000000
  [2]  from=0x09c30cdC...  to=0x52A7f38D...  amount=109680810000000000
  [3]  from=0x09c30cdC...  to=0x076CcBBc...  amount=470000000000000000
  [4]  from=0x09c30cdC...  to=0x493B7539...  amount=63356640000000000
  [5]  from=0x493B7539...  to=0xa0327E8a...  amount=63356640000000000  (hop through intermediate contract)
  [6]  from=0x09c30cdC...  to=0xE3736168...  amount=15000000000000000
  [7]  from=0x09c30cdC...  to=0xdC2AEe58...  amount=34007800000000000
  [8]  from=0x09c30cdC...  to=0x119021dE...  amount=9866910000000000
  [9]  from=0x09c30cdC...  to=0xB65190e2...  amount=500000000000000000
  [10] from=0x09c30cdC...  to=0x97cCC563...  amount=25649430000000000
  [11] from=0x09c30cdC...  to=0x2E1358a9...  amount=113588480000000000

ExtractTransfers found 0 top-level transfers. Safe heuristic found 0. Only debug_traceTransaction detects these.

@DNK90
Copy link
Collaborator Author

DNK90 commented Mar 19, 2026

Test Coverage for Changed Code

File Function Coverage Notes
trace.go ExtractInternalTransfers 100% Fully covered
trace.go walkCallTrace 92.9% Near-complete (uncovered: nil call guard)
evm.go traceModeActive 100% Fully covered
evm.go extractReceiptTxHashes 82.7% All branches tested
evm.go traceWithProviderAwareness 92.3% Near-complete (uncovered: type assertion fail)
evm.go convertBlock 87.5% Trace/fallback/dedup paths tested
evm.go fetchTraces 0% Orchestrator — requires full failover setup
evm.go processBlocksAndReceipts 0% Orchestrator — requires full indexer setup
failover.go AnalyzeAndHandleError 0% Called via mock tests but failover package has no test file
failover.go RecordSuccess 0% Same
failover.go HandleCapabilityError 0% Same
factory.go newEVMProvider / buildEVMIndexer 0% Factory — requires full infra
client.go DebugTraceTransaction 0% RPC call — integration tests only
safe.go ExtractSafeTransfers 100% Existing + new tests
tx.go CalcFee 90.9% Good

Summary

Core business logic (83–100% coverage):

  • Trace extraction (trace.go): 100% / 92.9%
  • Receipt gating + dedup (extractReceiptTxHashes): 82.7%
  • Provider handling (traceWithProviderAwareness): 92.3%
  • Block conversion with trace/fallback/dedup (convertBlock): 87.5%

Uncovered (infrastructure wiring):

  • fetchTraces, processBlocksAndReceipts, factory, failover wrappers — orchestrators that wire the well-tested functions together. Require full indexer setup with real failover pools.
  • DebugTraceTransaction RPC call — covered by integration tests when run with TRACE_RPC_URL.

@DNK90
Copy link
Collaborator Author

DNK90 commented Mar 19, 2026

Plan: Tests for Uncovered Code

Failover wrappers (internal/rpc/failover_test.go)

Test What it verifies
TestAnalyzeAndHandleError_Timeout Error with "timeout" → provider blacklisted 3min, metrics: Total=1, Failure=1, ErrorType="timeout"
TestAnalyzeAndHandleError_RateLimit Error with "429" → blacklisted 5min, ErrorType="rate_limit"
TestAnalyzeAndHandleError_ConnectionError Error with "connection refused" → blacklisted 2min, ErrorType="connection_error"
TestAnalyzeAndHandleError_GenericError No pattern match → Fail() (degraded/unhealthy), NOT blacklisted
TestRecordSuccess Provider state → Healthy, ConsecutiveErrors=0, metrics: Total=1, Success=1
TestHandleCapabilityError Provider blacklisted for 24h, IsAvailable()==false, metrics: Total=1, Failure=1, ErrorType="capability_error", Blacklist=1

Factory (internal/worker/factory_test.go)

Test What it verifies
TestNewEVMProvider Correct Name, URL, Network, State=Healthy, Client is *evm.Client
TestBuildEVMIndexer_NoDebugTrace DebugTrace: falsetraceFailover==nil, traceModeActive()==false
TestBuildEVMIndexer_WithDebugTrace 3 nodes, 1 debug → main failover has 3 providers, trace failover has 1
TestBuildEVMIndexer_DebugTraceNoNodes DebugTrace: true but no debug nodes → traceFailover==nil
TestBuildEVMIndexer_TraceThrottleDefaults Zero config → defaults to half main pool (RPS=4, Burst=8 from main 8/16)

DebugTraceTransaction (internal/rpc/evm/client_test.go)

Mock HTTP server — no real RPC needed.

Test What it verifies
TestDebugTraceTransaction_Success Mock returns valid JSON → parsed into CallTrace with correct fields
TestDebugTraceTransaction_RPCError Mock returns {"error":{"code":-32601,...}} → returns error containing the message
TestDebugTraceTransaction_MalformedJSON Mock returns garbage → returns unmarshal error

fetchTraces (internal/indexer/evm.go)

Mock HTTP server for trace RPC, constructed blocks/receipts.

Test What it verifies
TestFetchTraces_NilTraceFailover traceFailover==nil → returns nil immediately
TestFetchTraces_NoCandidates Block with only plain transfers (no contract calls) → returns nil
TestFetchTraces_SkipsFailedReceipts Tx with nil receipt or status=0x0 → skipped, not traced
TestFetchTraces_CollectsTracesFromMockRPC 2 contract calls with mock RPC → returns map with 2 entries
TestFetchTraces_PartialFailure 3 txs, mock fails on 1 → returns map with 2 entries, no error
TestFetchTraces_RespectsConfigConcurrency TraceThrottle.Concurrency=2 → verify via timing that max 2 run in parallel

processBlocksAndReceipts (internal/indexer/evm.go)

Full mock RPC setup — mock main pool (blocks + receipts) + mock trace pool.

Test What it verifies
TestProcessBlocksAndReceipts_TraceActive Mock main + trace RPC → traces passed to convertBlock, internal transfers in output
TestProcessBlocksAndReceipts_TraceInactive No trace failover → Safe heuristic used, no trace fetching
TestProcessBlocksAndReceipts_ERC20PrequerySkipped traceActive=true + pubkeyStore → verify eth_getLogs NOT called (count requests)

All use mock HTTP servers or constructed objects — no real RPC or infra needed.

failover_test.go (6 tests):
- AnalyzeAndHandleError: timeout/rate-limit/connection → blacklist with correct cooldown
- AnalyzeAndHandleError: generic error → degraded, not blacklisted
- RecordSuccess: restores healthy state + metrics
- HandleCapabilityError: 24h blacklist + capability_error metric

client_test.go (3 tests):
- DebugTraceTransaction success with mock server → valid CallTrace
- DebugTraceTransaction RPC error (-32601) → error with message
- DebugTraceTransaction malformed JSON → unmarshal error

evm_fetch_traces_test.go (6 tests):
- NilTraceFailover → returns nil
- NoCandidates (plain transfers only) → returns nil
- SkipsFailedReceipts (status=0x0 or nil) → not traced
- CollectsTracesFromMockRPC → 2 traces from 2 contract calls
- PartialFailure → 1 of 2 succeeds, returns partial
- RespectsConfigConcurrency → max 2 concurrent verified via timing

factory_evm_test.go (5 tests):
- newEVMProvider: correct Name/URL/Network/State/Client type
- buildEVMIndexer no debug → traceFailover nil
- buildEVMIndexer with debug → trace failover has 1 provider
- buildEVMIndexer debug but no nodes → traceFailover nil, warning
- buildEVMIndexer trace throttle defaults → half main pool

Also exports TraceModeActive() for cross-package testing.
@DNK90
Copy link
Collaborator Author

DNK90 commented Mar 19, 2026

Updated Test Coverage — After Failover/Factory/Client/FetchTraces Tests

File Function Before After
trace.go ExtractInternalTransfers 100% 100%
trace.go walkCallTrace 92.9% 92.9%
evm.go traceModeActive 100% 100%
evm.go extractReceiptTxHashes 82.7% 82.7%
evm.go fetchTraces 0% 97.1%
evm.go traceWithProviderAwareness 92.3% 92.3%
evm.go convertBlock 87.5% 87.5%
failover.go AnalyzeAndHandleError 0% 100%
failover.go RecordSuccess 0% 100%
failover.go HandleCapabilityError 0% 100%
failover.go analyzeError 0% 80.0%
failover.go logProviderMetrics 0% 87.5%
client.go DebugTraceTransaction 0% 87.5%
factory.go newEVMProvider 0% 100%
factory.go buildEVMIndexer 0% 100%

20 new tests added (6 failover + 3 client + 6 fetchTraces + 5 factory). All previously 0% functions now have 80-100% coverage.

@DNK90 DNK90 changed the title [WIP] feat: EVM internal transfer indexing via debug_traceTransaction feat: EVM internal transfer indexing via debug_traceTransaction Mar 19, 2026
@DNK90 DNK90 marked this pull request as ready for review March 19, 2026 07:17
@anhthii
Copy link
Contributor

anhthii commented Mar 19, 2026

Summary

Adds debug_traceTransaction support to detect internal native ETH transfers (contract-to-contract, router hops, CREATE with value) that the existing Safe heuristic cannot catch. Introduces a dedicated trace failover pool with isolated rate limiting and provider health, so trace failures don't affect main indexing. Falls back to the Safe heuristic when tracing is disabled or all trace providers are blacklisted. Comprehensive test coverage with 22 unit tests and 10 integration tests.

Review Checklist

  • Code correctness — no logic errors or bugs
  • Error handling — fail-fast where appropriate, graceful degradation for trace failures
  • Security — no secrets, injection, or SSRF risks
  • Database — N/A (no schema changes)
  • Concurrency — provider fields read under RLock, mutex on trace result map, errgroup with concurrency limit
  • API contract — CallTrace type matches callTracer output, EthereumAPI interface extended cleanly
  • Code reusability — newEVMProvider helper eliminates duplication in factory
  • Tests — excellent coverage across unit, integration, and edge cases

Findings

Priority File Issue
📘 internal/worker/factory.go:187 Trace rate limiter allocated unconditionally
📘 internal/indexer/evm_trace_integration_test.go:128 Non-atomic counter in test handler

Verdict

correct — clean architecture, thorough test coverage, solid fallback design.

Copy link
Contributor

@anhthii anhthii left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See inline comments.

- Guard trace rate limiter creation with chainCfg.DebugTrace check
  to avoid wasteful allocation when tracing is disabled
- Use atomic.Int32 for attempt counter in TestTransientErrorResilience
  for consistency with other tests and race-detector safety
@anhthii anhthii merged commit 6f48527 into main Mar 19, 2026
2 checks passed
@anhthii anhthii deleted the feat/evm-internal-transfer-tracing branch March 19, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: EVM internal transfer indexing via debug_traceTransaction

2 participants