Aggregated Internal Transaction Index#704
Merged
Merged
Conversation
Member
Author
barnabasbusa
approved these changes
May 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Rewrites
el_transactions_internalfrom a per-call-frame index (one row per sub-call) into a per-account aggregate (one row per touched account per transaction). Heavy-call transactions — e.g. a contract that fans 100 sub-calls into the same address, or a multisend with many recipients — drop from ~100 rows to a handful, eliminating the SQLite insert-pressure issue (~80k inserts/slot observed on busy devnets). The address-page "Internal Txs" tab now shows aggregated per-direction call counts and value/gas totals instead of one row per call frame.Changes
Schema & Data Model
el_transactions_internalrebuilt with new layout, primary key(tx_uid, account_id):tx_uidblock_uid << 16 | tx_indexaccount_idin_countout_countcall_type_mask1 << CallType_n(CALL=0, STATICCALL=1, DELEGATECALL=2, CREATE=3, CREATE2=4, SELFDESTRUCT=5)value_invalue_outgas_usedSingle composite index
(account_id ASC, tx_uid DESC)replaces the four old per-direction indexes (from_id+to_id, each with and withouttx_uid).Migration (
20260519000000_internal-tx-aggregate.sql, both engines): rebuilds the table from the old per-call layout. Historical data is aggregated in SQL:out_count = COUNT(*)per(tx_uid, from_id), contributes tovalue_out.in_count = COUNT(*)per(tx_uid, to_id), contributes tovalue_in.call_type_maskreconstructed viaSUM(DISTINCT 1 << call_type)— since each call type maps to a distinct power of two, summing distinct values is equivalent to bitwise OR (portable across SQLite and Postgres, noBIT_ORaggregate required).gas_usedzeroed for migrated rows (the old schema didn't store it); repopulates as the indexer touches new blocks.Indexer (
txindexer/process_transactions.go)pendingInternalCall(one struct per sub-call) replaced withpendingInternalAggregate(one struct per touched account, accumulated as the call tree is walked).processCallTracenow builds amap[*pendingAccount]*pendingInternalAggregateduring the depth-first walk:A → B:A.outCount++,B.inCount++,B.callTypeMask |= 1<<type, value/gas attributed both sides.A → A:inCount++andoutCount++on the single row, mask bit set, value/gas attributed once.el_transactions.inCount/outCountclamped to 32767 (postgresSMALLINTsigned max) on insert.INSERT OR REPLACEfor SQLite,ON CONFLICT (tx_uid, account_id) DO UPDATEfor Postgres) so re-indexed blocks overwrite cleanly.Frame-Local Gas Fix
debug_traceBlockByHash(callTracer) returnsgasUsedper frame as a cumulative subtree total. Summing it across frames over-counts nested execution: a 3-deep chain attributing the same parent gas to every account it passes through. Fix:Applied to both:
gas_usedwritten intoel_transactions_internal(aggregation correctness).GasUsedstored in the blockdbFlatCallFrame(so the tx-detail "Internal Txs" tab shows the gas each frame's own execution consumed, not the inflated subtree total).The saturating subtract guards against non-Geth tracers that may round
gasUsedinconsistently.DB Layer (
db/el_transactions_internal.go)InsertElTransactionsInternalrewritten to insert 8-field aggregate rows (was 7-field per-call rows).GetElTransactionsInternalByAccountis now a single direct index scan (WHERE account_id = ?) — the old query neededUNION ALLoverfrom_id/to_idbranches.HasElTransactionsInternalByAccountsimplified to a singleEXISTS.GetElTransactionsInternalCountByTxUidnow counts distinct accounts touched by the tx (was: count of call frames).GetElTransactionsInternalByTxUidreturns aggregate rows ordered byaccount_id.Address Page (
/address/{addr}?v=internaltxs)New columns:
in_count/out_count— number of internal calls hitting the address in each direction. Em-dash when zero.call_type_mask(incoming-only). Lets staticcall/precompile interactions show "STATICCALL" even when no value transferred.value_in/value_outformatted as ETH.One row per tx (no more
rowspangrouping).calculateInternalTxHashRowspansremoved.Transaction Page (
/tx/{hash}?v=internaltxs)FlatCallFramearray).ElBlockDataCallTracesnot set), the page now shows the "not available" archive notice instead of the old degraded per-call DB view — the DB index no longer holds per-call detail.Internal Txs (N)shows account-count fromGetElTransactionsInternalCountByTxUidon initial render; once the tab loads, blockdb data overwrites it with the actual call count.Models (
types/models/address.go)AddressPageDataInternalTransactionreshaped to mirror the aggregate row (InCount,OutCount,CallTypes,ValueIn,ValueOut,GasUsed). AddedAddressPageDataInternalTransactionCallTypehelper struct for pre-expanded badge rendering.TxHashRowspanand per-call fields (CallIndex,CallType,FromAddr,ToAddr,IsOutgoing, …) removed.Impact
in_count=1orout_count=400x...0E) called once per txin_count=1el_transactions_internalstorage