If you come to TON from an EVM background, gas feels like a solved problem you already understand. You pay for computation, the caller covers it, transactions revert if they run out. You have done this a hundred times on Ethereum. So you write your first Tact contract, it compiles, it works in testing, and then in production something quietly goes wrong with the money math, or a contract slowly bleeds its balance, and you realize TON's model was never the same model you assumed.
I ran a non-custodial escrow contract into most of these walls while building on TON. This post is the guide I wish I had read first: the gas and storage realities that do not exist in the EVM mental model, and the specific decisions they forced into my contract.
The core surprise: your contract pays to exist
On Ethereum, storage is a one-time cost. You pay gas to write it, and then it just sits there forever, for free, as long as the chain exists. Nobody charges you rent on a mapping.
On TON, contracts pay storage rent. Your contract is charged over time for the data it holds, and that charge comes out of the contract's own balance. If the balance runs low enough, the contract can eventually be frozen or deleted. This single fact reshapes how you think about a contract that holds state, because now state is not a write-once cost, it is an ongoing liability against a balance you have to keep funded.
For an escrow contract this is a genuine hazard, because the balance the storage rent eats into is, in the naive design, the same balance holding user funds. You do not want the mechanism that keeps your contract alive to be quietly nibbling at money that belongs to your users. That tension shaped almost every gas decision that followed.
Sending money costs money, and you have to plan for it
Here is the second thing the EVM mental model gets wrong on TON. On TON, sending value is done by dispatching a message, and dispatching a message has its own gas cost. That cost has to come from somewhere.
The naive version: a user sends funds into your contract, you record the amount, and later you send funds back out. But the outbound send costs gas. If you recorded the full incoming amount as "owed" and then pay send fees out of the same pot, your accounting drifts. You are now paying message fees out of value you promised to someone else, and across many escrows that drift compounds into a real shortfall.
So on TON you cannot treat "the amount the user sent" and "the amount you can pay out" as the same number by default. The gas for every future outbound message related to this deal has to be accounted for at the moment value comes in, not assumed away.
How I handled it: an explicit, bounded gas budget
The pattern I landed on was to make the gas budget an explicit part of every escrow, collected up front and bounded on both sides.
When an escrow is created, the contract requires the incoming value to cover the agreed amount plus a gas budget, and it rejects anything outside a sane window:
const MIN_GAS_BUDGET: Int = 50000000; // 0.05 TON
const MAX_GAS_BUDGET: Int = 300000000; // 0.3 TON
// on escrow creation:
require(ctx.value >= required + MIN_GAS_BUDGET, "Insufficient deposit");
require(ctx.value <= required + MAX_GAS_BUDGET, "Overpayment: reduce attached value");
Two decisions are doing work here.
The minimum guarantees every escrow arrives with enough attached value to fund the outbound messages it will eventually need for settlement. The contract is never in a position where it has to pay someone out but cannot afford the message to do it.
The maximum is the less obvious one, and I added it after thinking about failure modes. Without an upper bound, a user fat-fingering an amount, or a buggy frontend, could attach a wildly excessive value. Bounding the top end turns a silent overpayment into an explicit, recoverable rejection. On a contract that handles money, a loud revert is almost always better than a quiet acceptance of the wrong number.
The key mental shift: the deposit and the gas budget are two separate quantities that happen to arrive in the same transaction. Keeping them conceptually distinct is what keeps the fund accounting honest.
Send modes are not boilerplate
On TON, when you send a message you specify a mode, and the mode is a real decision with real consequences, not ceremony to copy from a template. Two flags mattered most in my settlement path:
send(SendParameters{
to: recipient,
value: amount,
mode: SendPayGasSeparately | SendBounceIfActionFail
});
SendPayGasSeparately means the message's forward fees are paid from the contract balance rather than deducted from the value being sent. For a payout, this is what you want: the recipient should receive the amount you intended, not the amount minus whatever the network decided the forwarding cost was. If you skip this, the person you are paying quietly receives slightly less than the number in your logic, and your accounting and reality diverge again.
SendBounceIfActionFail is a safety belt. If the action fails, you want to know, through a bounce, rather than having value silently vanish into a failed send. On a contract holding user funds, "fail loudly" is the correct default, and choosing the mode that surfaces failures is part of writing money-safe TON code.
The broader lesson: on TON, the send mode is part of your contract's correctness, not a detail. The difference between two mode flags can be the difference between correct payouts and a slow, invisible leak.
The scaling trap I did not fully escape
There is one more TON-specific reality worth naming, because it is the one I am still living with.
The idiomatic TON design for something like escrow is a contract per item: a factory that spawns a separate child contract for each escrow, each with its own balance and its own storage rent. The reason is exactly the storage and gas model above. When every escrow is its own actor, the storage rent for each is funded by and isolated to that escrow, and the contract's growth does not turn into an ever-rising per-transaction cost.
I did not start there. My first design kept all escrows in one contract, which is comfortable coming from the single-contract EVM habit, and it works, but it fights the grain of the platform. As the state grows, so does the cost of touching it, and the shared balance has to cover shared storage rent. On TON this is the anti-pattern, and knowing that earlier would have changed my first architecture.
If you are starting a stateful TON project now, take the per-item contract model seriously from day one. It is not premature optimization on TON. It is the model the chain's economics are actually built around.
What I would tell my past self
TON's gas and storage model is not EVM with different names. The differences are structural, and each one has a direct consequence for a contract that holds money:
Storage costs rent. Design so that rent never eats user funds, and prefer per-item contracts so rent stays isolated.
Outbound messages cost gas. Collect that gas budget up front, keep it conceptually separate from the deposit, and bound it on both ends.
Send modes are correctness. Pay fees separately on payouts, and choose modes that fail loudly rather than leak quietly.
None of this is exotic once you internalize that a TON contract is a living actor with its own balance and its own upkeep, not a static object that lives for free. But almost none of it is what the EVM mental model prepares you for, and the gap is exactly where the quiet, money-losing bugs live.
I build NovaCont, a non-custodial escrow protocol on Base and TON. Contract addresses and security notes are public. Docs.

Top comments (0)