A minimal, end-to-end reference app showing how to combine Privy embedded wallets with Transatron (Transfer Edge) fee sponsorship to send TRC-20 transfers on TRON without making the user hold TRX for energy.
The sandbox supports both Transatron payment modes side by side:
- Account mode (default) — the user signs one transaction; Transatron deducts the fee from a prepaid TFN/TFU balance held by the operator. Funds the gas, not the user's wallet.
- Instant mode (reference) — the user signs two transactions back-to-back: a TRX fee deposit and the main TRC-20 transfer. Funds come from the user's own wallet, sponsored by Transatron at network speed.
The repo is small, deliberately. It's intended as a starting point you can read in one sitting.
┌────────────────────┐
│ Privy embedded │
│ TRON wallet │
│ (browser-side) │
└────────┬───────────┘
│ signRawHash (32-byte tx hash)
▼
┌────────────────────┐ ┌────────────────────┐
│ Vite + React │ POST │ Express backend │
│ frontend │────────▶│ /broadcast │
│ │ │ │
│ TronWeb reads via │ │ spender-keyed │
│ Transatron URL │ │ TronWeb instance │
│ (non-spender) │ │ (account mode) │
└────────┬───────────┘ └────────┬───────────┘
│ HTTPS, key in URL │ HTTPS, header auth
▼ ▼
┌─────────────────────────────────────────────────┐
│ Transatron RPC (api.transatron.io) │
└────────────────┬────────────────────────────────┘
▼
┌────────────────────┐
│ TRON mainnet │
└────────────────────┘
| Choice | Reason |
|---|---|
| Two Transatron API keys (spender + non-spender) | Privilege separation — the spender key (which can move funds from the Transatron account) never reaches the browser; the non-spender key is safe to embed in a frontend bundle. |
Browser TronWeb points at https://api.transatron.io/<NON_SPENDER_KEY> |
Transatron's RPC has CORS open and authenticated rate limits. Public TronGrid free-tier 429s under typical app traffic. |
| Backend broadcasts via a spender-keyed TronWeb | Account mode is selected purely by which keyed instance broadcasts — no special tx field, no flag. |
Privy useSignRawHash from @privy-io/react-auth/extended-chains |
TRON is a "tier-2" chain in Privy; raw-hash signing is the supported path. The 64-byte signature is converted to TRON's 65-byte r‖s‖v shape by recovering the v byte against the wallet address. |
TronWeb pinned at 6.2.2 |
Matches Transatron's official examples; the underscore-prefixed builder helpers (_triggerSmartContractLocal, _getTriggerSmartContractArgs) are stable in this minor. |
| 1-hour transaction expiration, solidified ref-block | Long enough to handle a slow user, short enough that orphaned txs don't linger. Solidified block immunises against TAPOS micro-fork errors. |
- Node 18+
- pnpm 9+
- A Privy app with TRON enabled under Wallet Configuration
- A Transatron account with both a spender and a non-spender API key
- A small balance on the funding actor relevant to your chosen mode (see "Funding & balance gates" below)
cp .env.example .env
# Fill in the values listed below
pnpm install
pnpm devThe dev script runs the backend (:4010 by default) and the frontend (:5173) concurrently. Vite restarts on .env changes.
# ── Privy ────────────────────────────────────────────────
PRIVY_APP_ID=
PRIVY_APP_SECRET= # server only
VITE_PRIVY_APP_ID= # same value as PRIVY_APP_ID
# ── Transatron — split keys ──────────────────────────────
TRANSATRON_API_KEY_SPENDER= # server only
TRANSATRON_API_KEY_NON_SPENDER= # server side
VITE_TRANSATRON_API_KEY_NON_SPENDER= # same value, exposed to the bundle
TRANSATRON_API_URL=https://api.transatron.io
VITE_TRANSATRON_API_URL=https://api.transatron.io
# ── TRON ─────────────────────────────────────────────────
TRON_NETWORK=mainnet # 'mainnet' | 'nile'
VITE_TRON_RPC=https://api.trongrid.io # fallback only — Transatron URL is preferred
# ── Default token ────────────────────────────────────────
DEFAULT_TRC20_CONTRACT=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
DEFAULT_TRC20_DECIMALS=6
DEFAULT_TRC20_SYMBOL=USDT
VITE_DEFAULT_TRC20_CONTRACT=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
VITE_DEFAULT_TRC20_DECIMALS=6
VITE_DEFAULT_TRC20_SYMBOL=USDT
# ── Servers ──────────────────────────────────────────────
PORT=4010
VITE_BACKEND_URL=http://localhost:4010Why the duplication of TRANSATRON_API_KEY_NON_SPENDER and VITE_TRANSATRON_API_KEY_NON_SPENDER? Vite only exposes env vars prefixed with VITE_ to the browser bundle. Both values are the same; the duplication makes the privilege boundary explicit at the configuration layer.
The spender key never has a VITE_ counterpart — putting it there would ship it to anyone who loads the page.
The default flow is account mode. Instant-mode code paths remain in the codebase as a reference and can be re-enabled with a small change in backend/src/server.ts and frontend/src/App.tsx.
| Mode | User signs | Fee comes from | Backend broadcasts via | Pre-flight gate |
|---|---|---|---|---|
| Account | 1 tx (the main TRC-20 transfer) | Transatron account TFN/TFU balance | Spender-keyed TronWeb | > 5 TFN OR > 2 TFU on the Transatron account |
| Instant | 2 txs (TRX fee deposit + main) | The user's TRX balance | Non-spender-keyed TronWeb | > 5 TRX on the user's Privy wallet |
In both modes the transaction itself is identical — the same _triggerSmartContractLocal build, the same prepareTransaction (solidified ref-block, 1-hour expiration), the same r||s||v signature. The wire-level switch between modes is which keyed TronWeb broadcasts. That's it.
The UI surfaces both balances and disables the send button when the active mode's threshold isn't met:
- Wallet rail shows the user's TRX and TRC-20 balances (read from a public TRON node via the Transatron RPC URL).
- Transatron rail shows TFN / TFU balances on the operator account (read from
/api/v1/configon the backend, gated by Privy session). Goes red below the threshold; submit button changes to "Top up Transatron to send."
The thresholds are minimum-viable for a single TRC-20 transfer to a fresh recipient. Bump them if you sponsor batches or expect frequent activations.
- Browser fetches
/config(token defaults),/transatron-config(TFN/TFU), and reads on-chain balances directly via TronWeb. - User enters recipient and amount. The submit button is disabled if balance gates aren't met.
- On submit:
- Browser estimates the energy fee with
triggerConstantContractand computes afeeLimit. - Browser builds the main TRC-20 tx locally via
transactionBuilder._triggerSmartContractLocal(...)and prepares it with a solidified ref-block + 1-hour expiration. - Privy's
useSignRawHashsigns the tx hash. The 64-byte ECDSA signature is upgraded to 65-byter‖s‖vby recovering the v byte against the wallet address. - Browser POSTs
{ mainTx: signedTx }to backend/broadcast.
- Browser estimates the energy fee with
- Backend broadcasts via the spender-keyed TronWeb instance. Transatron deducts the fee from the account's TFN/TFU balance. The backend translates Transatron's status codes (
SUCCESS,INSUFFICIENT_BALANCE, etc.) into appropriate HTTP statuses. - Browser shows the tx hash with a Tronscan link, then re-fetches
/transatron-configso the balance update is visible immediately.
PrivyTest/
├── .env.example
├── package.json workspace root + concurrently
├── pnpm-workspace.yaml
├── backend/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── env.ts loads ../.env, validates required keys
│ ├── privy.ts verifies the Privy session token
│ ├── transatron.ts two TronWeb instances + /api/v1/config + defensive helpers
│ └── server.ts Express: /config, /transatron-config, /broadcast — plus /deposit-address and /fee-quote kept as instant-mode helpers
└── frontend/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── index.html
└── src/
├── main.tsx PrivyProvider + Buffer polyfill
├── App.tsx UI, send flow, balance gates
├── api.ts authed fetch wrappers
├── tron.ts browser TronWeb (Transatron URL preferred, TronGrid fallback)
└── sign.ts useSignRawHash → r||s||v recovery
- Privy
useWallets()does NOT return TRON wallets. That hook's return type isConnectedWallet[]which extendsBaseConnectedEthereumWallet. TRON (and every other tier-2 chain) is onuser.linkedAccounts, filtered bytype === 'wallet'andchainType === 'tron'. A naiveuseWallets().find(w => w.walletClientType === 'privy')will pick up the user's Ethereum wallet by accident on multi-chain accounts. - The two TronWeb providers (
fullNode+solidityNode) on the spender instance must both carry the spender header. If only one does, account mode silently falls back to non-spender behaviour and you'll spend hours debugging an inert spender. - Don't insert anything between the two
sendRawTransactioncalls in instant mode. No confirmation modal, no polling, no extra Privy prompt. Transatron requires the fee tx and the main tx to arrive back-to-back. - No TRX-burn fallback. If Transatron's broadcast fails, the error propagates verbatim. The backend never silently retries through a different code path.
- The Privy session token is the only auth on backend endpoints. No database, no service account, no shared secret beyond the
PRIVY_APP_SECRETused to verify sessions.
The frontend's submit flow can broadcast in instant mode by:
- Calling
fetchFeeQuoteandfetchDepositAddress(both still exposed infrontend/src/api.ts). - Building a TRX
sendTrxto the deposit address sized byquote.feeRtrxInstant. - Signing both txs through Privy.
- POSTing
{ feeTx, mainTx }to/broadcast.
The backend /broadcast handler currently expects { mainTx } only; flip the schema to either accept both shapes or add a sibling /broadcast-instant endpoint that uses the non-spender TronWeb (transatronTron) and runs both sendRawTransaction calls back-to-back.
- Default network in this sandbox is TRON mainnet. To target Nile testnet, set
TRON_NETWORK=nile,VITE_TRON_RPC=https://nile.trongrid.io, the appropriate testnet TRC-20 contract, and ask Transatron support for a testnet RPC URL if available. - Block explorer: tronscan.org (mainnet) or nile.tronscan.org (Nile).
Provided as a reference implementation. Use, fork, adapt freely.