Wallets, keys and recovery
Where every Tron private key on the install lives, what the vault must be unlocked for, the one mistake that makes every key permanently unreadable, and how to export a single customer's mnemonic for a manual sweep.
Every Tron address on your install is an independent private key that only your database holds. There is no master seed behind them, no HD parent, and no way to re-derive one that is lost. That makes the backup story on Tron different from the EVM chains, and it is the part of the platform where a wrong assumption is unrecoverable rather than inconvenient.
Read Master wallets and the vault first. The vault, the passphrase trade-off and the platform-wide rules are there and are not repeated here; this page is the Tron-specific half.
How an address is created
The first time a user opens the deposit page for a Tron-backed currency, the Tron
service generates a fresh BIP-39 mnemonic — a new one per wallet, not an
index under anything — derives a key at m/44'/195'/0'/0/0 through
ethers.HDNodeWallet.fromPhrase, strips the 0x prefix, and asks TronWeb to
derive the base58 address from the private key. If that derivation fails the
whole operation is refused rather than recording a half-made wallet.
195' is Tron's registered BIP-44 coin type, so m/44'/195'/0'/0/0 is the path
a third-party Tron wallet offers by default. That is what makes an exported
mnemonic importable later.
The same function creates the Tron master wallet. Admin → Ecosystem →
Wallets → Master Wallets on chain TRON calls exactly this code, so the master
wallet is a mnemonic and a key at the same path, and nothing is derived from it.
What is stored, and where
The mnemonic, public key, private key and derivation path are serialised to JSON, encrypted with the Ecosystem vault key, and written to one row:
| Where | What |
|---|---|
| Table | wallet_data |
| Unique on | walletId + currency + chain |
chain |
TRON |
currency |
TRX for a native wallet, the token's symbol for a TRC-20 wallet |
index |
Always 0 — it is not a derivation index on this chain |
data |
The encrypted blob: mnemonic, publicKey, privateKey, derivationPath |
The address itself is not in that row. It goes into the address JSON column
on the wallet row, keyed by chain:
{
"TRON": {
"address": "TR7NHq…",
"network": "mainnet",
"balance": 0
}
}The network value is copied from TRON_NETWORK at generation time. It is a
label. Nothing re-reads it and it selects nothing, so on an install whose network
was changed it is simply wrong.
The master wallet's key material is in ecosystem_master_wallet.data, encrypted
the same way. That encrypted blob is the only persisted copy of any key on this
platform — nothing is written to a plaintext file on disk, deliberately, and
there is no "reveal seed phrase" screen anywhere in the admin panel.
An ECO wallet is per currency, and each one generates its own Tron address from its own mnemonic. A user's TRX address and their USDT address are unrelated keys that happen to belong to the same person. This is expected and it is why a support conversation about "their Tron address" needs to name the currency.
The vault has to be unlocked in the process handling the request
The vault key lives in the memory of the process that decrypted it. Setting
ENCRYPTION_KEY_PASSPHRASE in .env unlocks it at boot for every process that
starts; unlocking by hand at Admin → Ecosystem → Initiate Vault unlocks it
only for the process that served that request, and only until it restarts.
On Tron, a locked vault produces three distinct symptoms, and only the first is loud:
A new deposit address is refused outright. First-time wallet creation
pre-tests the vault before it touches anything, so the request fails with a 500
and the message Encryption key is not set. Nothing partial is written.
A backfilled Tron address silently does not appear. When a wallet already
exists and is only missing its TRON entry — a currency that gained a Tron token
later, or an address being regenerated because it does not start with T — the
backfill path has no such pre-test. The Tron generation throws, the failure is
logged and swallowed, and the address map is saved without the Tron entry. The
user sees every other chain's address and no Tron one, with no error on screen.
Search the log for Failed to generate address for token.
A withdrawal is failed and refunded, not held. The withdraw route never
decrypts anything, so the request is accepted and the wallet debited. The failure
happens later in the queue, when the handler decrypts the key — and because
Encryption key is not set is not one of the queue's retry markers, the row is
marked FAILED and the user refunded. Nothing is lost, but the customer sees a
rejected withdrawal rather than a pending one, and re-submitting will fail the
same way until the vault is unlocked.
Deposits are unaffected. Detection and crediting never touch key material, so coins arriving at an address that already exists still credit normally.
Admin → Ecosystem → Blockchains → Requirements → Tron reports the vault as a prerequisite, and downgrades the Withdrawals readiness row to failed while it is locked — even when every live probe passes. That row, not the green ticks, is the one that tells you whether a customer can get their money out.
The mistake there is no recovery from
wallet_data.data and ecosystem_master_wallet.data are AES-256-GCM ciphertext
under the vault key. The vault key is ENCRYPTED_ENCRYPTION_KEY in .env,
itself encrypted under ENCRYPTION_KEY_PASSPHRASE.
Clear ENCRYPTED_ENCRYPTION_KEY and re-run the generator and you get a new
key. Every existing blob was encrypted with the old one and will never decrypt
again. Lose the passphrase and you cannot decrypt the key at all. Either way,
every Tron mnemonic on the install becomes ciphertext nobody can read, and the
coins at those addresses are unreachable — by you, by us, by anybody.
pnpm env-manager refuses to edit either variable for exactly this reason.
Because Tron addresses are independent, there is no seed you could fall back on.
On EVM you could in principle re-derive a user address from the master wallet's
mnemonic. On Tron each address exists in exactly one place — its own encrypted
wallet_data row — so a row missing from a restore is a customer address you can
never sign for again.
A backup is therefore three things taken together, and any one of them alone is worthless:
- The database, including
wallet_dataandecosystem_master_wallet. .env, forENCRYPTED_ENCRYPTION_KEY— stored somewhere the database backup is not.- The passphrase, stored somewhere neither of the first two is.
What the Tron master wallet key is for
On EVM chains the master wallet is an HD parent and losing it strands every user address beneath it. On Tron it is none of that. Its only jobs are:
- Signing TRX top-ups. Tron has no fee-payer primitive, so before a TRC-20 transfer the platform physically sends TRX to whichever address is about to sign, sized to that transfer's energy and bandwidth shortfall. That top-up is signed by the master wallet's key. See Energy and bandwidth.
- Holding the TRX those top-ups come out of.
Nothing else. No user address is derived from it, it deploys nothing — Tron tokens are imported, not deployed — and it never holds customer funds.
So losing the master wallet key strands fee capacity and whatever TRX sits at that address, and no customer address becomes unspendable. Native TRX withdrawals keep working throughout, because they are signed by the address being drained. TRC-20 payouts stop.
The one reason to hold a copy of the master wallet's mnemonic outside the platform is staking. The platform has no freeze, unfreeze or resource-delegation code path at all, so if you intend to stake TRX against the master address for its own bandwidth you will be doing that from a normal Tron wallet, using this key, outside Bicrypto.
Disabling a master wallet is a supported lever: the diagnostics treat a disabled wallet exactly like a missing one and fail withdrawal readiness. Use it to stop signing on Tron if you believe the key is compromised.
There is no delete endpoint for master wallets. Create a new one only after sweeping the TRX out of the old one, because the old row is the only copy of the key that holds it.
Exporting one customer's key for a manual sweep
There are cases the platform cannot resolve for you: an install being decommissioned, a customer whose coins sit at an address the withdrawal path refuses, a chain you are migrating off. The procedure below produces the mnemonic for a single wallet so it can be imported into a normal Tron wallet.
The mnemonic you print controls that address permanently, and there is no way to rotate it — the address is the key. Anyone who sees the output can drain it, including from a shell history file or a terminal scrollback.
Do this on the server, for one wallet at a time, and never in a shared session. Do not paste the output into a ticket, and do not send it to the customer: the platform's own balance for that user is a database number, not a claim on the coins at that address, and handing over the key hands over more than they are owed.
Before you sweep anything, read the warning about pooled sourcing below.
-
Find the row. A wallet is per currency, so pick the currency the customer is missing.
SELECT wd.id, wd.currency, wd.chain, wd.data, JSON_EXTRACT(w.address, '$.TRON.address') AS tron_address FROM wallet_data wd JOIN wallet w ON w.id = wd.walletId JOIN user u ON u.id = w.userId WHERE wd.chain = 'TRON' AND u.email = 'customer@example.com';wd.datais a three-part hex string,iv:authTag:ciphertext. -
Decrypt it. Save this as
tron-export-key.cjsin the project root, where.envis. It reproduces exactly what the backend does — nothing else is needed and it touches no database.const crypto = require("crypto"); require("dotenv").config(); const blob = process.argv[2]; const passphrase = process.argv[3] || process.env.ENCRYPTION_KEY_PASSPHRASE; // Unwrap the vault key: iv:authTag:ciphertext:salt, PBKDF2-SHA512, 100k rounds const [kIv, kTag, kCipher, kSalt] = process.env.ENCRYPTED_ENCRYPTION_KEY .split(":") .map((p) => Buffer.from(p, "hex")); const kdf = crypto.pbkdf2Sync(passphrase, kSalt, 100000, 32, "sha512"); const ku = crypto.createDecipheriv("aes-256-gcm", kdf, kIv); ku.setAuthTag(kTag); const vaultKey = Buffer.from( ku.update(kCipher, undefined, "utf8") + ku.final("utf8"), "hex" ); // Decrypt the wallet blob: iv:authTag:ciphertext const [iv, tag, cipherText] = blob.split(":"); const d = crypto.createDecipheriv("aes-256-gcm", vaultKey, Buffer.from(iv, "hex")); d.setAuthTag(Buffer.from(tag, "hex")); console.log(d.update(cipherText, "hex", "utf8") + d.final("utf8"));node tron-export-key.cjs "<the wd.data value>"It prints
{"mnemonic":"…","publicKey":"…","privateKey":"…","derivationPath":"m/44'/195'/0'/0/0"}.Unsupported state or unable to authenticate datameans the vault key does not match this blob — a restored database against the wrong.env. -
Import it. In TronLink, Ledger Live or any BIP-39 wallet: restore from the twelve words, derivation path
m/44'/195'/0'/0/0. The address it shows must matchtron_addressfrom step 1. If it does not, you have the wrong row — stop, do not send anything.The private key from the blob can be imported directly instead, as raw hex with no
0xprefix, which is what the backend feeds TronWeb. -
Fund it before you sweep. The address pays its own bandwidth, and a TRC-20 transfer also burns energy. An address holding only tokens cannot move them. Send a little TRX first.
-
Delete the script and clear your shell history when you are done.
Tron withdrawals are pooled. When a user's own address does not physically hold
the tokens, the platform sources them from whichever custodial wallet does and
records the draw in ecosystem_private_ledger — so an address's on-chain balance
is not a statement about who owns those coins. See
Withdrawals.
Sweeping an address by hand moves coins the platform still believes it can spend, and the platform will not notice. Reconcile against the ledger before you sweep, not after.
Address activation and the 1 TRX
Tron accounts do not exist until something creates them. An address that has never been used has no on-chain account record, no bandwidth allowance, and cannot originate a transaction — but it receives perfectly well. TRX sent to it arrives and activates it; TRC-20 tokens sent to it are recorded by the token contract regardless.
Two places this shows up:
On a withdrawal to a new recipient. The withdraw route asks the node whether
the destination is activated and, if it is not, records a 1 TRX activation
figure on the transaction. That number is denominated in TRX, it is metadata
only, and it is never added to the customer's token-denominated debit — the
customer pays amount + the token's platform fee and nothing else.
On your own signing addresses. The gas top-up treats an unactivated or unknown account as having zero energy and zero bandwidth and funds it from scratch, which is what activates it. So a user address that has only ever received USDT is activated the first time the platform pays out from it, without anybody doing anything.
The practical consequence is for manual work: an address you have just imported into a third-party wallet holds its tokens fine and cannot send them until it has TRX.
Related
- Addresses and deposits — what happens at those addresses
- Withdrawals — pooled sourcing and which key signs what
- Energy and bandwidth — what the master wallet's TRX buys
- Ecosystem: master wallets and the vault — the shared vault model
- Troubleshooting — "private key not found" and the rest