Skip to content

fix(tron): count internal transaction inflows in balance history - #1682

Open
cranycrane wants to merge 9 commits into
masterfrom
fix/tron-internal-transactions-1660
Open

fix(tron): count internal transaction inflows in balance history#1682
cranycrane wants to merge 9 commits into
masterfrom
fix/tron-internal-transactions-1660

Conversation

@cranycrane

@cranycrane cranycrane commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #1660. Depends on #1703 — merge that first; do not reindex Tron before it lands, or the reindex bakes in wrong contract registry rows.

Tron getBalanceHistory counted TRX sent into contracts as sent, but TRX returning from contracts arrives via internal transactions, which were never counted as receivedprocessInternalTransactions was off, so the balance history of DeFi-active addresses drifted billions of TRX negative.

Core fix:

  1. fix(tron): classify contract creations by missing recipient, not contract_address — java-tron fills the tx-info contract_address for ordinary TriggerSmartContract calls too, so every contract call was registered as a contract creation. A creation is now recognized by the missing eth-style recipient combined with a reported contract_address (neither signal alone discriminates: native non-VM operations such as FreezeBalance/WithdrawBalance also have a null to, but never a contract_address). Had to land before anything re-processes history.
  2. feat(tron): enable processInternalTransactions — zero extra sync-time RPC: gettransactioninfobyblocknum is already fetched per block for fees/receipts; the internal transfers were parsed and discarded. Fixes all newly synced blocks.
  3. chore(tron): enable vm.saveInternalTx in packaged backend config — upstream's main_net_config.conf ships the flag off, under which java-tron records no internal transactions at all. The deployed backend already runs with it on (verified: it serves internal transactions for the whole chain history); this keeps a package rebuild from silently regressing that.

Review fixes (commits after the review rounds; the findings were verified against a live mainnet node and java-tron GreatVoyage-v4.7.7 sources):

  1. fix(tron): skip rejected internal txs, register full contract lifecycle — rejected frames (e.g. OUT_OF_ENERGY) no longer book phantom transfers; every non-rejected create/suicide note emits its registry event in execution order.
  2. fix(tron): emit zero-value transfers for create and suicide frames — eth processCallTrace parity: without a transfer, a factory-deployed or ephemeral child's own address history never contains the tx that created/destroyed it, and the reorg rollback in fix(db): keep contract registry consistent for same-block lifecycles and reorgs #1703 has no row to key off. Plain zero-value calls stay skipped.
  3. fix(tron): skip featured frames, drop bogus error and selfdestruct inference — featured internal transactions (delegateResourceOfEnergy, freezeBalanceV2For*, …) report the staked amount in callValue although no TRX moves; unknown notes are now skipped explicitly instead of booked as CALL transfers, and the packaged nile config turns vm.saveFeaturedInternalTx off to match mainnet's data shape. The per-frame rejected flag no longer stamps an error on successful transactions (java-tron rejects single frames inside txs that succeed), and the top-level SELFDESTRUCT inference is gone — it could not survive packing (one type bit; contract packed only for CREATE) and would have surfaced a destroyed contract as createdContract. GetAddrDescFromAddress also returns ErrAddressMissing for the empty address instead of falling through to a log line — empty is a normal shape for creation frames.
  4. fix(tron): do not store internal data rows for plain transactions — eth parity: empty internal data no longer writes a cfInternalData row for every Tron transaction.
  5. fix(tron): do not register contracts from failed deployments — java-tron precomputes and reports contract_address even when the deployment failed; the CREATE classification and the registry entry are now gated on the receipt result.
  6. chore(tron): bump backend package_revision for the config changes — the extract_command of both packaged backends changed, but a rebuilt package kept the identical -latest version string and would never install as an upgrade; both configs now use the repo-wide satoshilabs-1 revision, which also sorts above latest in dpkg comparison.

Split out of this PR: the contract registry consistency fixes moved to #1703. They repair two pre-existing bugs in the shared eth-type DB layer (a same-block create→selfdestruct lost its destruction; reorgs left stale cfContracts rows that permanently disable GetBalanceHistory) that affect all eth-type chains, not just Tron — Tron simply becomes the second producer of registry events. This PR is correct only on top of it.

Healing plan for existing indexes: this PR makes newly synced blocks correct; already-indexed history will be healed by a full reindex (the online backfill routine proposed in an earlier revision of this PR was dropped from it).

Operator notes: after deploying, regenerate blockchaincfg.json, restart, and reindex — with #1703 included in the build. The backend must run with vm.saveInternalTx = true (the packaged config now enforces it). Until the reindex completes, expect benign Contract … not found in DB warnings as pre-existing TRC-20 contracts self-heal on first read — they signal missing internal data in old blocks, not corruption.

Testing: unit tests cover the CREATE classification (the #1660 call-with-contract_address case fails on master), failed deployments, featured-frame skipping, TRC-10 filtering, rejected frames and the ephemeral create→selfdestruct registry event order; the DB-side merge and disconnect rollback are tested in #1703. go test -tags unittest passes for bchain/coins/tron, db, api. Verified against a live backend: running the block builder over block 83,534,426 books the reference tx d54a1abc…'s 13,245.012561 TRX refund to TT2T17KZ… as an internal transfer.

🤖 Generated with Claude Code

@cranycrane
cranycrane marked this pull request as ready for review July 30, 2026 12:00
@cranycrane

Copy link
Copy Markdown
Contributor Author

Code review

Found 1 issue:

  1. internalDataEqual breaks backfill idempotency for CREATE transactions on non-Tron Ethereum-type chains (bug due to api/internaldata_backfill.go comparing stored.Contract != computed.Contract with asymmetric normalization). The computed side comes raw from the trace (d.Contract = r.To in ethrpc.go#L1554-L1558, lowercase hex) and the fixEIP55 block only normalizes Transfers[i].From/To, never internalData.Contract (ethparser.go#L158-L166), while the stored side is unpacked via GetAddressesFromAddrDescEIP55Address, which always returns checksummed mixed-case (rocksdb_ethereumtype.go#L835-L843). So on a standard eth-type chain every contract-creation tx compares unequal, filterAlreadyReconnectedInternalData never skips it, and re-running a range re-runs ReconnectInternalDataToBlockEthereumType on it — re-incrementing the contract transaction counters that the filter's own comment says it exists to prevent, and contradicting the "Re-running a range is safe" contract of StartInternalDataBackfill. Tron is unaffected (base58 is case-canonical both ways), which is why the tests in api/internaldata_backfill_test.go don't catch it — consider comparing address descriptors or normalizing both sides before comparing.

func internalDataEqual(stored, computed *bchain.EthereumInternalData) bool {
if (stored.Type == bchain.CREATE) != (computed.Type == bchain.CREATE) {
return false
}
if stored.Type == bchain.CREATE && stored.Contract != computed.Contract {
return false
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@cranycrane

Copy link
Copy Markdown
Contributor Author

Code review — follow-up (verified against live backend)

Tested the PR's assumptions against java-tron (backend15.sldev.cz:8091 HTTP + :8545 JSON-RPC) over ~720 recent mainnet blocks. The core classification logic checks out on live data: to is null only for CreateSmartContract (verified via both eth_getTransactionByHash and bulk eth_getBlockByNumber, which is what GetBlock parses), contract_address is filled for ordinary TriggerSmartContract calls (2,642 of 12,589 txs vs. 4 actual creates in one sample), factory creates carry the factory — not the child — in contract_address, the only notes observed were call/create/suicide, and the issue #1660 example tx decomposes exactly as documented. Found 2 issues in how the enabled pipeline handles real chain data:

  1. Rejected internal transactions are booked as real value flows. The transfer loop appends a transfer for every internal tx with callValue > 0 without checking itx.Rejected, and neither processInternalData (rocksdb_ethereumtype.go#L601-L607) nor processInternalTransactionsForBalanceHistory (worker.go#L1803-L1806) gates on the stored error. Live specimen: ab58f2059d7fd71350d2ae7e862bea3dd428040b6c87cf8ee98ca70a04af65fa failed with OUT_OF_ENERGY; all 12 internal txs are rejected: true, two carrying callValue: 457584 — both would be indexed and booked into balance history although no TRX moved. A failed high-value DeFi call would inject its entire internal flow into the history this PR is fixing. Ethereum's tracer path has the same weakness, but Tron hands over an explicit rejected flag, so skipping is a one-line guard.

for _, cv := range itx.CallValueInfo {
// skip TRC-10
if cv.CallValue <= 0 || cv.TokenID != "" {
continue
}
val := *big.NewInt(cv.CallValue)
d.Transfers = append(d.Transfers, bchain.EthereumInternalTransfer{
Type: t,
From: from,
To: to,
Value: val,
})
}

  1. The ephemeral create→use→selfdestruct pattern corrupts the contract registry. detectSelfDestructedContract runs before the internal-tx loop and sets d.Contract to the first suicided contract; the nested-create guard to != d.Contract then skips registering that same contract's creation, producing a destroyed-but-never-created entry — and for txs with multiple suicides only the first destruction is ever registered, so the remaining children (registered as created by the new nested-create block) become permanent zombies. Live specimens: 76a2228970dd1f9198b8c5edefab46fe7bfe3b1fed81fabd5b398d23c1fa916c (SUCCESS factory call: 3 creates + 3 suicides → recorded as 1 destroyed-never-created + 2 created-never-destroyed; eth's processCallTrace would record all 6 registry events), plus 4391da24…, be72904c…, ea2ce22c… (single ephemeral). This pattern is common: 36 of 600 scanned blocks contained suicide notes and every sampled one was ephemeral (MEV-style), i.e. roughly 2,500 tx/day. Note the same txs also illustrate that zero-value nested creates/suicides emit no transfer at all (every one of the 81 observed create notes had empty callValueInfo), so these ephemeral contracts' own address histories stay empty — unlike eth, which appends CREATE/SELFDESTRUCT transfers unconditionally.

} else {
destructed, err := detectSelfDestructedContract(info.InternalTransactions)
if err != nil {
return data, contracts, err
}
if destructed != "" {
d.Type = bchain.SELFDESTRUCT
d.Contract = destructed
contracts = append(contracts, bchain.ContractInfo{
Contract: destructed,
DestructedInBlock: blockHeight,
})
}
}
for _, itx := range info.InternalTransactions {
t, err := tronNoteHexToInternalType(itx.Note)
if err != nil {
return data, contracts, err
}
from := ToTronAddressFromAddress(itx.CallerAddress)
to := ToTronAddressFromAddress(itx.TransferToAddress)
// nested create frames register the child contract in the registry but
// do not change the top-level type (parity with eth processCallTrace) —
// factory calls remain CALLs
if t == bchain.CREATE && to != "" && to != d.Contract {
contracts = append(contracts, bchain.ContractInfo{
Contract: to,
CreatedInBlock: blockHeight,
Standard: bchain.UnhandledTokenStandard,
})
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

cranycrane added a commit that referenced this pull request Jul 30, 2026
internalDataEqual compared addresses as raw strings, but on standard
Ethereum-type chains the stored side unpacks EIP55 checksummed while the
computed side keeps the trace's lowercase hex (fixEIP55 normalizes only
transfer addresses, not internalData.Contract). Every contract creation
therefore compared unequal, was reconnected again on every backfill
re-run and drifted the contract transaction counters - breaking the
"re-running a range is safe" contract. Tron was unaffected as base58 is
case-canonical on both sides.

Compare via address descriptors from the chain parser instead, which
canonicalizes both representations.

Addresses PR #1682 review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cranycrane added a commit that referenced this pull request Jul 30, 2026
Two fixes for how real chain data flows through the Tron internal data
provider, found in PR #1682 review against a live backend:

- A rejected internal transaction (e.g. all frames of an OUT_OF_ENERGY
  transaction) did not execute, but its call value was still booked as
  an internal transfer, injecting phantom flows into the balance
  history this PR fixes. Rejected frames now contribute nothing except
  the existing transaction error flag; unlike eth's tracer path, Tron
  hands over an explicit per-frame rejected flag, so use it.

- The ephemeral create->use->selfdestruct pattern (common, MEV-style)
  corrupted the contract registry: only the first suicide was ever
  detected, and the nested-create guard skipped registering the
  creation of the very contract picked as the top-level destructed
  one, producing destroyed-but-never-created and created-but-never-
  destroyed entries. Creations and destructions are now emitted per
  note in execution order (parity with eth processCallTrace), so a
  destruction always merges into its stored creation. The top-level
  SELFDESTRUCT type is still inferred from the first destroyed
  contract, matching previous behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cranycrane
cranycrane force-pushed the fix/tron-internal-transactions-1660 branch from 82ff3d5 to 7b930c8 Compare July 31, 2026 09:11
@cranycrane
cranycrane force-pushed the fix/tron-internal-transactions-1660 branch from 7b930c8 to 728aedd Compare August 3, 2026 09:01
@pragmaxim
pragmaxim self-requested a review August 3, 2026 15:35

@pragmaxim pragmaxim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

@cranycrane cranycrane left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review follow-up: the High and Medium findings from my analysis, as inline comments. (The three blocking items — failed-deploy contract registration, the PR-body/commit-message mismatch with the absent backfill, and the unbumped package_revision — are tracked separately, as are the test-hardening notes.)

Data claims below were verified against a live mainnet node (backend15, 1,080 blocks / 134,239 txs / 90,088 internal frames) and against java-tron GreatVoyage-v4.7.7 sources.

For the record, the core fix checks out: contract_address is set for 41,700 of 41,704 sampled txs that are ordinary TriggerSmartContract calls (only 4 are deploys), so the old classifier misread essentially every contract call as a deployment; and tx.To == "" && contract_address != "" is a perfect discriminator on that sample (0 false positives, 0 false negatives). The reference tx d54a1abc…a547 does carry the 13,245.012561 TRX refund that master drops.

Medium findings that have no anchor in this diff

  • cfContracts is never rolled back, and this PR amplifies the drift. Nothing in disconnectBlockTxsEthereumType deletes from cfContracts (db/rocksdb_contracts.go:114/184/278 are the only writers; 278 is the admin DELETE). Commit 389d3b9 changes Tron from one registry event per transaction to one per frame, so a reorged block — routine on Tron — now leaves proportionally more stale rows, and a stray row permanently disables GetBalanceHistory for that address (api/worker.go:2085). Either mirror disconnectErcProtocols over the disconnected range, or document the acceptance.
  • Post-deploy log wave. api/worker.go:709-712 emits Contract %v not found in DB only when ProcessInternalTransactions is on. On a Tron DB whose history was indexed without it, the first read of every pre-existing TRC-20/721 logs this before self-healing. It is the same log shape that signals a genuine index gap on other chains — worth a line in the PR so it is not misread as corruption.
  • internalTxs counts transfer legs, not transactions. ac.InternalTxs++ (db/rocksdb_ethereumtype.go:489) fires once per leg, outside the per-tx dedupe at :517-520, so ?filter=1 page counts can overshoot what getAddressTxids returns. Pre-existing eth behaviour, newly reachable on Tron.
  • An empty cfInternalData row per Tron transaction. EthTxToTx nils out empty internal data (bchain/coins/eth/ethparser.go:154-158), but buildTxFromHTTPData then overwrites tx.CoinSpecificData (bchain/coins/tron/tronrpc.go:716), restoring a non-nil pointer, so db/rocksdb_ethereumtype.go:730 always runs. Eth writes no row at all. Pure storage waste at Tron's tx volume; consider passing nil when the entry is empty.
  • GetInternalDataForBlock (lines 52-78) is unreachable in productionTronRPC.GetBlock shadows EthereumRPC.GetBlock and calls buildInternalDataFromTronInfos directly (tronrpc.go:883). Worth a comment, since it is the method the provider unit test exercises while production takes another path. Separately, its return nil, nil, err at :73 diverges from eth's contract of returning a correctly-sized data slice on error (ethrpc.go:1525), which the caller relies on for &internalData[i] — harmless while the method is dead, a latent nil-slice index panic if it is ever wired up.

Comment on lines +152 to +158
// A transaction deploys a contract only when its eth-style representation
// has no recipient (java-tron's JSON-RPC leaves `to` null solely for
// CreateSmartContract) AND the node reported the deployed address in the
// transaction info. contract_address alone is not a creation signal:
// java-tron fills it for ordinary TriggerSmartContract calls as well.
deployedContract := ""
if tx.To == "" && info.ContractAddress != "" {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Medium — the comment's stated reason is factually wrong (the code is right).

to is not null "solely for CreateSmartContract". Of 220 null-to txs in a 134,239-tx mainnet sample, 216 are native non-EVM contracts: WithdrawBalance 95, FreezeBalance 60, UnfreezeBalance 31, FreezeBalanceV2 14, UnfreezeBalanceV2 10, WithdrawExpireUnfreeze 3, ExchangeTransaction 1, CancelAllUnfreezeV2 1, AccountPermissionUpdate 1. Verified individually, e.g.

FreezeBalanceV2Contract    b02358bb…ccd9b → to: null
WithdrawBalanceContract    8b88c532…81a87 → to: null
CreateSmartContract        d3ba408f…b1d7  → to: null
TriggerSmartContract       d54a1abc…a547  → to: "0x72db65b2e023e4783d46023e7135c692e527f6cb"

All the discriminating power comes from the info.ContractAddress != "" half of the &&contract_address is empty for every non-VM tx. The conjunction is a perfect discriminator (0 FP / 0 FN on the sample), so the behaviour is correct; only the justification is not.

Two suggestions:

  1. Reword to say the pair is what discriminates, and that contract_address is empty for native non-EVM contracts.
  2. Consider using the authoritative signal instead — GetBlock already has RawData.Contract[0].Type == "CreateSmartContract" available (both maps are awaited before tronrpc.go:883). Relying on getTo() returning null is a java-tron JSON-RPC artifact; any proxy or alternate node that normalises to to the deployed address would silently turn every deployment into a CALL and register no contract at all.

Comment on lines +176 to +181
// a rejected internal transaction did not execute - it moved no
// value, created no contract and destroyed none; it only flags
// the transaction error below
if itx.Rejected {
continue
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

High — rejected is per-frame, not per-tx, so the tail loop at lines 250-259 stamps an error on successful transactions.

Skipping the transfers here is right. The problem is the companion loop below: java-tron rejects a single frame when a nested call reverts (Program.java:955/1161internalTx.reject() then stackPushZero()) and the outer transaction keeps running and can succeed. Real mainnet case:

tx 4ae4a08e829dca98c8279adf18bfe8ed8eeeca2d83b3697f1a3b24c8c3e862fc   block 82,000,016
receipt.result = SUCCESS      eth receipt status = 0x1      log count = 1
internal_transactions: 8 frames, ALL rejected:true, all note "call"

That yields ethereumSpecific.error = "Internal transaction rejected" alongside status: 1. The web templates gate the error text on Status == 0 (tx_tron.html:39, txdetail_tron.html:8) so the HTML UI is unaffected, but any JSON consumer (Suite) sees an error on a transaction that succeeded. Frequency in the sample: 1 in 16,915 SUCCESS txs carrying internal frames — rare per-tx, thousands chain-wide.

Suggest only appending the note when the receipt itself is non-SUCCESS:

if d.Error != "" {
    d.Error += "; internal transaction rejected"
}

Related nit: for the 607 failed txs in the sample, every frame is rejected, so the suffix is unconditional noise on "REVERT" / "OUT_OF_ENERGY". And unlike eth, this string bypasses PackInternalTransactionError's 1-byte compression (ethparser.go:655-670) and is stored raw.

Comment on lines +186 to +188
// registry events are emitted in note order (parity with eth
// processCallTrace), so that an ephemeral contract's creation is
// stored before its destruction merges into it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

High — the in-block merge this comment relies on does not exist; the destruction is silently dropped.

storeContractInfo resolves a destruction entry by reading committed state:

// db/rocksdb_contracts.go:173-183
if contractInfo.CreatedInBlock == 0 && contractInfo.DestructedInBlock != 0 {
    storedCI, err := d.GetContractInfo(key, "")   // LRU + d.db.GetCF only
    if storedCI == nil { return nil }             // <- destruction dropped

GetContractInfo reads d.db.GetCF (db/rocksdb_contracts.go:96); the creation PutCF emitted moments earlier sits in an uncommitted grocksdb.WriteBatch that is flushed only after the whole block (db/rocksdb.go:734 then :746), and there is no WriteBatchWithIndex anywhere in the repo. The creation also cachedContracts.deletes its key, so the cache misses too.

So for a fresh ephemeral contract — exactly the create→use→selfdestruct case this commit targets — the destruction returns early and the row ends as {CreatedInBlock: H, DestructedInBlock: 0}, i.e. recorded as alive. Emission order is inert here; it only matters for a contract created in an earlier block. The same applies when the creator and destroyer are two different txs in one block.

The unit test "Ephemeral contract created and destroyed in one call" passes because it asserts the returned slice, not what the DB retains — and its comment ("creation first, so that the destruction merges into the stored creation") repeats the same claim.

Either dedupe/merge the []bchain.ContractInfo for the block before storing, or give storeContractInfo an in-batch map to consult — and until then, drop the ordering rationale from both comments so it does not read as a solved problem.

A second, smaller issue in the same helper: line 181 mutates the object returned by GetContractInfo, which may be the shared LRU value (db/contract_info_cache.go:61), before the batch commits — a concurrent reader can observe DestructedInBlock early, and permanently if the batch write then fails.

// registry events are emitted in note order (parity with eth
// processCallTrace), so that an ephemeral contract's creation is
// stored before its destruction merges into it
switch t {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

High — unrecognised notes fall through to CALL, turning staking operations into phantom multi-million-TRX transfers.

tronNoteHexToInternalType default-maps any unknown note to CALL (bchain/coins/tron/tronparser.go:348-350), so the frame's callValue is booked as a TRX transfer at line 225. But featured notes carry the staked/delegated amount, which never moves. Observed live on backend15:

note delegateResourceOfEnergy     caller 41c64e69… → 417b098c…  callValue 8333333000000   (8,333,333 TRX)
note unDelegateResourceOfEnergy   caller 41c64e69… → 41c9b586…  callValue 1255110000000   (1,255,110 TRX)

Across the sample: 43 such frames, 54,298,294 TRX of transfers that did not happen. Proof the value stays put — the delegator TU3kjFuhtEo42tsCBtfYUAZxoqQ4yuSLQ5 is not drained; /walletsolidity/getaccount shows the amount under account_resource.delegated_frozenV2_balance_for_energy, and a same-block delegate/undelegate pair (1ff0e6d9…bb4c / 8cd47fcd…66f8, block 65,000,007) moves an identical 1,255,110 TRX in both directions while the txs' own call_value is 1,649 TRX / null.

Whether these reach Blockbook is a node-config question — java-tron filters the list to call/create/suicide unless vm.saveFeaturedInternalTx is on (chainbase/.../capsule/utils/TransactionUtil.java:127-140, v4.7.7). Which means:

  • Mainnet as packaged is safe — the pinned main_net_config.conf leaves saveFeaturedInternalTx commented out at line 701.
  • Nile as packaged is not. config-nile.conf ships saveInternalTx = true and saveFeaturedInternalTx = true (lines 530-531 of the pinned 3f6f53ca… revision), and this PR adds no sed to turn it off. Nile will fabricate these transfers, so it is not a faithful pre-flight for mainnet.
  • backend15 clearly runs with the flag on — the frames quoted above came from it — so the node used to verify this change has a different data shape from the packaged config.

I would fix this in the parser rather than depend on node config: return an explicit "ignore this frame" outcome for anything that is not call/create/suicide. There are ~28 such notes in v4.7.7 (freezeFor*, unfreezeFor*, freezeBalanceV2For*, unfreezeBalanceV2For*, delegateResourceOf*, unDelegateResourceOf*, withdrawReward, voteWitness, withdrawExpireUnfreeze*, cancelAllUnfreezeV2, each × resource type), and all the value-bearing ones behave identically. Optionally also add the nile sed for symmetry with mainnet.

Note this used to be visible: the removed detectTopType at least logged glog.Warningf("Unknown Tron internal transaction type %v", t). After this PR an unhandled note type is completely silent.

case bchain.CREATE:
// nested create frames register the child contract but do not
// change the top-level type - factory calls remain CALLs
if to != "" && to != deployedContract {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Medium — the deployedContract guard covers the registry entry but not the transfer emitted at line 237.

The guard exists to stop the root create being registered twice should java-tron ever list it among internal_transactions. But the zero-value fallback below checks only to != "", so in that same scenario you would still emit a duplicate CREATE transfer for the deployed contract. Either both sites should consult deployedContract or neither should.

For what it is worth, the guard cannot suppress a legitimate nested CREATE: deployedContract is non-empty only when tx.To == "" (a CreateSmartContract), a CREATE2 redeploy always goes through a factory (tx.To != ""), and a nested create cannot land on the root contract's own tx-hash-derived address. It is also empirically untested — all 4 CreateSmartContract txs in my sample have no internal_transactions at all, so the skip arm never runs today, and the "Deployment with constructor-created child contract" test uses a different child address. A regression that removed the guard would double-register every deployment and still pass the suite.

Comment on lines +194 to +205
contracts = append(contracts, bchain.ContractInfo{
Contract: to,
CreatedInBlock: blockHeight,
Standard: bchain.UnhandledTokenStandard,
})
}
case bchain.SELFDESTRUCT:
if from != "" {
contracts = append(contracts, bchain.ContractInfo{
Contract: from,
DestructedInBlock: blockHeight,
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Medium — moving from one registry event per transaction to one per frame amplifies reorg drift.

This is the right semantics (eth registers every nested create and suicide — bchain/coins/eth/ethrpc.go:1477 and :1485 — so this is genuine parity), but it interacts badly with a pre-existing gap: nothing ever removes cfContracts rows on disconnect. DisconnectBlockRangeEthereumType clears cfTransactions, cfInternalData, cfAddresses, cfBlockTxs, cfHeight, cfBlockInternalDataErrors and reverts cfErcProtocols (db/rocksdb_ethereumtype.go:1382-1426), but cfContracts is untouched — its only writers are db/rocksdb_contracts.go:114/184 plus the admin DELETE at :278.

On a chain with routine 1-block reorgs, each orphaned block now leaves proportionally more rows whose CreatedInBlock/DestructedInBlock point at a height that no longer contains the tx. The sharp edge is api/worker.go:2085: any address with a cfContracts row gets "GetBalanceHistory for a contract not allowed" permanently, repairable only via DELETE /admin/contract-info/<address> — which is itself lossy (see the comment at db/rocksdb_contracts.go:248-251).

Suggest either deleting cfContracts rows whose CreatedInBlock falls in the disconnected range (mirroring disconnectErcProtocols), or explicitly noting the accepted drift in the PR.

Comment on lines +206 to +213
// Tron internal transactions describe only nested frames,
// so unlike eth the top-level SELFDESTRUCT type is
// inferred from the first destroyed contract
if d.Type == bchain.CALL {
d.Type = bchain.SELFDESTRUCT
d.Contract = from
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

High — this inference cannot survive packing, and the field it writes means something else.

Two independent problems.

1. It is dead code. packEthInternalData stores the type in a single bit and only packs the contract for CREATE:

// db/rocksdb_ethereumtype.go:812-817
// internalType is one bit (CALL|CREATE), it is joined with count of internal transfers*2
l := packVaruint(uint(data.internalType)&1+uint(len(data.transfers))<<1, varBuf)
...
if data.internalType == bchain.CREATE {

SELFDESTRUCT is 2 (bchain/types_ethereum_type.go:59-62), so 2&1 == 0 → the row unpacks as CALL with no contract (unpackEthInternalData:835), and processInternalData indexes id.Contract only for CREATE (:608). Nothing set here can reach /api/v2/tx. The tests asserting wantType: bchain.SELFDESTRUCT (test file lines 294 / 331 / 374) therefore pin behaviour no client can observe.

2. If it ever were persisted, it would be wrong. EthereumInternalData.Contract surfaces as EthereumSpecific.CreatedContract — documented as "Address of contract created by this transaction" (api/types.go:303, set at api/worker.go:515) — and the template renders it only under {{if eq $tx.EthereumSpecific.Type 1}} labelled "Contract creation" (txdetail_tron.html:64). Putting a destroyed contract there is a category error. Concrete case:

tx 23bffcf1831a75c18dc318faf68e5faa3a2eb9734902979ea8078a4066d03016   block 85,056,933
top-level: TriggerSmartContract → 0x3fea1575…, SUCCESS, eth to non-null
frames: create(413fea15…→41e065e6…), 4× call, suicide(41e065e6…→4118c792…)

This code yields type = 2 and createdContract = TWRiSFE9UFw9mufv6uEto5ZZyTfUrQ9Mks — an ephemeral contract that was destroyed, not created. Geth on the same trace shape reports type = CALL, createdContract = null: processCallTrace never touches d.Type/d.Contract, and eth sets the root type from the root frame only (ethrpc.go:1554-1560). All 56 suicide-bearing txs in my sample are top-level TriggerSmartContract, so 56/56 would be relabelled.

It is also inconsistent with the sibling rule eight lines up, which deliberately leaves a nested CREATE from changing the top-level type ("factory calls remain CALLs") — the same nesting level, opposite treatment.

Suggest deleting the d.Type/d.Contract mutation entirely (and the assertions that depend on it). The per-transfer Type: SELFDESTRUCT already round-trips correctly — t.internalType is packed as a full byte at db/rocksdb_ethereumtype.go:820 — and that is what the UI actually renders (txdetail_tron.html:85).

Comment on lines +216 to 225
transferEmitted := false
for _, cv := range itx.CallValueInfo {
// skip TRC-10
if cv.CallValue <= 0 || cv.TokenID != "" {
continue
}
transferEmitted = true

val := *big.NewInt(cv.CallValue)
d.Transfers = append(d.Transfers, bchain.EthereumInternalTransfer{

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Medium — this loop can emit several transfers for one frame, where eth emits exactly one.

eth's processCallTrace appends a single transfer per frame (bchain/coins/eth/ethrpc.go:1470-1495); here every positive non-TRC-10 callValueInfo entry produces its own. Low practical risk — java-tron puts the sole TRX entry at callValueInfo[0] and any extras are TRC-10 (buildInternalTransaction, L155-167) — and I saw no counterexample in 90,088 frames, so this is a robustness note rather than a live bug. Worth either a comment recording the invariant, or breaking after the first accepted entry.

The TRC-10 filter itself is correct and load-bearing: 30 frames in the sample have the two-entry shape [{}, {"tokenId":"1002000","callValue":57753367}] and the TokenID != "" test drops only the token leg. The CallValue <= 0 skip is also required — callValueInfo: [{}] is by far the most common shape. Neither is covered by a test case, though, and the interaction with the fallback below is subtle: a TRC-10-only create frame still emits a zero-value CREATE transfer (because transferEmitted stays false), while a TRC-10-only call frame emits nothing. Deliberate, I assume, but unpinned.

Comment on lines +233 to +243
// eth processCallTrace parity: create and suicide frames emit a
// transfer even when no TRX moved, so that the created/destroyed
// contract's own address history contains this transaction -
// plain zero-value calls stay skipped
if !transferEmitted && ((t == bchain.CREATE && to != "") || (t == bchain.SELFDESTRUCT && from != "")) {
d.Transfers = append(d.Transfers, bchain.EthereumInternalTransfer{
Type: t,
From: from,
To: to,
})
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Medium — the emptiness guards never fire in practice, so the stated rationale rests on cases that do not occur.

The eth-parity claim itself is accurate — processCallTrace does emit CREATE and SELFDESTRUCT transfers regardless of value while skipping zero-value plain calls (bchain/coins/eth/ethrpc.go:1470-1489) — so the mechanism is right. But across 90,088 mainnet frames: 0 have an empty or missing transferTo_address, and 0 have an empty caller_address. All 83 create frames and all 56 suicide frames carry both endpoints, and 83/83 creates are zero-value, so the fallback fires for every real create frame. The cause is structural: java-tron builds both addresses via DataWord.toTronAddress(), which cannot yield empty.

So to != "" / from != "" is dead defensive code, and the test case asserting a suicide with to: "" ("no beneficiary recorded") does not correspond to a shape the node emits. Not worth removing — it is cheap and correct — but the comment reads as if the empty case is the motivating scenario, and it is not.

If such a frame ever did appear, there is a small one-way drift: connect skips the unparseable address (db/rocksdb_ethereumtype.go:635-639, ErrAddressMissing handled quietly), while appendAddress stores 20 zero bytes (:796-805) that unpack to the valid Tron zero address T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb, so disconnectInternalData (:1329-1344) decrements TotalTxs/InternalTxs that were never incremented. Also note TronParser.GetAddrDescFromAddress logs at glog.Infof for every such call (tronparser.go:71), defeating the caller's deliberate silence on ErrAddressMissing — eth's parser logs nothing.

The record format is otherwise safe here: fixed-width addresses mean a zero-length descriptor can never shorten a record or desynchronise the transfers that follow, and a zero Value packs to a single byte and unpacks back to 0 (db/rocksdb.go:2904-2951).

cranycrane and others added 9 commits August 5, 2026 09:54
…ract_address

java-tron fills the tx-info contract_address for ordinary TriggerSmartContract
calls, not just deployments, so every contract call was reclassified as a
contract creation and the called contract registered as created-in-this-block
with UnhandledTokenStandard, polluting the contract registry.

A transaction now counts as a deployment only when its eth-style
representation has no recipient (java-tron's JSON-RPC leaves `to` null solely
for CreateSmartContract) and the node reported the deployed address. Nested
"create" internal notes register the child contract in the registry without
changing the top-level type (parity with eth processCallTrace), so factory
calls remain CALLs.

Preparation for enabling processInternalTransactions on Tron (#1660); must
land before any internal-data backfill runs over history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TRX returning from contracts (WTRX unwraps, liquidity removal, swap payouts,
refunds) arrives via internal transactions. With the flag off they were
neither indexed nor counted in getBalanceHistory, so only the outgoing
direction of smart-contract flows was booked and the reconstructed balance
history of DeFi-active addresses drifted billions of TRX negative.

The per-block gettransactioninfobyblocknum response is already fetched for
fees and receipts, so enabling the flag adds no sync-time RPC call - the
internal transfers were parsed and discarded.

Fixes #1660 for newly synced blocks; already-synced history is backfilled by
the internal data backfill introduced in the follow-up commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The upstream main_net_config.conf ships vm.saveInternalTx = false, under
which java-tron does not record internal transactions at all and
gettransactioninfobyblocknum returns them empty. The deployed backend
already runs with the flag on (it serves internal transactions for the
whole history); patch the packaged config to match so a backend package
rebuild does not silently regress it. The nile testnet pinned config
already has saveInternalTx = true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes for how real chain data flows through the Tron internal data
provider, found in PR #1682 review against a live backend:

- A rejected internal transaction (e.g. all frames of an OUT_OF_ENERGY
  transaction) did not execute, but its call value was still booked as
  an internal transfer, injecting phantom flows into the balance
  history this PR fixes. Rejected frames now contribute nothing except
  the existing transaction error flag; unlike eth's tracer path, Tron
  hands over an explicit per-frame rejected flag, so use it.

- The ephemeral create->use->selfdestruct pattern (common, MEV-style)
  corrupted the contract registry: only the first suicide was ever
  detected, and the nested-create guard skipped registering the
  creation of the very contract picked as the top-level destructed
  one, producing destroyed-but-never-created and created-but-never-
  destroyed entries. Creations and destructions are now emitted per
  note in execution order (parity with eth processCallTrace), so a
  destruction always merges into its stored creation. The top-level
  SELFDESTRUCT type is still inferred from the first destroyed
  contract, matching previous behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The contract registry records an ephemeral or factory-deployed child's
lifecycle, but without a transfer the child's own address history never
contains the transaction that created or destroyed it - the address
index is built from transfers. Ethereum's processCallTrace emits CREATE
and SELFDESTRUCT transfers unconditionally; mirror that for create and
suicide notes that moved no TRX. Plain zero-value calls stay skipped,
matching eth's default without processZeroInternalTransactions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ference

Review fixes for the internal transaction pipeline:

- Featured internal transactions (delegateResourceOfEnergy,
  freezeBalanceV2For*, ...) report the staked amount in callValue although
  no TRX moves; unknown notes are no longer mapped to CALL and booked as
  transfers but skipped explicitly (logged at -v1). The packaged nile
  config turns vm.saveFeaturedInternalTx off to match mainnet's data shape.
- `rejected` is per-frame: java-tron rejects single frames inside
  transactions that succeed, so the "internal transaction rejected" note
  flagged successful transactions with an error; on failed ones it added
  nothing over the receipt result. Removed.
- The top-level SELFDESTRUCT inference could not survive packing (the type
  is stored in one bit and the contract is packed only for CREATE) and
  would have surfaced a destroyed contract as createdContract; the
  per-transfer types already round-trip. Removed.
- The zero-value transfer fallback consults deployedContract like the
  registry arm, the CREATE classification comment states the actual
  discriminator (native non-VM operations also have null `to`), and the
  TRC-10 filtering is pinned by tests.
- Empty addresses no longer log in GetAddrDescFromAddress and the unused
  GetInternalDataForBlock keeps the eth error contract (sized data slice).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EthTxToTx drops empty internal data, but buildTxFromHTTPData overwrote
CoinSpecificData with a non-nil pointer again, so every Tron transaction
stored a useless cfInternalData row. Mirror the eth emptiness check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
java-tron precomputes the contract address from the tx hash and reports it
in the transaction info even when the deployment failed, so a reverted
CreateSmartContract registered a contract that does not exist - and a stale
registry row permanently disables GetBalanceHistory for that address. The
CREATE classification and the registry entry are now gated on the receipt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extract_command of both packaged backends changed (mainnet turns
vm.saveInternalTx on, nile turns vm.saveFeaturedInternalTx off), but a
rebuilt package kept the identical 4.7.7-latest version string, so it
would never install as an upgrade. Adopt the repo-wide satoshilabs-N
revision convention, which also sorts above "latest" in dpkg comparison.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cranycrane
cranycrane force-pushed the fix/tron-internal-transactions-1660 branch from 050bc14 to a9fdc32 Compare August 5, 2026 07:54
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.

Tron balance history goes negative: internal-transaction inflows never counted (processInternalTransactions off for Tron)

2 participants