Skip to content

transatron/examples-privy

Repository files navigation

Privy + Transatron Sandbox

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.

Architecture

        ┌────────────────────┐
        │   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   │
                └────────────────────┘

Key design choices

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.

Prerequisites

  • 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)

Setup

cp .env.example .env
# Fill in the values listed below
pnpm install
pnpm dev

The dev script runs the backend (:4010 by default) and the frontend (:5173) concurrently. Vite restarts on .env changes.

Environment variables

# ── 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:4010

Why 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.

Payment modes

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.

Funding & balance gates

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/config on 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.

How a send works (account mode)

  1. Browser fetches /config (token defaults), /transatron-config (TFN/TFU), and reads on-chain balances directly via TronWeb.
  2. User enters recipient and amount. The submit button is disabled if balance gates aren't met.
  3. On submit:
    • Browser estimates the energy fee with triggerConstantContract and computes a feeLimit.
    • Browser builds the main TRC-20 tx locally via transactionBuilder._triggerSmartContractLocal(...) and prepares it with a solidified ref-block + 1-hour expiration.
    • Privy's useSignRawHash signs the tx hash. The 64-byte ECDSA signature is upgraded to 65-byte r‖s‖v by recovering the v byte against the wallet address.
    • Browser POSTs { mainTx: signedTx } to backend /broadcast.
  4. 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.
  5. Browser shows the tx hash with a Tronscan link, then re-fetches /transatron-config so the balance update is visible immediately.

File layout

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

Sharp edges baked in

  • Privy useWallets() does NOT return TRON wallets. That hook's return type is ConnectedWallet[] which extends BaseConnectedEthereumWallet. TRON (and every other tier-2 chain) is on user.linkedAccounts, filtered by type === 'wallet' and chainType === 'tron'. A naive useWallets().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 sendRawTransaction calls 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_SECRET used to verify sessions.

Re-enabling instant mode

The frontend's submit flow can broadcast in instant mode by:

  1. Calling fetchFeeQuote and fetchDepositAddress (both still exposed in frontend/src/api.ts).
  2. Building a TRX sendTrx to the deposit address sized by quote.feeRtrxInstant.
  3. Signing both txs through Privy.
  4. 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.

Network notes

  • 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).

License

Provided as a reference implementation. Use, fork, adapt freely.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors