Wallets, keys and recovery

What TON key material the platform holds, why the wallet contract version is part of every address, what a backup has to contain to be a backup, and how to recover one customer's TON by hand.

12 min readUpdated 6 August 2026custody, mnemonic, backup, recovery, vault, wallet-version

Every TON address on this install is a full, independent wallet whose secret this platform holds. There is no HD tree, no master seed, and no derivation index — a hundred customers holding TON means a hundred separate 24-word mnemonics sitting encrypted in one database table.

That shape decides what a backup has to contain, what a restore can and cannot recover, and what you can do for a single customer when the platform itself cannot pay them. Read Master wallets and the vault first — the vault, the passphrase trade-off and the two ways to lose an entire install are described there and are not repeated here.

How an address is created

The first time a user opens the deposit page for TON, the platform builds a wallet for them and never builds another one for that wallet row.

  1. A 24-word mnemonic is generated with TON's own mnemonic library, then validated before anything else happens. An invalid mnemonic aborts address generation with Generated mnemonic is invalid.

  2. The mnemonic is turned into an Ed25519 key pair — a 32-byte public key and the secret key that signs.

  3. A wallet contract is created from the public key. The bundled TonWeb builds its default wallet version, which is v3R1. The platform does not override that choice.

  4. The address is read off the contract. On TON an address is the hash of a contract's initial state — its code and its initial data — so the address exists as a string long before anything is deployed on-chain.

  5. The three secrets are encrypted together with the Ecosystem vault key and written to wallet_data as one blob, while the address itself is written into the wallet row's address map.

The words come from the same English wordlist, which is exactly why this mistake is easy to make. The derivation is not the same: TON runs its own PBKDF2-SHA512 scheme with TON-specific salt strings, not BIP-39's seed derivation followed by BIP-32.

Feed a platform-generated mnemonic to a BIP-39 tool, an Electrum-style recovery utility or a hardware wallet's generic seed import and you get a different key, a different address, and an empty balance. It will not warn you — the checksum rules differ, so a BIP-39 validator usually just says the phrase is invalid, and where it does not, it silently derives the wrong wallet.

Recovery has to go through a TON wallet that accepts a TON mnemonic.

What is stored, and where

One row per ECO wallet, in wallet_data, keyed by the unique triple (walletId, currency, chain):

Column Value for TON Notes
walletId The ECO wallet's ID Joins to wallet
currency TON
chain TON The withdrawal handler looks the row up on exactly this pair
balance 0 Written once at creation and not maintained for TON
index 0 Not a derivation index. Always zero; nothing reads it
data The encrypted blob AES-256-GCM, as iv:authTag:ciphertext, all hex

Decrypted, data is a small JSON object with exactly three fields:

{
  "mnemonic": "word word word … word",
  "publicKey": "…64 hex chars…",
  "privateKey": "…128 hex chars…"
}

mnemonic is the 24 words joined by single spaces. publicKey is the Ed25519 public key as hex. privateKey is the Ed25519 secret key as hex — the field name says private key, the value is the 64-byte secret key the signing library produces, and it is what the withdrawal path feeds back in.

The address itself is not in that blob. It lives in the wallet row:

{
  "TON": {
    "address": "UQ…",
    "network": "mainnet",
    "balance": 0
  }
}

network is a label stamped from TON_NETWORK at the moment the address was generated. Nothing re-reads it, and a TON account address is the same string on both networks, so it is a diagnostic breadcrumb rather than a setting.

The encrypted blob is the only persisted copy of that key material. Nothing is written to a plaintext file, no admin screen displays it, and there is no export button anywhere in the product — deliberately, because any file-read or backup leak would otherwise drain every address on the install.

The wallet version is part of the address

This is the single most expensive thing to misunderstand about TON custody.

An address is derived from the public key and the code of the wallet contract holding it. Change the contract version and the same key produces a completely different address. The platform creates v3R1 wallets, and modern mobile wallets default to v4R2 or W5.

Import a platform-generated mnemonic into a wallet that defaults to v4R2 and it will show you a real, valid, brand-new address with a zero balance. Nothing is missing and nothing is broken — you are looking at a different account derived from the same key.

Any recovery has to select v3R1 explicitly. Verify by comparing the address the recovery tool shows against the one in wallet.address before you move anything.

The withdrawal path is built so this can never drift on the platform's side: it rebuilds the wallet from the stored public key, the stored secret key and the stored address, so the reconstructed contract is always the same account the deposits went to, whatever the library's current default happens to be.

What it does not do is look at any other version. Coins sent to a v4 address derived from the same key are invisible to this platform for ever.

Address form: UQ and EQ are the same account

A user-friendly TON address encodes a bounce flag in its first byte, which is what makes it start UQ (non-bounceable) or EQ (bounceable). Both decode to the same workchain and the same account hash.

The platform is non-bounceable everywhere. The address stored at creation is user-friendly and non-bounceable; every address the service re-renders — for fetching transactions, and for both ends of a withdrawal — is user-friendly, URL-safe and non-bounceable.

It is tempting: an explorer will happily show you the EQ… form of a customer's address, and it looks like the same thing to paste into an exchange withdrawal screen. It is the same account, but the flag travels with it.

A transfer sent bounceable to an account whose contract has not been deployed yet is returned to the sender. Every fresh platform address is in exactly that state until its owner's first withdrawal. Non-bounceable is why a first deposit arrives and sits there instead of bouncing, and converting the address undoes that.

Always give out the UQ… string the platform issued.

Two related format details are covered on Deposit addresses and detection: the stored address uses standard rather than URL-safe base64, so it can contain + and / — which the platform's own withdrawal form rejects — and a testnet install still renders the mainnet-style UQ… prefix.

Uninitialised contracts and the sequence number

A TON wallet contract is not deployed by receiving coins. It is deployed by sending them.

Before that, asking the account for its sequence number returns nothing. The withdrawal handler reads it, treats null, undefined and NaN alike as 0, and a transfer signed at seqno 0 carries the contract's initial state alongside it — so the customer's first outgoing transfer deploys the wallet and sends in one operation.

Three consequences worth knowing before you look at an explorer:

  • A brand-new customer address will show as uninitialised or nonexistent on explorers even while it holds a balance. That is normal, not a fault.
  • The first withdrawal from an address costs more in network fee than later ones, because it carries the contract deployment.
  • The seqno read swallows every error. A rate-limited or timed-out read is indistinguishable from a genuinely undeployed contract, and both become 0. On an address that has already sent once, that produces a message the wallet contract will not accept — see Choosing and sizing the TON endpoint.

What a backup has to contain

wallet_data.data is AES-256-GCM ciphertext under the Ecosystem vault key. That key is ENCRYPTED_ENCRYPTION_KEY in .env, itself encrypted under ENCRYPTION_KEY_PASSPHRASE.

Restore the database without .env and you have one row of unreadable ciphertext per customer. Keep .env and lose the database and you have a key that opens nothing. Neither half is a backup on its own.

Regenerating ENCRYPTED_ENCRYPTION_KEY is unrecoverable. Running the generator against an .env where the value has been blanked produces a new random key. Every existing blob was sealed with the old one and will never decrypt again — every TON address on the install becomes permanently unspendable, along with every other chain's. There is no partial recovery and no support workaround.

A backup that actually covers TON custody is three things, stored in at least two places:

  1. The database, including wallet_data (the keys) and wallet (the address map). Both tables — the address map is how you know which account a given blob belongs to without reconstructing it.
  2. .env, or at minimum ENCRYPTED_ENCRYPTION_KEY, kept somewhere your database dumps are not.
  3. The passphrase, kept somewhere neither of the first two live. It is the only thing that opens the key, and it is not in the database.

The raw 32-byte vault key is printed once, on screen, by node ./scripts/kms/generate.mjs when it first creates the value. If you kept that line, it is a second route to your own ciphertext and should be treated with the same care as the passphrase.

The TON master wallet is stored the same way — its own independent 24 words, encrypted with the same vault key, in ecosystem_master_wallet.data. It is not a fallback for anything; see below.

There is no master seed covering the estate

On the EVM chains, every customer address is derived from the master wallet's HD material at the next unused index, so one seed in principle regenerates them all. TON works nothing like that.

Each call that creates a TON address generates a fresh, unrelated mnemonic. The index column is always 0 and is not a derivation index. The TON master wallet is created by the very same routine as a customer wallet — it is one more independent v3R1 wallet that happens to belong to you, and it derives nothing, covers nothing and cannot sign for anything but itself. TON withdrawals never touch it; the customer's own address signs.

Two consequences, pulling in opposite directions:

Losing the TON master wallet strands nothing. Every customer key is self-contained. Create a new master wallet and the chain carries on.

Losing one wallet_data row loses one customer for ever. There is no seed to re-derive it from and no second copy. A restore that silently skips or truncates that table is a set of addresses you can never sign for again — verify row counts after any restore, per chain:

SELECT chain, currency, COUNT(*) FROM wallet_data GROUP BY chain, currency;

Recovering one customer by hand

Use this when the platform cannot pay a customer but the coins are demonstrably at their address — a wound-down install, a chain you have disabled, or a withdrawal path you no longer trust. It is not a routine operation.

Whoever runs these steps can spend that address, permanently, from anywhere. Everything below runs on the server, never on a laptop, never through a paste buffer that syncs, and never into a ticket, a chat window or a screenshot.

Only one party can hold a TON key safely at a time. Once the mnemonic has been imported into a wallet you do not control, treat the address as no longer yours: move every remaining coin out of it and never issue it again.

  1. Find the row. Identify the customer's ECO wallet and take both the address and the ciphertext.

    SELECT w.id                       AS walletId,
           w.address ->> '$.TON.address' AS tonAddress,
           wd.data                    AS blob
    FROM wallet_data wd
    JOIN wallet w ON w.id = wd.walletId
    JOIN user   u ON u.id = w.userId
    WHERE u.email = 'customer@example.com'
      AND wd.chain = 'TON'
      AND wd.currency = 'TON';
  2. Confirm the coins are actually there. Look the tonAddress up on an explorer. A platform balance is a ledger entry; it does not prove the address holds anything. If the address is empty, this procedure recovers nothing and the problem is coverage, not custody.

  3. Unwrap the vault key and decrypt the blob. There is no shipped CLI for this. The following reproduces exactly what the backend does, and should be run from the project root on the server:

    const crypto = require("node:crypto");
    require("dotenv").config();
    
    const PASSPHRASE = process.argv[2];
    const BLOB = process.argv[3];
    
    // 1. unwrap ENCRYPTED_ENCRYPTION_KEY -> the raw vault key
    const [iv, tag, ct, salt] = process.env.ENCRYPTED_ENCRYPTION_KEY
      .split(":").map((p) => Buffer.from(p, "hex"));
    const kek = crypto.pbkdf2Sync(PASSPHRASE, salt, 100000, 32, "sha512");
    const unwrap = crypto.createDecipheriv("aes-256-gcm", kek, iv);
    unwrap.setAuthTag(tag);
    const vaultKey = Buffer.from(
      unwrap.update(ct, undefined, "utf8") + unwrap.final("utf8"), "hex");
    
    // 2. decrypt one wallet_data.data value
    const [iv2, tag2, ct2] = BLOB.split(":");
    const dec = crypto.createDecipheriv("aes-256-gcm", vaultKey,
      Buffer.from(iv2, "hex"));
    dec.setAuthTag(Buffer.from(tag2, "hex"));
    console.log(JSON.parse(dec.update(ct2, "hex", "utf8") + dec.final("utf8")));

    A wrong passphrase fails at the unwrap step. A blob that does not split into three colon-separated parts is not a wallet_data value — the four-part shape is the vault key itself.

  4. Import the 24 words into a TON wallet, selecting wallet version v3R1. Use a wallet that lets you choose the contract version on import; a tool that offers no version choice is the wrong tool for this. Never use a generic BIP-39 or hardware-wallet seed importer — see the warning at the top of this page.

  5. Check the address before you move anything. The wallet must show the same address as tonAddress from step 1. If it does not, you are on the wrong wallet version — go back to step 4 rather than sending a test transaction.

  6. Send the coins to the destination the customer gave you, leaving nothing behind. Remember the network fee comes out of that same balance, so a "send max" that leaves zero will not go through.

  7. Settle the ledger. The platform still believes the customer holds that balance. Adjust their ECO wallet to match reality, and record why. Nothing in this procedure touches the database.

  8. Retire the address. It is now shared. Delete the recovery script, and do not present that address to anyone as a deposit address again.