Withdrawals and fees
How a TON withdrawal is validated, signed from the user's own address and confirmed — the fee model, the hundred-second confirmation window, and the two failure modes that leave money in the wrong place.
A TON withdrawal is unusual among the chains this platform supports: it is signed by the user's own address, using the key material stored for that wallet, and the network fee comes out of the same balance being sent. No master wallet, no gas payer, no custodial contract.
That simplicity has a cost. The address has to hold the coins, and the platform has to find its own transaction afterwards to record a hash. Both of those are failure modes with money attached, so they are covered here in full.
The path a withdrawal takes
-
Two-factor policy. If withdrawal 2FA is enabled, it is checked before any balance is touched.
-
Character gate. The address is trimmed to 200 characters and checked against a conservative allow-list: letters, digits,
-,_and:only. Injection-shaped strings are rejected outright. -
TON address validation. The string is parsed by the TON library, which accepts both the 48-character user-friendly form and the raw
workchain:hexform. -
Internal short-circuit. If the destination belongs to another user on this install, it is settled as an internal transfer with no on-chain transaction. Withdrawing to your own address is refused.
-
Precision check. The amount's decimal places are compared against the token's
precision— ordecimalsif precision is unset. -
Debit under a row lock. The wallet row is locked,
amount + feeis deducted, and aPENDINGtransaction row is written in the same database transaction. -
Queue. The row goes onto the in-memory withdrawal queue, which flips it to
PROCESSING, calls the TON handler, and expects a hash back.
The queue enforces a five-second cooldown between withdrawals on the same chain, specifically to keep Toncenter from rate-limiting a burst.
What the user pays
Two numbers, and only one of them is charged by the platform.
The platform fee is max(amount × percentage, min) from the token's fee
object, denominated in TON. It is added to the debit, so the wallet loses
amount + fee and the recipient receives amount. Leave the token's fee
object empty and TON withdrawals cost your customers nothing.
The network fee is taken by TON itself, from the sending address, on top of the transferred amount. The platform does not estimate it, does not quote it and does not add it to the debit. There is no gas estimation branch for TON — that path exists only for native EVM coins.
The practical consequence is that a TON address can never send its entire balance. The handler checks the amount against the address's live balance and refuses when the amount is greater than or equal to it, so there is always some dust left behind, and that dust has to be enough to cover the fee.
Signing and broadcast
The handler loads the wallet row, reads the TON entry from its address map, and
loads the encrypted wallet_data record for (walletId, TON, TON). Decryption
uses the Ecosystem vault key — a locked vault stops here.
It then rebuilds the wallet contract from the stored public and private key plus the stored address, so the reconstructed wallet is guaranteed to be the same account the deposits went to, regardless of what wallet version the library would default to.
Next it reads the sequence number. A wallet contract that has never sent anything is not yet deployed on-chain and has no sequence number; the handler treats that as zero, and the resulting transfer deploys the contract and sends in one go. This is why a first withdrawal from a given address costs slightly more in network fees than subsequent ones.
The transfer carries a text comment of the form
TON_WITHDRAWAL_<transactionId>_<timestamp>. That comment is not decoration —
it is how the platform finds its own transaction afterwards.
It arrives at the destination as an ordinary TON text message. Exchanges ignore it; individuals will see it in their wallet's transaction history. It contains an internal transaction ID and a timestamp, nothing sensitive.
The confirmation window
After broadcasting, the handler polls for its own transaction: up to ten attempts, ten seconds apart, each fetching the sender's last five transactions and looking for an outgoing message whose body matches the unique comment.
Found: the transaction row is updated to COMPLETED with the hash, and the
withdrawal is done.
Not found within roughly a hundred seconds: the handler throws.
The transfer is broadcast before the polling starts. If the polling loop
exhausts its ten attempts — because Toncenter rate-limited the reads, because
the endpoint was briefly down, or because the network was slow — the handler
marks the row FAILED and rethrows. The withdrawal queue then sees a failed row
with no transaction hash and refunds the user.
At that point the coins are on-chain and the balance has been given back. The platform is short the difference and nothing will notice on its own.
This is the single strongest reason to configure TON_MAINNET_RPC_API_KEY.
Anonymous Toncenter at roughly one request per second is exactly the condition
that starves this loop.
If it happens, the audit trail is usable: search the transaction's description
for "Transaction hash could not be retrieved", then look up the user's TON
address on a block explorer for an outgoing transfer around that timestamp
carrying a TON_WITHDRAWAL_ comment.
The other failure mode: an under-funded address
The platform's ledger balance and the TON sitting at a user's address are two different facts. A user can hold TON on the platform because they bought it on an Ecosystem market, and hold none at their TON address, because nothing was ever deposited there.
When that user withdraws, the handler's balance check refuses, marks the row
FAILED with the description "Not enough balance for withdrawal", and returns
normally rather than throwing.
Because the handler returns instead of throwing, the queue's failure path never
runs. The row stays FAILED, the debit already taken from the wallet is not
returned, the platform fee is recorded as admin profit, and a withdrawal
confirmation email goes out to the user.
Every part of that has to be undone by hand: refund the wallet, reverse the fee record, and tell the customer to disregard the email.
The underlying situation — customers holding a balance the chain does not have — is a coverage problem, not a TON problem. The admin overview computes coverage per asset for exactly this reason. Watch it, and top up the addresses that need it before withdrawals start failing.
Stuck rows and recovery
The withdrawal queue re-enqueues PENDING rows at boot, and a watchdog picks up
rows older than three minutes. Rows found PROCESSING with no hash for more
than five minutes are handled differently per chain, and TON's rule is the
conservative one:
Every non-UTXO handler writes the transaction hash only after the network broadcast succeeds. A crash in the window between broadcast and that write leaves a
PROCESSINGrow with the coins already gone. Reverting it toPENDINGwould sign and broadcast a second transfer.
So the recovery job leaves TON rows alone, logs them, and asks for manual
review. Two guards back this up: a row that already carries a hash is never
failed or refunded, and the FAILED write itself is conditional on the hash
still being null.
A PROCESSING TON withdrawal that has not moved in ten minutes is a human
decision, not a bug. Check the address on an explorer first. If the transfer is
there, set the row COMPLETED with its hash. If it is not, the coins never
left and the row can be failed and refunded.
Related
- Deposit addresses and detection — where the coins are
- Troubleshooting — symptom-first version of this page
- Deposit wallets and custody — the shared queue, its recovery jobs and the private ledger