This guide has two parts: Part 1 explains why Tron runs off a dedicated
contracts-tron fork and how to keep it in sync with this repo (contracts);
Part 2 is the operational how-to for deploying contracts to Tron — read
Part 1 first, since you deploy from the fork and the repo situation is
prerequisite context. For general Tron-vs-EVM technical reference (address
formats, fees, TronWeb code samples), see the internal Tron Datasheet linked
from the SC team's knowledge base.
contracts-tron is a true GitHub fork of this repo that exists solely so
Tron can ship a USDT-safe token-transfer path. It carries a small, deliberate
delta on top of main; it is an overlay, not an independent codebase.
Root cause — Tron USDT is a broken ERC-20. Tron USDT is a legacy
Solidity ^0.4.18 contract (StandardTokenWithFees). It overrides
transfer and declares returns (bool) but forgets the actual return
statement. The EVM then returns the zero-value (32 bytes of 0x00).
Solady's SafeTransferLib.safeTransfer reverts when returndata size is
> 0 and the decoded value is 0 — so every direct transfer of Tron
USDT reverts with TransferFailed. (transferFrom on the same token does
include its return statement, which is why bridges using transferFrom —
AllBridge, Symbiosis — are unaffected.)
This broke every already-deployed contract that sends tokens via Solady
safeTransfer: WithdrawFacet, GenericSwapFacet, GenericSwapFacetV3,
Executor, FeeCollector, TokenWrapper, FeeForwarder (via
WithdrawablePeriphery), and NEARIntentsFacet. USDT is effectively all
Tron volume, and this specifically blocked NEAR Intents — our only
competitively-priced route from Tron to BTC and ETH mainnet, which earns
fees at the bridge level.
Approaches weighed:
LibAssetchain-gate on all chains — add ablock.chainid == TRONbranch insideLibAsset.transferERC20. Single place, but ships Tron-only logic (and ~20 gas overhead) to 60+ other chains and sets a network-specific precedent in shared code.SafeTransferLibWrapper— a wrapper lib swapped in everywheresafeTransferis used. Minimal change, but scatters the concern and leaves many direct-safeTransfercall sites.- Fork (
contracts-tron) — keepmaincompletely free of Tron logic; hold the Tron-specific transfer behavior in a fork used only for Tron deploys.
Decision — a hybrid. Two independent moves:
- In this repo's
main: standardize ERC-20 transfers to route throughLibAsset(removing scattered directsafeTransfercalls) — a codebase-consistency win that is not Tron-specific and is now CI-enforced byenforceLibAssetRouting.yml.maincontains no Tron branching. - In
contracts-tron: the actual Tron USDT-safe behavior lives only here — a bypass branch inLibAsset.transferERC20plus aWithdrawablePeripheryre-route (see the delta below).
Rationale for the fork over the all-chains gate: no Tron-only gas/precedent
burdening 60+ chains, main stays clean, the fork's diff stays small and
Tron-scoped, and "is this contract Tron-ready?" becomes an explicit property
of the fork rather than an unanswerable question in main.
The fork stays close to upstream, but the delta is more than one line:
~20 files across 7 categories (verified by a clean upstream→fork merge
of 69 commits with zero conflicts, then diffing the fully-synced tree
against main). Everything here is Tron-enablement, CI, test, config or
audit — no product features.
- Source (2) —
LibAsset.sol→2.1.3-tron: addsTRON_CHAIN_ID(728126428) andTRON_USDTconstants plus a bypass branch intransferERC20that, for that one token on Tron only, callsIERC20(assetId).transfer(recipient, amount)and returns — skipping the Solady return-value check that the broken token trips.WithdrawablePeriphery.sol→1.0.0-tron: drops the upstreamTODO(EXSC-241)deferral and routeswithdrawTokenthroughLibAsset.transferAsset(so withdrawals inherit the bypass), adding aZeroAmountcheck. First landed incontracts-tronPR #9 / EXSC-315. - Tests (5) — new
MockTronUSDT.sol(mimics the missing-return behavior), addedLibAsset.t.solandWithdrawablePeriphery.t.solcases, and minor tweaks to the ReceiverAcrossV3/V4/OIF/StargateV2 tests. - CI (5) —
olympixStaticAnalysis.yml(skip files identical to upstream),versionControlAndAuditCheck.yml(accept the-tronversion suffix and skip the audit-commit-association check for upstream-inherited audits onsync/upstream-*PRs),syncUpstreamContracts.yml(the weekly sync job itself),tronForkDeltaCheck.yml(the fork-delta guard — see below), andverifyCommitsSigned.ymlremoved (upstream squash-merges break the signed-commit chain downstream). - Scripts (3) —
script/tasks/checkTronForkDelta.ts, its rule enginetronForkDelta.ts, and unit tests: the machinery behind the guard. - Agent rules (2) —
100-solidity-basics.mddocuments the-tronversioning overlay;400-solidity-tests.mduses a Tron test-naming example. - Config (1) —
config/networks.json:somnia.skipHealthcheck = true(see the sync-pain section below). - Audit (2) —
auditLog.jsonentries forLibAsset 2.1.3-tronandWithdrawablePeriphery 1.0.0-tron, plus the2026.05.22_TronCanonicalUSDT(Part-2).pdfreport.
If a change lands only in contracts-tron and grows beyond this shape,
stop and reconsider — it almost certainly belongs here in main instead
(this doc included: it lives here so it flows to the fork through the
normal sync rather than becoming its own untracked delta item).
lifinance/contracts (this repo, upstream) |
lifinance/contracts-tron (fork) |
|
|---|---|---|
| Purpose | Source of truth. All feature dev, all audits, all non-Tron deployments. | Only for deploying to Tron with the USDT-safe transfer path. |
| Feature development | Yes — everything starts here. | Never. No independent feature work. |
| Relationship | — | Overlay on top of main; pulls from it, never pushes back. |
Production deploy rule (unchanged): production is deployed only from the
main branch of the respective repo. A Tron production deploy therefore
runs from contracts-tron's main.
Audits are keyed by ContractName + @custom:version, so one version
must map to exactly one bytecode. The fork uses a -tron overlay scheme:
- Contract identical to
main→ same version (e.g.2.1.3). - Contract differs in the fork →
<main-version>-tron(e.g.2.1.3-tron). - Multiple Tron-only iterations against the same
mainbaseline → append a revision:2.1.3-tron-r2,2.1.3-tron-r3. - Only move to
2.2.0-trononcemainis actually at2.2.0and the fork has synced to that baseline (never imply amainversion that doesn't exist yet). - Conversely, once the fork has synced to
2.2.0, the overlay must move with it — an overlay left at2.1.3-tronon top of upstream's2.2.0code is a version that no longer maps to one bytecode. This is the single most likely way to break the scheme, so CI enforces it (see the fork-delta guard). - GitHub Actions were adjusted to accept versions beyond the plain
{X.Y.Z}shape.
Deployed ↔ repo drift: we deliberately accept that the repo can diverge
from already-deployed bytecode (we do not re-deploy every contract on every
chain for a non-functional change). To keep re-verification possible, the
git commit hash is stored in the deploy log / MongoDB (EXSC-330) — re-verify
by checking out that exact commit, with no dependency on main still
matching deployed bytecode.
Direction: one-way for code, contracts → contracts-tron. The fork
pulls from this repo; it never pushes code back to main. Deploy logs are
the explicit exception — they round-trip back upstream (see Part 2) and
then flow back down through the normal sync.
Current process (automated, PR only on exception):
syncUpstreamContracts.yml on the fork runs weekly (Mon 06:00 UTC, also
workflow_dispatch-able). It merges upstream/main (this repo) into the
fork's main and then decides where the result goes:
| Merge | Gates | Result |
|---|---|---|
| clean | delta guard + tests pass | pushed straight to the fork's main as the lifi-contracts-tron-sync App |
| clean | either gate fails | pushed to sync/upstream-YYYY-MM-DD-<run> + PR, all required checks apply |
| conflicted | not run | conflict markers committed to that branch + PR for manual resolution |
The direct-push path depends on the App being registered as a bypass actor on the fork's "main protection" ruleset (one-time manual setup, EXSC-587 / EXSC-599); without it that push is rejected and the job fails. Branch protection still applies to humans. Reviewer attention is reserved for the exception paths.
Note that a plain merge is genuinely protective: it is not a mirror or an overwrite, so fork-only hunks survive on their own and an upstream edit to the same region raises a conflict rather than clobbering us. What a merge cannot judge is whether the result is still correctly labelled — that is the delta guard's job (next section).
When to update which — the order:
- Feature / fix → land it in this repo's
mainfirst (normal PR + audit + merge). - Tron deploy → sync the fork from this repo's
main, ensure the-trondelta is intact, then deploy from the fork'smain. Deploy logs from that deploy are PR'd back to this repo (not the fork) and then sync back down — see Part 2. - A contract that has a
-tronvariant → update both repos, this repo first, then the fork (bumping the-tronversion).
CI carve-outs on sync PRs: to keep routine sync PRs low-noise, they skip Olympix for files identical to upstream, and skip the audit-commit-association check for audits inherited from upstream (an audit entry the sync PR itself adds is still verified — see the fork-delta guard).
The biggest recurring headache on the fork side: required checks and network healthchecks fire on sync PRs and block them on failures that have nothing to do with the fork.
Concrete example that blocked sync PR #13: an unrelated upstream commit
changed the expected ERC20Proxy owner across networks from the Safe to
the refundWallet. On somnia (newly added via that sync) the on-chain
owner was still the Safe, so the healthcheck failed — on a PR that
introduced zero fork-authored code. Short-term fix: set the existing
skipHealthcheck flag for somnia in config/networks.json on the fork
(temporary).
General class: any required check keyed to on-chain / production state will fire on sync PRs. A new dev should expect this and verify the real diff rather than chase the healthcheck noise.
Everything above protects the overlay's content. The guard protects its label — the property audits actually depend on.
This repo's versionControlAndAuditCheck.yml forces a version bump on any
non-comment change to a src/**/*.sol file. So when upstream changes an
overlaid contract, upstream's version line moves (2.1.3 → 2.2.0) while
the fork's line reads 2.1.3-tron — the same line on both sides, which
means git raises a conflict every time. That is a useful accident: an
upstream change to an overlaid contract can never merge silently.
But the conflict then lands on a human, and nothing checked how they
resolved it. Taking upstream's side on that one line — the natural
one-keystroke resolution — produces a file that still carries the Tron
bypass but claims to be plain 2.2.0. It goes green: the audit check
sees 2.1.3-tron → 2.2.0, looks for an audit entry for LibAsset 2.2.0,
finds the one synced from upstream (where the contract was audited without
the bypass), and passes. The result is a mislabelled contract, CI-approved,
one deploy away from breaking one-version-↔-one-bytecode.
tronForkDeltaCheck.yml runs script/tasks/checkTronForkDelta.ts on every
fork PR, and syncUpstreamContracts.yml runs the same script against the
merge result before anything reaches main. Two invariants, keyed off the
-tron suffix itself — there is no manifest to maintain, because the suffix
is the inventory:
- Every file that carried a
-tronversion before the change still carries one, its baseline still matches upstream's current version for that file, the overlay is still actually present, and any audit-relevant change bumps the version and lands an audit-log entry. - No file diverges from upstream without declaring it with a
-tronversion — so the overlay cannot grow silently.
| Code | Fires when |
|---|---|
TRON_SUFFIX_LOST |
an overlaid file came out of the change without its -tron suffix |
TRON_BASELINE_STALE |
the overlay is 2.1.3-tron but upstream has moved to 2.2.0 |
TRON_DELTA_MISSING |
a -tron file is now equivalent to upstream (clobbered, or obsolete) |
TRON_VERSION_NOT_BUMPED |
an overlaid file changed materially with no version change |
TRON_AUDIT_MISSING |
the new -tron version has no entry in auditLog.json |
TRON_FILE_DELETED |
an overlaid file disappeared |
VERSION_TAG_MISSING |
an overlaid file lost its @custom:version tag |
UNDECLARED_FORK_DELTA |
a file differs from upstream but carries no -tron version |
UPSTREAM_TRON_LEAK |
upstream itself carries a -tron version — the repos have crossed |
"Audit-relevant" uses the same comment/pragma/whitespace filter as
versionControlAndAuditCheck.yml, so the two never disagree about whether a
change needed a bump. Only src/**/*.sol is covered: the rest of the delta
(CI workflows, config/networks.json) carries no version tag and is not
machine-checkable.
Almost always this means upstream bumped an overlaid contract. Then:
-
Re-apply, don't re-resolve blindly. Read upstream's change and check the overlay still does what it should on top of it — for
LibAssetthat thetransferERC20bypass is still on the path every ERC-20 transfer takes; forWithdrawablePeripherythatwithdrawTokenstill routes throughLibAsset.transferAsset. -
Rebase the suffix:
2.1.3-tron→2.2.0-tron(or2.2.0-tron-r2if this is a further Tron-only iteration on that baseline). -
Log the review. The rebased version is a new version and needs its own
auditLog.jsonentry. How much review it needs is a human call, recorded in the PR:- upstream's change does not touch the functions the overlay lives in
→ an internal SC-core review of the re-applied delta is enough; log it
like any other audit, with a written review note at
auditReportPath. - upstream's change does touch them → commission a full external
audit, as for any other
-tronchange.
Upstream's audit of
2.2.0does not cover our delta, which is why an entry is required either way. Write a new audit entry — pointing2.2.0-tronat the previous overlay's audit ID is rejected, because the audit-commit-association check is skipped only for audits inherited from upstream, and that older ID's commit does not live in this PR. A fork-authored entry added by the sync PR is verified normally. - upstream's change does not touch the functions the overlay lives in
→ an internal SC-core review of the re-applied delta is enough; log it
like any other audit, with a written review note at
-
If the guard says
TRON_DELTA_MISSING, upstream may have adopted an equivalent fix. That is good news — retire the overlay deliberately (drop the-tronversion along with the code) rather than patching the check.
Run it locally from a contracts-tron checkout (the scripts are
fork-only and are not present in this repo) against a candidate resolution:
bunx tsx script/tasks/checkTronForkDelta.ts --base origin/main --head HEAD --upstream upstream/main- Never develop features in
contracts-tron. Everything starts here incontracts. - Deploy to Tron only from the fork's
mainbranch. - Keep the fork delta minimal. If this repo refactors
LibAssetorWithdrawablePeriphery, re-check that the TrontransferERC20bypass and thewithdrawTokenre-route still apply cleanly on the fork. - On sync PRs, unrelated required-check / healthcheck failures are expected — confirm the real diff is clean; don't fix on-chain state to satisfy a sync PR. The fork-delta check is the exception: when it fails it is never noise, it is the overlay being mislabelled.
- Resolving a version-line conflict on an overlaid contract: keep our
-tronsuffix and move the baseline, never take upstream's plain version line. See the fork-delta guard. - Watch versioning: one
@custom:version↔ one bytecode. Use-tron/-tron-rNfor anything that differs.
Tron deployments cost TRX (for Energy + Bandwidth — see Tron resource model below). To get funds onto Tron, bridge in via the Symbiosis Bridge.
Deploying to Tron is a round-trip between the two repos. The key rule: deploy logs are committed here, in this repo, never to the fork — the fork receives them back through the normal sync. This keeps this repo the single source of truth for deploy logs on every chain, Tron included.
- Sync the fork first. On
contracts-tron, bringmainup to date with this repo'smain— wait for (or trigger) the weeklysyncUpstreamContracts.ymljob, or merge any pendingsync/upstream-*exception PR if one is open for conflicts / failed gates. (See Sync mechanism above.) - Deploy from
contracts-tron. With the fork synced, run the Tron deploy scripts (see Running the deployment scripts below) from that repo. This writes the deploy logs —deployments/tron.jsonanddeployments/tron.diamond.json— in that working tree. - PR the deploy logs back here, not to the fork. Open a PR with the
updated deploy logs directly against
lifinance/contractsmain— not againstcontracts-tron. Authoring the PR against this repo directly is why the fork still never "pushes back": the logs land in the canonical repo, not via a fork branch. - Let the logs flow back to the fork. Once merged here, those deploy
logs reach
contracts-tronon the next upstream→fork sync (step 1 of the next deploy) — usually a direct push to the fork'smain, or via async/upstream-*PR if gates fail.
Tron requires custom deployment scripts because Foundry doesn't support it
natively. All Tron deployment scripts live in script/deploy/tron/ (on the
contracts-tron fork — they are part of the -tron delta and are only
runnable there since they need the fork's USDT-safe contracts).
TypeScript
- All Tron deployment scripts are written in TypeScript
- Executed using the
bunruntime for fast execution - Type definitions in
script/deploy/tron/types.tsprovide compile-time safety - Async/await pattern used throughout for handling blockchain interactions
TronWeb
- Official Tron JavaScript SDK (equivalent to ethers.js/web3.js for Ethereum)
- Version: 6.0.0 (see
package.json) - Handles wallet management, transaction signing, and contract interaction
- Requires a post-install patch for compatibility
(
script/troncast/postinstall-tronweb-fix.mjs) - Key differences from ethers.js:
- Uses Base58 addresses natively
- Different transaction structure and signing process
- Built-in support for Tron's resource model (Energy/Bandwidth)
script/deploy/tron/
├── TronContractDeployer.ts # Core deployment class
├── constants.ts # Network configs and addresses
├── types.ts # TypeScript type definitions
├── utils.ts # Helper functions
├── deploy-core-facets.ts # Deploys Diamond pattern facets
├── register-facets-to-diamond.ts # Registers facets to Diamond
├── deploy-and-register-periphery.ts # Periphery contracts
├── deploy-and-register-symbiosis-facet.ts # Bridge facets
└── deploy-and-register-allbridge-facet.ts
The .agents/rules/202-tron-scripts.md rule on contracts-tron documents
the TypeScript conventions that apply to everything under
script/deploy/tron/** and script/troncast/**.
Energy vs gas
- Energy: Tron's equivalent to Ethereum gas, consumed by smart contract execution.
- Cost: 1 Energy = 100 SUN (0.0001 TRX) since TRON governance
Proposal #104 (Aug 2025); it was 210 SUN before that and 420 SUN
earlier. This is a live governance parameter — always confirm the
current value via the
getEnergyFeekey inwallet/getchainparameterson a Tron mainnet node (the value is network-specific — Shasta/Nile report different fees) before quoting deployment costs. - Free Energy: Users can stake TRX to get free daily Energy allocation (avoids TRX fees).
- Contract deployment: roughly 200 Energy per byte of deployed
bytecode — so ~175k Energy for the tiny Diamond proxy up to ~2.3M for
a large facet, averaging ~1M per contract (measured against the live
trondiamond, 2026-07). At 100 SUN that is ~18–230 TRX per contract. A full ~24-contract diamond deploy ≈ ~26M Energy (~2,600 TRX) for energy, plus ~230 TRX bandwidth and350 TRX for the diamondCut/registration calls — roughly **3,000–3,200 TRX ($1,000 at $0.33/TRX)** all-in.
Bandwidth
- Purpose: Covers transaction size costs (bytes transmitted over network).
- Cost: 1 Bandwidth = 1000 SUN (0.001 TRX) when paying with TRX.
- Free Bandwidth: Every account gets 600 free bandwidth daily, more available through staking.
- Usage: Simple TRX transfers use ~250 bandwidth, contract calls use more based on data size.
Both resources can be obtained free through staking or paid for with TRX at transaction time.
Per-deploy energy is rented (see Prerequisites
and the Tron Datasheet), but our ongoing Tron timelock operations are
funded by delegated Energy from staked TRX, so we don't burn TRX on
every transaction. Two wallets do the work — addresses live in
config/global.json → tronWallets on the fork (always treat that file
as the source of truth, do not hard-code addresses):
deployerWallet— runsscheduleBatch(~53–54k energy/tx) and deploys contracts. Deployment energy is bought from a rental service, so it is not funded by delegation.devWallet— runsexecuteBatch(~230–360k energy/tx). Executions are now run from here, and this is the expensive operation.
Delegation split — favour the dev wallet. Because executeBatch costs
~4–6× more energy than scheduleBatch, the bulk of delegated energy must
sit on devWallet. Target roughly an 80/20 split in favour of
devWallet (~110k energy on deployerWallet is enough for scheduling).
If most delegation sits on the deployer wallet instead, executeBatch falls
back to burning TRX — historically ~$8–12 per execute.
Topping up TRX / delegation. The automate-wallet-dev-fees repo does
not support Tron, so Tron top-ups and energy delegation are arranged by
pinging Max directly. Typical flow: Max funds and delegates energy to
our Tron wallets; TRX can be moved deployerWallet → devWallet as needed.
Always re-check the split after a top-up, and after any wallet rotation
re-point the delegation at the current config/global.json →
tronWallets addresses (stale delegation on retired wallets is wasted).
TronContractDeployer.ts is the core deploy engine: it estimates
Energy/Bandwidth and TRX cost before deploying, retries on network failures,
and waits for on-chain confirmation. Every deploy-and-register-*.ts script
follows the same shape — read network config, initialize the deployer, read
the Forge artifact, deploy, update the deployment file — so the fastest way
to write a new one is to copy an existing script rather than build the
pattern from scratch (see Adding a new contract).
Read the class and an existing script directly for exact interfaces; they're
not reproduced here to avoid this doc drifting out of sync with the code.
Run these from a contracts-tron checkout, not this repo:
# Set environment
export NETWORK=tron # or tron-shasta for testnet
export PRIVATE_KEY=your_64_char_hex_key_without_0x
# Deploy in order
bun script/deploy/tron/deploy-core-facets.ts
bun script/deploy/tron/register-facets-to-diamond.ts
bun script/deploy/tron/deploy-and-register-periphery.ts
bun script/deploy/tron/deploy-and-register-symbiosis-facet.tsMost integration configs hold one address that is valid on every EVM chain. Tron cannot
reproduce EVM vanity/CREATE3 addresses, so any such reference needs a Tron-specific entry —
config/eco.json, config/allbridge.json and config/symbiosis.json all keep a tron
block for this reason, and config/lifiintentescrow.json carries one for the OIF settlers
(lifiEscrowInputSettler, OIFOutputSettlerSimple).
The Tron deploy scripts read the block for the resolved network (tron in production,
tronshasta otherwise) and throw when it is missing, so the mistake surfaces before any
energy is spent — left unguarded the address resolves to zero and the constructor reverts
on-chain. A Shasta deploy of the intent contracts therefore needs a tronshasta block
added first.
Located in script/troncast/ on the fork, see that repo's
script/troncast/README.md for the full command reference. In short:
# Read contract
bun troncast call <address> "functionName() returns (type)" --env mainnet
# Send transaction
bun troncast send <address> "functionName(type)" <args> --private-key KEYUsed for post-deployment verification and testing.
Tron uses Tronscan instead of Etherscan:
- Mainnet: https://tronscan.org
- Testnet (Shasta): https://shasta.tronscan.org
Verification process:
- Navigate to contract address on Tronscan.
- Click "Contract" tab.
- Click "Verify and Publish".
- Select compiler version (check
foundry.toml). - Upload flattened source (use
forge flatten). - Provide constructor arguments (ABI-encoded).
Tronscan API endpoints (from config/networks.json):
- Mainnet: https://apilist.tronscan.org/api
- Testnet: https://api.shasta.tronscan.org/api
- Write the Solidity contract in
src/Facets/orsrc/Periphery/here, in this repo (see Repo roles), and compile withforge build. - Create a deployment script on
contracts-troninscript/deploy/tron/by copying an existing script as a template — update the contract name and constructor args, following the naming conventiondeploy-and-register-[name].ts. - Follow the end-to-end deploy flow above: sync the fork, run the script, verify on Tronscan, then PR the updated deploy logs back here.
Tickets: EXSC-241 (fix Tron USDT transfers), EXSC-315 (contracts-tron
USDT bypass — PR #9), EXSC-330 (store git commit hash in deploy logs),
EXSC-299 (GenericSwapFacet v1 deprecation), EXSC-575 (this doc), EXSC-587 /
EXSC-599 / EXSC-603 (the automated sync job, its App bypass and its test
gate).
PRs: contracts #1715
(LibAsset routing + Tron work); contracts-tron
#9,
#13,
#15.
External resources:
- Tron Documentation: https://developers.tron.network/
- TronWeb SDK: https://tronweb.network/
- Tronscan API: https://apilist.tronscan.org/api