WORKDEXBUILDING ON TON
SECURITY · SMART CONTRACT AUDITS

How we design smart contracts on TON

Using three of our contracts as examples, we show which rules protect users' funds and how we verify them on every change.

7 min read

A user sends money to a smart contract and from then on relies on nothing but its code. That's why we write contracts so that funds can't be lost or withdrawn twice, can't get stuck forever, and the rules can't be changed after launch.

Below we show how this works using three of our contracts: an escrow for bets, a pool that splits income among its members, and an encrypted note. All of them are written in Tolk and are built and tested in Acton.

The rules are fixed at launch

Our contracts have no code upgrade function and no admin key. Whatever rules the user saw at the moment of deposit are the rules the contract will follow until the last payout.

We keep an owner only where there's no way around it. The pool is created with no owner at all: members and their shares are set at deployment, and the only way to change them is a new contract. In the escrow, only the two parties to a dispute and the arbiter, if they appointed one, can act. The note does have an owner, but they can change only its content, not the contract logic.

If a contract needs improving, we release a new version. It has different code, and therefore a different address, while contracts already created keep running under the old rules. The app keeps a version registry and identifies a contract by its code hash, additionally checking the number the contract returns from codeVersion(). A contract with unfamiliar code is simply not recognised by the app as its own.

How not to lose money in an asynchronous network

On TON, contracts communicate through messages, and each message is processed in its own transaction. If a chain of messages breaks halfway, there is no global rollback. In practice, this is how money is most often lost: a message was handled differently from what the sender expected. So the same rules apply in all our contracts.

  • A plain transfer with no command is always accepted. Such a transfer may arrive without the ability to bounce (non-bounceable), and if the handler fails, the money simply burns. That's why receive never throws on these messages. In the pool, such a transfer becomes the members' income; in the note, it pays for storage.
  • The app sends commands as bounceable. If a check fails (wrong address, window already closed, stake too small), the network returns the transfer to the sender.
  • The contract first writes the new state and only then sends money. If the same message arrives again or late, it sees the closed status and is rejected.
  • TON charges a fee for storing data, and the network may freeze a contract with an empty balance. To prevent this, the contract keeps a small reserve on its balance with every payout. In the escrow it's 0.01 TON.

Why the same share can't be withdrawn twice

The pool doesn't send money to members by itself. It accumulates funds, and each member withdraws their part whenever it suits them. Sending to a list in an asynchronous network would break on the first unreachable recipient and grow more expensive with every new member. This way, a withdrawal costs the same whether there are 2 members or 64: about 7,560 gas units.

The contract calculates how much can be withdrawn at the moment of the request from two numbers: how much has come into the pool in total and how much the member has already taken. Three consequences follow.

  • The withdrawn amount only grows. A repeated request sees that the share has already been paid and pays nothing.
  • When dividing, the contract rounds down. So members are never owed more in total than has come in, and there's always enough for the last one.
  • Gas is paid by whoever performs the operation. The fee for processing a deposit is deducted from the deposit itself, and forwarding a payout is paid for by the person withdrawing. Operations never reduce the overall balance.

In the escrow, the same problem is solved by status. Once settled, the bet moves to a final state and can't be settled a second time.

Why the money won't get stuck

Every escrow state has a timeout exit. If one of the parties disappears, the others' stakes won't be stuck. A bet goes through the pending, active and resolving stages and ends in one of three ways: a payout to the winner, a dispute or a refund.

If the opponent never accepted the bet, the creator can cancel it themselves. Once the acceptance window has closed, anyone can return the stake with the Expire command.

If the votes disagree, one party proposes an arbiter, the other agrees, and the arbiter decides the dispute. An arbiter can be appointed only before the bet deadline; otherwise the parties could extend the dispute retroactively. And if the arbiter never responds, after the arbitration window the bet closes with a refund and no deductions. Participants don't pay for someone else's irresponsibility.

Anyone can trigger a timeout refund, and that's intentional: the money depends neither on our server nor on whether the app is working. In the pool there's nothing to get stuck: each member withdraws their own part and waits for no one.

How we verify this

An immutable contract can't be fixed after deployment, so each of these rules is checked automatically in CI. Until the checks pass, a change won't reach the main branch.

CheckWhat it catches
Contract tests in ActonBroken invariants and incorrect state transitions. The pool has a separate stress test with 400 withdrawals
Gas snapshotAny deviation in gas usage from the baseline. Gas constants are hard-coded, so rising consumption must be noticed before it reaches the network
Wrapper freshnessContract code has drifted from the code the app uses to compute the address. Without this check, money could go to an old version's address
Static analysis (acton check)Common mistakes in Tolk. Findings show up right in the merge request
Deployed code verificationCases where one thing was tested and another was deployed. The code hash at the address is compared with the version registry

In the pool, gas constants include a 25–50% margin over measurements. The contract takes gas prices from the network configuration at runtime. If the network changes its fees, commissions stay correct and the contract doesn't need to be redeployed.

Where these rules are written down

All the rules described here are listed in the header of each contract as a list of invariants and are covered by tests. If a change breaks one of them, a test fails and the change doesn't pass CI.

Such a list also makes external review easier. An auditor doesn't have to reconstruct the intent from the code: they take this list and check the properties one by one.