[V136] feat: add TRON

Summary

This MR adds TRON (gas asset TRON.TRX, plus TRC20 USDT) to MAYAChain from 1.136.0.

  • It is part 6 of the V136 chain stack. Tracking issue: #325.
  • It targets v136/04-bch. Review only the commits of this branch (v136/04-bch..v136/05-tron).
  • Below 1.136.0 TRON is inert. The chain-activation gate from v136/00-chain-activation keeps every TRON case at the unknown-chain default until the network version reaches 1.136.0.
  • Bifrost ships TRON with disabled: true.

Changes

Consensus ([V136])

  • 9041a1988 common: TRONChain, TRON.TRX (6 decimals, gas unit sun), dust 0.1 TRX, 3 s blocks, base58check addresses, pubkey derivation (0x41 ‖ keccak(secp256k1 key)[12:]). Every case returns the unknown-chain default while !IsActivated(). No short code.
  • 2c2bdc4fe common/tokenlist: TRON TRC20 token list. Empty below 1.136.0. USDT on mainnet/stagenet, Nile USDT on testnet/mocknet builds.
  • 90d8cfa5a x/mayachain: TRON in SUPPORT_CHAINS_VCURRENT, externalAssetMatchV136, NetworkFeeHandler.handleV136 (records the agreed TRON height from the fee heartbeat, never lowers it), GetRandomTRONAddress.
  • a7b4b101e common: refuse any byte outside the base58 alphabet before decoding a TRON address. btcutil base58.Decode panicked on runes above 255 inside NewAddressV136 / IsChainV136(TRON), which run in consensus code. V136 is unreleased, so the function is changed in place.

Bifrost and config ([Version-unspecific])

  • 3f5169cb4 test helper: chain activation for tests in other packages.
  • 68963dd25 mayaclient.GetBlockTimestamp (fails closed: height match, non-zero time, 10 s timeout).
  • ef69bddef config: tron chain block, disabled: true, reference_address, max_gas_limit (200 TRX).
  • 980084e97 the TRON client (bifrost/pkg/chainclients/tron). f1698aee7 wiring: loadchains, allowFutureObservation, exinfo requiresNonZeroGas.
  • THOR parity fixes: 9c5860f90 (D3), 50ad41a13 (D4), 9f0260a78 (D2), 57237505a (D5), 7e11244c6 (D1). See "THORNode references and parity".

Docs, mocknet, tests

  • f9c3d6e39 docs/chains/tron.md (chain notes and runbook), docs/mimir.md.
  • 8ec3a50fd mocknet: java-tron node and TRC20 deployment (compose profile tron).
  • e282fbded simulation: TRON lite client, opt-in (SIM_TRON=true).
  • 9d0d33138 regression suites suites/tron/*. 7e5bbd967 regenerated exports.

Versioning / consensus impact

  • New versioned code: NewAddressV136 / IsChainV136 / getChainV136 (TRON branch only when activated), externalAssetMatchV136, NetworkFeeHandler.handleV136. The replaced bodies (externalAssetMatchV111, handleV135) move unchanged to *_archive.go.
  • NewAddressV136 checks TRON before the ZEC tm/tn/tex prefix guard. Otherwise a TRON address starting TM/TN/TEx would be routed to ZEC.
  • GetAddress returns NoAddress for any inactive chain before the pubkey cache, so a TRON observation fails exactly as on a 1.135 binary.
  • Not added: addGasFeesV136 (THOR's zero-gas sentinel). A free-bandwidth vault tx reports gas of 1 unit (1e-8 TRX). The book is then below the chain, the safe direction.
  • No quote change (solidified scan, 0 confirmations). No Yggdrasil support (StopFundYggdrasil is set on mainnet).

Transaction construction and signing safety

  • Local build. Bifrost encodes TransferContract and TriggerSmartContract itself with a hand-written protowire encoder. No node-built bytes are signed on any path. No java-tron protos are vendored. The encoder reproduces THOR's two java-tron fixtures byte for byte.
  • Decode and verify before signing. decodeRaw accepts only the fields bifrost sets, in strict order, canonical re-encoding, at most 1 KiB. verifyRaw checks owner, destination, amount, contract, the 68-byte transfer calldata, fee limit, memo and the validity window against the TxOut item. It runs on every fresh build and on every checkpoint restore.
  • Determinism across nodes. The reference block comes from the MAYAChain-agreed TRON height, read from the solidity API with number and id checks. The timestamp is the MAYAChain block time. Expiry is +20 min. Every node builds the same bytes.
  • Fee limit. fee_limit = MaxGas[TRX] ×1, capped by max_gas_limit. A missing MaxGas is an error.
  • Pre-sign checks (decide whether to sign, never what): triggerconstantcontract dry-run from the vault must not revert or return false; energy × energy fee ≤ fee limit; vault TRX ≥ fee limit; a TRX payment to a Contract account is not signed.
  • Lifecycle (ZEC style). Broadcast via /wallet/broadcasthex. DUP_TRANSACTION_ERROR counts as success. A cached outbound is re-signed only when its last attempt is past expiry on the solidified chain and is either absent from it or in it with a definitive failure: its contractRet is a java-tron failure result (REVERT, OUT_OF_ENERGY, ...) and, for TRC20, so is its receipt result (the scanner reports that as a vault spend only, so the outbound is still owed). A lookup failure, or a missing, empty, unknown or disagreeing result, never re-signs. When the primary's answer is not definitive and secondary_chain_api is set, the signer asks that second endpoint at once, and re-signs only on its definitive failure.
  • Observed payments. A vault payment observed by any node is recorded in its signer cache (as the EVM clients do), so the nodes of a vault the outbound is moved to do not pay it again.
  • TSS. RemoteSignAtHeight. The signature must recover to the vault address. Signing is locked per vault.
  • Self-observation is decoded from the signed bytes, never from the intent.

TRC20 handling

  • Whitelist = configured addresses ∩ TRON token list (consensus data). Configuration can only narrow it.
  • decimals() / symbol() are verified lazily on chain, with backoff. A mismatch drops the token. Until a token is verified, the scanner retries vault-related blocks for it, SignTx refuses its outbounds and the fee estimate fails. TRX is unaffected.
  • Inbounds are credited from the receipt Transfer event (token, from, to), capped at the call value. No event means no credit, plus an alert.
  • The scanner reads solidified blocks only. A missing or malformed block or receipt is retried, never skipped. Short calldata never panics.
  • Any vault spend that is not a successful whitelisted transfer is observed as OUT:<txid> with its real fee, plus an alert.

THORNode references and parity

Full audit: no class-A gaps. Every THOR TRON follow-up up to c3d15b5dd is present or superseded. The shared-scanner health change of c3d15b5dd was missing and is ported in the review follow-up below (7d29acaea, cd118f913, 5ae12f573, 52407b664, 635765d10, 6a474b90c, 08885891d, e53ab7456, 5f2141c33, e382d2e36, c3d15b5dd, …). No commit has touched thor:bifrost/pkg/chainclients/tron since.

Ported from the audit (bifrost only):

ID Fix Commit
D1 Network-free construction; TRC20 metadata verified lazily. Before, one failed decimals() read or an empty reference_address stopped the whole client, TRX included. THOR makes no node call at construction. 7e11244c6
D2 Sign lock per vault instead of client-wide (cross-vault keysign stalls during churn) 9f0260a78
D3 Transfer-log address decoded as bytes (a token id starting 0x41 was never credited) 9c5860f90
D4 TRX outbounds rounded down to whole sun, as THOR tron_client.go does, instead of refused forever 50ad41a13
D5 Fee heartbeat posts the last good rate when the estimate fails, so the agreed height keeps moving 57237505a

Intentional divergences from THOR (kept): local build vs node-built createtransaction; solidified scan vs tip−1; checksum on addresses; agreed-height reference block vs node-local; expiry lifecycle vs THOR's signer-cache expiry and SkipSignerCacheTRON (a759cb4f9, 9f17b66f8); fee limit ×1 + cap + dry-run; Transfer-event crediting vs calldata; USDT only vs 132 tokens; no zero-gas sentinel (isTronZeroGasTx); no default reference_address.

Deferred: THOR 161ba2adaf (churn out nodes whose keygen chain set omits a served chain). It is a consensus feature, so it is a V137 candidate.

Tests

  • Unit (review follow-up): TestFailedAttemptMayBeSignedAgain, TestFailedTRXAttemptMayBeSignedAgain, TestScanWaitsForAFinalResult, TestObservedPaymentIsNotPaidAgain, TestUnavailableBlockClearsHealth. Each fails on the code before its fix.
  • Unit: activation equivalence at 1.135.0 for every predicate (TestTRONActivation). MsgObservedTxIn/Out fail ValidateBasic before 1.136.0 and pass at 1.136.0. Network-fee agreed height. Encoder fixtures. Tamper rejection (TestVerifyRawRejectsTampering). Checkpoint restore. Deterministic signing across nodes. Pre-sign checks. Expiry lifecycle. Lazy whitelist. Per-vault lock. Whole-sun rounding. Transfer-log address.
  • Fuzz: FuzzDecodeRaw and FuzzDecodeTRC20Transfer in the client. FuzzNewAddressV136 on v136/06-sol found the base58 panic fixed in a7b4b101e.
  • Regression: new tron/* suites, including tron-version-gate (node 1.135.0 rejects, then 1.136.0 accepts) and tron-swap. Full EXPORT=1 run: 150/150 suites, 0 failures, exports unchanged after the parity fixes.
  • Mocknet: full static simulation with SIM_TRON=true passes (283 actors). Run on 2026-10-19. It ran before the THOR-parity fixes; the regression run above includes them.
  • Manual TRC20 round trip on mocknet: deposit credited from the receipt with 6→8 decimal scaling, symmetric add created the pool, withdraw produced a vault-signed TRC20 transfer (13 641 energy, 1.3641 TRX), exact amount received and observed back.
  • Lint (gofumpt, versions, handlers, managers, analyze) and make test pass.

Deferred / open items

  • Reference address. reference_address has no default. A MAYA-owned mainnet account holding USDT must be funded and set identically on every node before TRON is enabled. Then put it in config/default.yaml. Without it, TRX works but TRC20 fees are not estimated.
  • Signer cache (!880 (merged)). TRON does not use SignTxCacheCheck. Its expiry lifecycle already re-signs a dropped outbound once it is expired and absent on chain. No wiring is planned unless reviewers want the SkipSignerCacheTRON mimir as well.
  • ForbidTransferToContract behaviour on mainnet is not verified. The pre-sign check is conservative.
  • A failed add on a pool-less TRC20 is not refunded. This is existing MAYA behaviour, not TRON-specific.
  • Simulation TRC20 deposits are not implemented (the wallet sends TRX only).
  • node-launcher needs the TRON daemon and bifrost settings (separate repo).

Operator notes

Full runbook: docs/chains/tron.md.

  1. Before the 1.136.0 image, set HaltTRONChain=1.
  2. Run a java-tron FullNode (HTTP 8090, solidity 8091, vm.supportConstant=true). Set TRON_HOST, TRON_SOLIDITY_HOST and TRON_REFERENCE_ADDRESS (same on all nodes). Keep BIFROST_CHAINS_TRON_DISABLED=true.
  3. After /mayachain/version reports 1.136.0, enable the client and churn so vaults gain TRON. Then unhalt the chain and set HaltTRONTrading=0.
  4. If USDT blacklists a vault: HaltTRONTrading (plus HaltSigningTRON), then migrate by churn.
  5. Optional: set TRON_SECONDARY_SOLIDITY_HOST to a second, independent solidity API (another node or a provider). It is only asked when a vault transfer result stays unsettled (tron_result_not_final).

CI follow-up

  • 0fc90a6cf clears the trunk check findings in the TRON client: shadowed errors, checked type assertions in tests, the unused lowerHex removed, black/prettier on the mocknet TRC20 deploy files, and the java-tron SUCESS literal kept with a misspell/codespell ignore. externalAssetMatchV136 becomes a switch with the same cases in the same order (gocritic ifElseChain); behaviour is unchanged.

Review follow-up

Three findings from the review, each with a regression test:

  • 6f5aea271 the signer cache treated any solidified attempt as paid. An attempt that landed but failed (OUT_OF_ENERGY, a revert) paid nothing, yet these nodes refused to sign the outbound again, so it stayed stuck when no other vault could pay. Once expired, the attempt's outcome is now read with the scanner's payment rule (contractRet SUCCESS and, for TRC20, a SUCCESS receipt). A definitive failure clears the cache entry (tightened in round 4). One that paid, or whose outcome cannot be read, is still never signed twice.
  • 8693f176b OnObservedTxIn was empty, so the nodes of vault B did not remember a payment of vault A. If the outbound moves to B after A's payment landed, B could pay it again. An observed OUT/REFUND payment now marks the outbound as signed, as in the EVM clients. A failed attempt is observed as OUT:<own txid>, names no outbound, and is not recorded.
  • 171efc4d6 shared blockscanner: a block at or below the tip that cannot be fetched (ErrUnavailableBlock, e.g. a missing receipt) kept the healthy flag, so the node kept signing while no longer observing. Every failed fetch now clears health, as THOR c3d15b5dd does; the block is still retried and never skipped, and the next scanned block restores health (before the network fee check reads it). This applies to every chain: a briefly missing block pauses signing on that chain until the next successful fetch, and the outbound is retried, not dropped.

Review follow-up (round 4)

The branch is restacked on the round-4 fixes of !883 to !887.

  • 3dbc29f14: a TRC20 attempt with contractRet SUCCESS but an empty or missing receipt result, or with any result string bifrost does not know, counted as failed. The cache was cleared and the outbound could be paid twice. An expired attempt is now treated as failed only when its contractRet and, for TRC20, its receipt both give a java-tron failure result (DEFAULT and UNKNOWN are excluded). Any missing, empty, unknown or disagreeing result keeps the attempt signed and is checked again next round. THORNode never re-signs a found attempt.
  • d342c7e61: the scanner reported a vault TRC20 transfer with an empty, missing or unknown result or receipt as a failed vault spend, so a transfer that had paid could leave the outbound owed and paid again by another vault. The scanner now shares trc20Result with the signer. A success in both result and receipt is a payment, and a java-tron failure in both is a failed spend. Anything else from a vault (and a vault TRX transfer with an empty or unknown result) fails the block with ErrUnavailableBlock and the tron_result_not_final alert until the node serves the final result. A TRC20 inbound with incomplete data is retried instead of dropped; a complete non-success inbound is still skipped. A permanent UNKNOWN on a vault transfer stalls the chain until handled manually (runbook entry in docs/chains/tron.md); see the follow-up below for the optional second endpoint.

Follow-up after review round 4

  • 04ad97fe5: after 36 retries (about 3 minutes) of a vault transfer whose result is not final, the scanner asks an optional second solidity API (bifrost.chains.TRON.block_scanner.secondary_chain_api, env TRON_SECONDARY_SOLIDITY_HOST, empty by default). Its answer is used only if it is definitive (paid: SUCCESS with a SUCCESS receipt; failed: a java-tron failure in both) and matches the tx id and block height; then the alert tron_result_settled_by_secondary is raised. Anything else keeps the block retried, as before. This settles a primary that serves an empty, DEFAULT or mismatched answer; a real on-chain UNKNOWN or a result that disagrees with its receipt looks the same on every node and still stalls the chain until handled manually. API URLs are logged as scheme://host only. Runbook: "Alert tron_result_not_final" in docs/chains/tron.md. Tests: TestScanSettlesFromSecondary, TestSecondaryConfiguration.
  • 5249f296b: when the primary gives no definitive answer for an expired attempt, the signer asks the second endpoint at once (a reschedule means MAYAChain already decided). It signs again only on a definitive failure; a definitive success keeps the attempt signed; anything else keeps today's behaviour. The secondary's answer must match the txid, and the primary's solidified block at the height it names must hold the tx. Alert tron_attempt_settled_by_secondary. Test: TestFailedAttemptAsksTheSecondary.
Edited by Bitol Maya

Merge request reports

Loading
Loading