feat: EVM internal transfer indexing via debug_traceTransaction#73
feat: EVM internal transfer indexing via debug_traceTransaction#73
Conversation
…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
Integration Test Results — All PassRPC-level tests (trace_integration_test.go)
Indexer-level tests (evm_trace_integration_test.go)
Key result: Batch sendNative (issue #71 sample tx)Tx
|
Test Coverage for Changed Code
SummaryCore business logic (83–100% coverage):
Uncovered (infrastructure wiring):
|
Plan: Tests for Uncovered CodeFailover wrappers (
|
| 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: false → traceFailover==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.
Updated Test Coverage — After Failover/Factory/Client/FetchTraces Tests
20 new tests added (6 failover + 3 client + 6 fetchTraces + 5 factory). All previously 0% functions now have 80-100% coverage. |
SummaryAdds Review Checklist
Findings
Verdictcorrect — clean architecture, thorough test coverage, solid fallback design. |
- 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
Closes #71
Summary
Add
debug_traceTransactionsupport 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
DebugTrace+TraceThrottletoChainConfig,DebugTracetoNodeConfigCallTracestruct totypes.goDebugTraceTransactiontoEthereumAPIinterfaceDebugTraceTransactioninclient.goconfig.example.yamlwith per-nodedebug_trace,trace_throttle(rps/burst/concurrency), and cost documentationProviderinstances + dedicated rate limiter infactory.gotraceFailoverfield +traceModeActive()method toEVMIndexerPhase 2: Trace Extraction
trace.go—ExtractInternalTransfers+walkCallTrace(CALL + CREATE/CREATE2, usenative_transfertype)Phase 3: Indexer Integration
CalcFee, update call sites intx.goandsafe.goNeedReceiptgate (usingtraceModeActive) + handle pubkeyStore==nil contract-call filter + skip ERC20 prequery + fetch contract call receipts + dedup tx hashesfetchTraces+traceWithProviderAwareness(single-attempt,AnalyzeAndHandleError, exhaust all providers)AnalyzeAndHandleError+RecordSuccess+HandleCapabilityErrorto Failovertracesparam tobuildBlockResults+convertBlockDedupTransfersinconvertBlockPhase 4: Deduplication
DedupTransferscall inconvertBlock, no change toDedupTransfersitself)Phase 5: Fallback + Provider Safety
traceFailoverisolated, single-attempt,AnalyzeAndHandleError, dedicated rate limiter)Verification — Unit Tests (22 tests, all pass)
ExtractInternalTransfers— 12 tests: CALL/CREATE/CREATE2/DELEGATECALL/STATICCALL/reverted/nil/dedup/typedebug_trace: false— Safe heuristic unchangedVerification — Integration Tests (10 tests, all pass)
DebugTraceTransactionreturns valid CallTrace from Tenderly Sepolia (skip in -short)Implementation Plan
See implementation plan comment on the issue.