[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-activationkeeps 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])
9041a1988common:TRONChain,TRON.TRX(6 decimals, gas unitsun), 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.2c2bdc4fecommon/tokenlist: TRON TRC20 token list. Empty below 1.136.0. USDT on mainnet/stagenet, Nile USDT on testnet/mocknet builds.90d8cfa5ax/mayachain: TRON inSUPPORT_CHAINS_VCURRENT,externalAssetMatchV136,NetworkFeeHandler.handleV136(records the agreed TRON height from the fee heartbeat, never lowers it),GetRandomTRONAddress.a7b4b101ecommon: refuse any byte outside the base58 alphabet before decoding a TRON address.btcutilbase58.Decodepanicked on runes above 255 insideNewAddressV136/IsChainV136(TRON), which run in consensus code. V136 is unreleased, so the function is changed in place.
Bifrost and config ([Version-unspecific])
3f5169cb4test helper: chain activation for tests in other packages.68963dd25mayaclient.GetBlockTimestamp(fails closed: height match, non-zero time, 10 s timeout).ef69bddefconfig:tronchain block,disabled: true,reference_address,max_gas_limit(200 TRX).980084e97the TRON client (bifrost/pkg/chainclients/tron).f1698aee7wiring:loadchains,allowFutureObservation, exinforequiresNonZeroGas.- THOR parity fixes:
9c5860f90(D3),50ad41a13(D4),9f0260a78(D2),57237505a(D5),7e11244c6(D1). See "THORNode references and parity".
Docs, mocknet, tests
f9c3d6e39docs/chains/tron.md(chain notes and runbook),docs/mimir.md.8ec3a50fdmocknet: java-tron node and TRC20 deployment (compose profiletron).e282fbdedsimulation: TRON lite client, opt-in (SIM_TRON=true).9d0d33138regression suitessuites/tron/*.7e5bbd967regenerated 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. NewAddressV136checks TRON before the ZECtm/tn/texprefix guard. Otherwise a TRON address startingTM/TN/TExwould be routed to ZEC.GetAddressreturnsNoAddressfor 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 (
StopFundYggdrasilis set on mainnet).
Transaction construction and signing safety
- Local build. Bifrost encodes
TransferContractandTriggerSmartContractitself with a hand-writtenprotowireencoder. 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.
decodeRawaccepts only the fields bifrost sets, in strict order, canonical re-encoding, at most 1 KiB.verifyRawchecks owner, destination, amount, contract, the 68-bytetransfercalldata, 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 bymax_gas_limit. A missing MaxGas is an error. - Pre-sign checks (decide whether to sign, never what):
triggerconstantcontractdry-run from the vault must not revert or return false; energy × energy fee ≤ fee limit; vault TRX ≥ fee limit; a TRX payment to aContractaccount is not signed. - Lifecycle (ZEC style). Broadcast via
/wallet/broadcasthex.DUP_TRANSACTION_ERRORcounts 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: itscontractRetis 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 andsecondary_chain_apiis 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,SignTxrefuses its outbounds and the fee estimate fails. TRX is unaffected.- Inbounds are credited from the receipt
Transferevent (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/OutfailValidateBasicbefore 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:
FuzzDecodeRawandFuzzDecodeTRC20Transferin the client.FuzzNewAddressV136onv136/06-solfound the base58 panic fixed ina7b4b101e. - Regression: new
tron/*suites, includingtron-version-gate(node 1.135.0 rejects, then 1.136.0 accepts) andtron-swap. FullEXPORT=1run: 150/150 suites, 0 failures, exports unchanged after the parity fixes. - Mocknet: full static simulation with
SIM_TRON=truepasses (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 testpass.
Deferred / open items
- Reference address.
reference_addresshas 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 inconfig/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 theSkipSignerCacheTRONmimir as well. ForbidTransferToContractbehaviour 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.
- Before the 1.136.0 image, set
HaltTRONChain=1. - Run a java-tron FullNode (HTTP 8090, solidity 8091,
vm.supportConstant=true). SetTRON_HOST,TRON_SOLIDITY_HOSTandTRON_REFERENCE_ADDRESS(same on all nodes). KeepBIFROST_CHAINS_TRON_DISABLED=true. - After
/mayachain/versionreports 1.136.0, enable the client and churn so vaults gain TRON. Then unhalt the chain and setHaltTRONTrading=0. - If USDT blacklists a vault:
HaltTRONTrading(plusHaltSigningTRON), then migrate by churn. - Optional: set
TRON_SECONDARY_SOLIDITY_HOSTto 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
0fc90a6cfclears the trunk check findings in the TRON client: shadowed errors, checked type assertions in tests, the unusedlowerHexremoved, black/prettier on the mocknet TRC20 deploy files, and the java-tronSUCESSliteral kept with a misspell/codespell ignore.externalAssetMatchV136becomes aswitchwith 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:
6f5aea271the 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 (contractRetSUCCESSand, for TRC20, aSUCCESSreceipt). 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.8693f176bOnObservedTxInwas 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 observedOUT/REFUNDpayment now marks the outbound as signed, as in the EVM clients. A failed attempt is observed asOUT:<own txid>, names no outbound, and is not recorded.171efc4d6shared 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 THORc3d15b5dddoes; 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 withcontractRetSUCCESSbut 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 itscontractRetand, for TRC20, its receipt both give a java-tron failure result (DEFAULTandUNKNOWNare 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 sharestrc20Resultwith 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 withErrUnavailableBlockand thetron_result_not_finalalert 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 permanentUNKNOWNon a vault transfer stalls the chain until handled manually (runbook entry indocs/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, envTRON_SECONDARY_SOLIDITY_HOST, empty by default). Its answer is used only if it is definitive (paid:SUCCESSwith aSUCCESSreceipt; failed: a java-tron failure in both) and matches the tx id and block height; then the alerttron_result_settled_by_secondaryis raised. Anything else keeps the block retried, as before. This settles a primary that serves an empty,DEFAULTor mismatched answer; a real on-chainUNKNOWNor 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 asscheme://hostonly. Runbook: "Alerttron_result_not_final" indocs/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. Alerttron_attempt_settled_by_secondary. Test:TestFailedAttemptAsksTheSecondary.