Skip to content
22 changes: 20 additions & 2 deletions bchain/coins/tron/tronhttp.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

const (
tronMaxIdleConnsPerHost = 64
tronMaxIdleConns = 100
)

type TronHTTP interface {
Request(ctx context.Context, path string, reqBody interface{}, respBody interface{}) error
}
Expand All @@ -19,10 +25,15 @@ type TronHTTPClient struct {
}

func NewTronHTTPClient(baseURL string, timeout time.Duration) *TronHTTPClient {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConns = tronMaxIdleConns
transport.MaxIdleConnsPerHost = tronMaxIdleConnsPerHost

return &TronHTTPClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: timeout,
Timeout: timeout,
Transport: transport,
},
}
}
Expand All @@ -43,7 +54,14 @@ func (c *TronHTTPClient) Request(ctx context.Context, path string, reqBody inter
if err != nil {
return fmt.Errorf("HTTP error calling Tron API %s: %w", path, err)
}
defer resp.Body.Close()
// Drain the body before closing so net/http can return the connection to the
// idle pool for keep-alive reuse. Without this, the >=300 early return (which
// reads nothing) and any bytes the JSON decoder leaves unconsumed would force
// the connection closed, defeating the MaxIdleConns* pooling configured above.
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()

if resp.StatusCode >= 300 {
return fmt.Errorf("Tron API returned status %d at path: %s %s", resp.StatusCode, c.baseURL, path)
Expand Down
27 changes: 27 additions & 0 deletions bchain/coins/tron/tronhttp_endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ type tronGetBlockResponse struct {
}

type tronGetBlockHeaderResponse struct {
BlockID string `json:"blockID"`
BlockHeader struct {
RawData struct {
Number *uint64 `json:"number"`
Expand Down Expand Up @@ -467,6 +468,32 @@ func (b *TronRPC) requestBlockByID(ctx context.Context, blockHash string, isSoli
return &resp, nil
}

// requestBlockHashByNum resolves a block hash by height via the solidity node's
// /walletsolidity/getblock endpoint. It is only called for already-solidified
// heights (see GetBlockHash), so the lookup always targets the solidity node.
func (b *TronRPC) requestBlockHashByNum(ctx context.Context, blockNum uint32) (string, error) {
req := map[string]any{
"id_or_num": strconv.FormatUint(uint64(blockNum), 10),
"detail": false,
}
http := b.getLookupHTTPClient(true)
var resp tronGetBlockHeaderResponse
if err := http.Request(ctx, "/walletsolidity/getblock", req, &resp); err != nil {
return "", err
}
if resp.BlockHeader.RawData.Number == nil {
return "", errors.Errorf("Tron getblock returned missing block_header.raw_data.number for height %d", blockNum)
}
if *resp.BlockHeader.RawData.Number != uint64(blockNum) {
return "", errors.Errorf("Tron getblock returned height %d, want %d", *resp.BlockHeader.RawData.Number, blockNum)
}
hash := strip0xPrefix(resp.BlockID)
if hash == "" {
return "", errors.Errorf("Tron getblock returned empty blockID for height %d", blockNum)
}
return hash, nil
}

func (b *TronRPC) requestLatestSolidifiedBlockHeight(ctx context.Context) (uint64, error) {
http := b.solidityNodeHTTP
if http == nil {
Expand Down
49 changes: 15 additions & 34 deletions bchain/coins/tron/tronrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,16 @@ func (b *TronRPC) GetBestBlockHash() (string, error) {

// GetBlockHash returns block hash in Tron API format (without 0x prefix).
func (b *TronRPC) GetBlockHash(height uint32) (string, error) {
if b.isBlockSolidified(uint64(height)) && b.solidityNodeHTTP != nil {
ctx, cancel := context.WithTimeout(b.requestContext(), b.Timeout)
defer cancel()
hash, err := b.requestBlockHashByNum(ctx, height)
if err == nil {
return hash, nil
}
glog.V(1).Infof("Tron native getblock hash lookup failed for height %d, falling back to JSON-RPC: %v", height, err)
}

hash, err := b.EthereumRPC.GetBlockHash(height)
if err != nil {
return "", err
Expand Down Expand Up @@ -347,21 +357,6 @@ func (b *TronRPC) GetBlockInfo(hash string) (*bchain.BlockInfo, error) {
}

func (b *TronRPC) getBestHeader() (bchain.EVMHeader, error) {
// During initial sync (before ZeroMQ is initialized) there is no push-based
// tip refresh, so always read the latest header from the backend.
if b.mq == nil {
_, err := b.refreshBestHeaderFromChain()
if err != nil {
return nil, err
}
b.bestHeaderLock.Lock()
defer b.bestHeaderLock.Unlock()
if b.bestHeader == nil || b.bestHeader.Number() == nil {
return nil, errors.New("best header is nil")
}
return b.bestHeader, nil
}

b.bestHeaderLock.Lock()

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.

Heads-up on the removed if b.mq == nil { refreshBestHeaderFromChain() } block: getBestHeader now serves a cached tip for up to tronBestHeaderMaxAge (30s), and the watchdog that refreshes it only starts in InitializeMempool, which runs after the initial ResyncIndex. So during initial bulk sync (and in polling-only mode before mempool init) the target tip and computeBlockConfirmations can lag the chain by up to 30s where they were previously always fresh. This looks like the deliberate trade-off from the "reduce requests per block" commit and is benign for far-behind sync — just confirming it's intended and that the syncer can't briefly treat the stale tip as fully-synced.

cachedHeader := b.bestHeader
cachedAt := b.bestHeaderTime
Expand All @@ -384,22 +379,6 @@ func (b *TronRPC) getBestHeader() (bchain.EVMHeader, error) {
return b.bestHeader, nil
}

// setBestHeader stores h as the cached tip and reports whether it changed.
//
// Unlike EthereumRPC.setBestHeader this is intentionally NON-monotonic: a lower
// height is accepted. Tron's tip is never taken from the feed header (the ZeroMQ
// notification carries none) — it is always an HTTP re-query (refreshBestHeaderFromChain),
// refreshed on every notification and on a tronBestHeaderMaxAge timer, so the cache
// is meant to track whatever the backend currently reports. Accepting a lower height
// is what lets a genuine rollback surface immediately to resyncIndex, so Tron is not
// subject to the frozen-tip masking that the EVM monotonic guard introduces (and which
// EVM has to undo with a watchdog regress).
//
// Tradeoff: with a load-balanced Tron RPC, a single lagging node answering a re-query
// could regress the tip and trip a spurious fork in resyncIndex (the case the EVM
// monotonic guard exists to prevent). That is acceptable for the common single-node
// java-tron backend; if Tron is ever fronted by a load balancer, port the EVM pattern
// here (monotonic hot path + on-advance liveness + allowRegress watchdog poll).
func (b *TronRPC) setBestHeader(h bchain.EVMHeader) bool {

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.

This branch deleted the doc comment that explained setBestHeader is intentionally non-monotonic — it accepts a lower height so a genuine rollback surfaces immediately to resyncIndex, unlike EthereumRPC.setBestHeader's monotonic guard, plus the load-balancer caveat. The body is unchanged, so that rationale is still accurate. Consider restoring a trimmed version: without it, a future maintainer "harmonizing" Tron with the EVM path could re-add a monotonic guard and silently reintroduce frozen-tip masking on rollbacks.

if h == nil || h.Number() == nil {
return false
Expand Down Expand Up @@ -697,11 +676,11 @@ func (b *TronRPC) computeBlockConfirmations(blockNumber uint64) (uint32, error)
return uint32(bestHeight - blockNumber + 1), nil
}

func (b *TronRPC) buildTxFromHTTPData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetTransactionInfoByIDResponse, blockTime int64, confirmations uint32, internalData *bchain.EthereumInternalData, isSolidified bool) (*bchain.Tx, error) {
func (b *TronRPC) buildTxFromHTTPData(txByID *tronGetTransactionByIDResponse, txInfo *tronGetTransactionInfoByIDResponse, blockTime int64, confirmations uint32, internalData *bchain.EthereumInternalData, keepReceipt bool) (*bchain.Tx, error) {
csd := tronBuildEthereumSpecificData(txByID, txInfo)
csd.InternalData = internalData

if !isSolidified {
if !keepReceipt {
csd.Receipt = nil // set to nil so it can be considered as pending
}

Expand Down Expand Up @@ -948,7 +927,9 @@ func (b *TronRPC) GetBlock(hash string, height uint32) (*bchain.Block, error) {
txInternalData = &internalData[i]
}

rebuiltTx, err := b.buildTxFromHTTPData(txByID, txInfo, bbh.Time, confirmations, txInternalData, isSolidified)
// Keep receipts for block indexing even before solidity. If logs are dropped
// here, token-transfer participants never reach the address/contract indexes.
rebuiltTx, err := b.buildTxFromHTTPData(txByID, txInfo, bbh.Time, confirmations, txInternalData, true)

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.

This fixes the indexing path, but the serving path stays asymmetric: GetTransaction (line 1044) and GetTransactionForMempool (line 1074) still pass isSolidified/false, so for a TRC-20 transfer in a mined-but-not-yet-solidified block (~19-block window) the transfer is indexed with its receipt here, yet a live tx-detail fetch of the same tx returns Receipt=nil, pending status, and zero token transfers. The address page would then list the transfer while the transaction page shows none — the same index-vs-detail disparity #1576 targets, on the serving side. The PR description says the GetTransaction/mempool paths are intentionally left pending until solid; flagging to confirm that user-visible inconsistency is acceptable, or whether GetTransaction should also keep the receipt once the tx is in a block.

if err != nil {
return nil, err
}
Expand Down
57 changes: 57 additions & 0 deletions bchain/coins/tron/txextra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,3 +512,60 @@ func TestTronBuildTxFromHTTPData_WithSynthesizedGenesisData(t *testing.T) {
require.NotNil(t, receipt)
require.Equal(t, "0x1", receipt.Status)
}

func TestTronBuildTxFromHTTPData_KeepReceiptControlsTokenLogs(t *testing.T) {
txByID := &tronGetTransactionByIDResponse{
TxID: "b7a97862b0c719b714f0cf7e250ebc2dcdf1e5f05c54ddb21ff3a748c1aa45e4",
}
txByID.RawData.Contract = []tronTxContract{{
Type: "TriggerSmartContract",
}}
txByID.RawData.Contract[0].Parameter.Value.OwnerAddress = "410746a05c314538e3e21faae3d702cc7939efc07a"
txByID.RawData.Contract[0].Parameter.Value.ContractAddress = "4139dd12a54e2bab7c82aa14a1e158b34263d2d510"
txByID.RawData.Contract[0].Parameter.Value.Data = "6f21b898"

txInfo := &tronGetTransactionInfoByIDResponse{
ID: txByID.TxID,
Log: []*bchain.RpcLog{
{
Address: "a614f803b6fd780986a42c78ec9c7f77e6ded13c",
Data: "000000000000000000000000000000000000000000000000000000002d4cae00",
Topics: []string{
"ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0000000000000000000000000746a05c314538e3e21faae3d702cc7939efc07a",
"000000000000000000000000f14cca91e8d5fff29cbcd42b26a19a7395b1aa84",
},
},
},
}
txInfo.Receipt.Result = "SUCCESS"

parser := NewTronParser(1, false)
tronRPC := &TronRPC{
Parser: parser,
}

pendingTx, err := tronRPC.buildTxFromHTTPData(txByID, txInfo, 0, 0, nil, false)
require.NoError(t, err)
pendingSpecific, ok := pendingTx.CoinSpecificData.(bchain.EthereumSpecificData)
require.True(t, ok)
require.Nil(t, pendingSpecific.Receipt)
pendingTransfers, err := parser.EthereumTypeGetTokenTransfersFromTx(pendingTx)
require.NoError(t, err)
require.Empty(t, pendingTransfers)

indexedTx, err := tronRPC.buildTxFromHTTPData(txByID, txInfo, 0, 1, nil, true)
require.NoError(t, err)
indexedSpecific, ok := indexedTx.CoinSpecificData.(bchain.EthereumSpecificData)
require.True(t, ok)
require.NotNil(t, indexedSpecific.Receipt)
require.Len(t, indexedSpecific.Receipt.Logs, 1)

indexedTransfers, err := parser.EthereumTypeGetTokenTransfersFromTx(indexedTx)
require.NoError(t, err)
require.Len(t, indexedTransfers, 1)
require.Equal(t, "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", indexedTransfers[0].Contract)
require.Equal(t, "TAdgLcNFZjeF1AspMobkz2bbxs5yDXpwYx", indexedTransfers[0].From)
require.Equal(t, "TXy5qqpAJNykdqsMU2dZM7B7mZbVF2izAN", indexedTransfers[0].To)
require.Equal(t, "760000000", indexedTransfers[0].Value.String())
}
5 changes: 5 additions & 0 deletions docs/tron-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ Set explicit `tron_fullnode_http_url_template` and `tron_solidity_http_url_templ
* the scheme differs from the JSON-RPC endpoint
* the deployment uses non-standard ports

## Sync Performance Tuning

For initial-sync tuning notes, see [Tron Sync Tuning](/docs/tron-sync-tuning.md).

## Related Docs

* [Config](/docs/config.md)
* [Tron Sync Tuning](/docs/tron-sync-tuning.md)
* [Testing](/docs/testing.md)
78 changes: 78 additions & 0 deletions docs/tron-sync-tuning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Tron Sync Tuning

Fast Tron initial sync needs tuning on both sides of the RPC boundary. In the
observed setup, the bottleneck was backend block-hash/block lookup latency, not
Blockbook Go GC or RocksDB writes.

## java-tron

Keep the backend on LevelDB, keep transaction history enabled, enable the LevelDB read tuning and raise the
HTTP connection limit enough for Blockbook's worker count:

```hocon
storage {
db.engine = "LEVELDB"
db.sync = false
transHistory.switch = "on"

# LevelDB read tuning from Tron TIP-343. Raises fd usage.
default = {
maxOpenFiles = 100
}
defaultM = {
maxOpenFiles = 500
}
defaultL = {
maxOpenFiles = 1000
}
}

node {
# With Blockbook -workers=32, start around 200.
maxHttpConnectNumber = 200
}
```

Run java-tron with enough heap, but leave RAM for the OS page cache. On large
hosts, `-Xms32g -Xmx32g` with G1GC is a good starting point. Validate with GC
logs and disk latency; a larger heap is not automatically faster.

Keep event subscription minimal for Blockbook sync:

```hocon
event.subscribe {
topics = [
{ triggerName = "block", enable = true, topic = "block", solidified = false },
{ triggerName = "solidity", enable = true, topic = "solidity" }
]
}
```

Do not enable `transaction`, `contractevent`, or `contractlog` topics just for
Blockbook initial sync.

## Blockbook

Run initial sync with enough workers to keep java-tron busy:

```ini
ExecStart=.../blockbook ... -sync -workers=32 ...
```

Pair this with a sufficiently large Tron HTTP connection pool in Blockbook and
`node.maxHttpConnectNumber` in java-tron. Raising workers without raising the
backend connection limit just moves the queue.

## Verify

Useful indicators while syncing:

* Blockbook log `elapsed` per 1000 blocks
* `blockbook_rpc_latency{method="GetBlockHash"}` and `method="GetBlock"`, increased latency means the bottleneck is on the Java-Tron side
* java-tron GC pauses and heap after GC
* disk utilization and await, for example `iostat -xz 1`
* open file descriptors for both services
* `blockbook_index_block_not_found_retries`

With the tuning above and native hash lookup, observed throughput reached about
7.5-9.5 seconds per 1000 historical blocks on the tested setup.
Loading