Skip to content

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

Description

@cranycrane

Summary

For Tron, getBalanceHistory accounts for only one direction of smart-contract TRX flows. TRX sent into a contract (the top-level call value) is booked as sent, but TRX that comes back from a contract (unwrapping WTRX, removing liquidity, swap payouts, refunds) arrives via internal transactions, which are never counted as received. The result is a one-directional leak that drags the cumulative balance below zero for any DeFi-active address — Trezor Suite renders this as a balance graph that goes deeply negative, which is impossible for a spendable balance.

Root cause: processInternalTransactions is not enabled in configs/coins/tron.json, so bchain.ProcessInternalTransactions defaults to false. Internal data is therefore neither indexed at sync (bchain/coins/tron/tronrpc.go:883) nor read in balance-history accounting (api/worker.go:1792 early-returns).

Reproduction

  • Address: TT2T17KZhoDu47i2E4FWxfG79zdkEWkU9N
  • Endpoint: WS/REST getBalanceHistory
  • Observed: cumulative received - sent bottoms at ≈ −4.96B TRX; totals received 10,591,741,179.20 / sent 15,295,334,567.64 (net −4.7B).
  • Reality (from /api/v2/address/...): the account is fully solvent — 289.5M liquid + 196.6M staked + 344.7M unstaking ≈ 830.7M TRX. It is impossible to have net-sent 4.7B more than ever received; the received side is being undercounted.

The current balance field is correct (read from account state). Only the reconstructed history is wrong.

Evidence — one transaction, dissected

Tx d54a1abc1c21795c2969881d2a7fb4318c3e24df68879100682dd5f791f9a547 (block 83,534,426) — a SunSwap V3 add-liquidity call.

walletsolidity/gettransactioninfobyid shows two native-TRX internal transfers (all other entries have empty callValueInfo):

internal tx caller → to native TRX
600aa8… position mgr → WTRX contract 177,460,640.803707 (our TRX wrapped into the pool)
dd6b90… position mgr → TT2T17KZ… (owner) 13,245.012561 ← refund back to the address

Arithmetic confirms the top-level call value is not duplicated in internal_transactions (so enabling processing does not double-count the send):

wrap 177,460,640.803707  +  refund 13,245.012561  =  177,473,885.816268  ==  top-level call value  ✅
  • Today (internal OFF): sent 177,473,885.82, received 0 → net −177,473,885.82.
  • Correct (internal ON): sent 177,473,885.82, received 13,245.01 → net −177,460,640.80, exactly the WTRX actually deposited.

This single tx is 100% of the sent in the time: 1781269200 (2026-06-12 13:00 UTC) balance-history bucket. The multi-billion drift comes from the mirror transactions (closing positions / unwrapping WTRX / swap-to-TRX), where call value is 0 (booked sent 0) and the entire principal returns as a native-TRX internal transfer that currently books received 0.

Verification of the existing parser

buildInternalDataFromTronInfos (bchain/coins/tron/tronInternalDataProvider.go) already extracts these transfers correctly (skips empty callValueInfo and TRC-10; ToTronAddressFromAddress handles both the 41… hex form from gettransactioninfobyblocknum and base58 passthrough). processInternalTransactionsForBalanceHistory (api/worker.go:1804-1824) would then credit received += 13,245.01. So the accounting is correct once the flag is on.

Secondary bug exposed by the same data — CREATE misclassification

bchain/coins/tron/tronInternalDataProvider.go:157:

if topType == bchain.CALL && info.ContractAddress != "" {
    topType = bchain.CREATE
    contractAddr = ToTronAddressFromAddress(info.ContractAddress)
}

Tron populates contract_address for ordinary TriggerSmartContract calls, not just deployments (confirmed in the d54a… response, contract_address = TLSWrv…). So every contract call is reclassified as a contract creation and the called contract is registered as "created in this block" with UnhandledTokenStandard. This does not corrupt balance-history amounts (that path reads only d.Transfers), but it pollutes the contract registry / first-seen + token-standard metadata. It should be gated on a real creation signal (tx being CreateSmartContract, or a create/suicide internal note) rather than merely contract_address != "".

⚠️ A full-history backfill would run this heuristic over every block, so this must be fixed before backfilling.

Proposed fix — no full reindex required

  1. Enable processInternalTransactions: true in configs/coins/tron.json (fixes all new blocks). Note: gettransactioninfobyblocknum is already fetched per block for fees/receipts and already contains internal_transactions, so there is no extra sync-time RPC call — the data is currently parsed and discarded.
  2. Backfill history using the existing primitive rather than a reindex: ReconnectInternalDataToBlockEthereumType (db/rocksdb_ethereumtype.go:743) patches internal data into an already-synced block and, via existingBlock=truesetAddressTxIndexesToAddressMap, also inserts the address→tx index entries for internal-only counterparties. It's driven by RefetchInternalDataRoutine (api/ethereumtype.go:50), which drains cfBlockInternalDataErrors.
    • Since the flag was always off, that error table is empty, so a small range-driver is needed: loop heights → GetBlock(h)ReconnectInternalDataToBlockEthereumType(block) (or seed cfBlockInternalDataErrors for the range and trigger RefetchInternalData()).
    • This re-fetches each block from the node (RPC load ≈ resync of the range) but writes only internal transfers + affected address indexes — no rebuild of tx data / token transfers / balances / UTXO indexes.

Caveats to validate

  • Fix the :157 CREATE misclassification first.
  • ReconnectInternalDataToBlockEthereumType was designed for a handful of transient-failure blocks, not full-history backfill. Confirm on a small range that re-adding an address already indexed via its top-level vin/vout does not double its tx count; diff getBalanceHistory + address tx count before/after, then run the full range.

Affected code

  • configs/coins/tron.jsonprocessInternalTransactions absent → defaults false (bchain/coins/eth/ethrpc.go:274)
  • bchain/coins/tron/tronrpc.go:883 — internal data built only when flag on
  • api/worker.go:1792 — balance-history internal accounting gated off
  • api/worker_balance_history_tron.go — Tron balance-history logic
  • bchain/coins/tron/tronInternalDataProvider.go:157 — CREATE misclassification
  • db/rocksdb_ethereumtype.go:743 / api/ethereumtype.go:50 — existing backfill path

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions