TonEscrow
TON Blockchain

Escrow Smart Contract

The provider and the client put money into a dedicated account. The funds are released once the work is done, by mutual agreement.

How it works

How it works

Five steps to a contract

Create a smart contract with a simple wizard.

  1. Step 1Your role in the contractChoose your role in the contract. There are only two roles: client and provider. There is no third-party arbitration.
    9:41LTE

    Your role in the contract

    Your role in the contract

    The client is the one ordering the service or goods. The client pays 100% upfront, plus an optional refundable deposit of up to 30%.

    The provider supplies the service or goods. The provider pays a refundable deposit (30% of the contract amount by default).

  2. Step 2Choose a currencyChoose the contract currency. GRAM, TON's native currency, suits quick and small deals, since it's fairly volatile. USDT is a more stable currency for large and long-term deals.
    9:41LTE

    Choose a currency

    Contract currency

    GRAM: faster setup. You can create the contract and deposit funds in a single transaction.

    USDT: two-step setup. First the contract is created (your wallet needs some native GRAM, or it must support gasless transactions; GRAM is preferred), then you deposit USDT.USDT is less volatile than GRAM.

  3. Step 3Contract amountEnter the contract amount in the chosen currency. This amount will be paid to the provider once the work is done.
    9:41LTE

    Contract amount

    GRAM

    The contract amount is the price of the work. It is paid by the client.

    30%

    From 10% to 90%. Default: 30%.

    0,4%

    0,4% of the amount, but no more than 15 GRAM.

  4. Step 4Wallet addressesPaste the wallet addresses. These wallets will later be used to deposit and withdraw funds.
    9:41LTE

    Wallet addresses

  5. Step 5Review the contractReview the contract details before deployment.
    9:41LTE

    Review the contract

    Contract terms
    Currency
    GRAM
    Contract amount
    1 000 GRAM
    Provider deposit
    300 GRAM · 30%
    Service fee
    4 GRAM · 0,4%

After creation

Contract card

You are the client, the currency is USDT. Six snapshots of the same card as in the real dashboard, with every action available at each step.

  1. Awaiting deposits
    1 000 USDT
    Contract of 21/09/2026 - EQDe…0001
    Client (you)
    0 / 1 000
    Provider
    0 / 300

    *Plus up to 30% refundable deposit

    USDT

    Just created

    Balances are zero. You can deposit the deal amount, deposit it together with an extra of up to 30%, or delete the contract while nobody has put anything in.

  2. Awaiting deposits
    1 000 USDT
    Contract of 21/09/2026 - EQDe…0001
    Client (you)
    1 000 / 1 000
    Provider
    0 / 300

    *Plus up to 30% refundable deposit

    USDT

    Funded, waiting

    The deal amount is already in the contract, but the provider deposit isn't yet. Until the status is “Active”, you can withdraw your funds. You can still add the extra deposit of up to 30%.

  3. Active
    1 000 USDT
    Contract of 21/09/2026 - EQDe…0001
    Client (you)
    1 000 / 1 000
    Provider
    300 / 300
    USDT

    The provider has to agree. The amount will be rounded to a whole percentage of the deal amount.

    *Plus up to 30% refundable deposit

    USDT

    Provider deposited

    Both agreed amounts are in, so the contract is active. The funds are locked, and USDT can't be withdrawn. You can accept the work, propose a different share, or add the extra deposit (an additional deposit from the client).

  4. Active
    1 000 USDT
    Contract of 21/09/2026 - EQDe…0001
    Client (you)
    1 200 / 1 000 + 200 · 20%
    Provider
    300 / 300
    USDT

    The provider has to agree. The amount will be rounded to a whole percentage of the deal amount.

    *Plus up to 30% refundable deposit

    USDT

    Added a 20% deposit

    There's +200 USDT on top of the deal amount. Another 100 USDT fits under the 30% cap. Withdrawals remain closed: an active contract holds the contract amount plus the extra.

  5. Payout proposed 800 USDT
    1 000 USDT
    Contract of 21/09/2026 - EQDe…0001
    Client (you)
    1 200 / 1 000 + 200 · 20%
    Provider
    300 / 300
    USDT

    Currently proposed: 800 USDT · 80%. You can replace it.

    *Plus up to 30% refundable deposit

    USDT

    Proposed 80% to the provider.

    The provider will receive 800 USDT if they agree. The “Accept work” button has disappeared; it will only come back if you set 100%. The proposal can be replaced, and the extra deposit can be topped up.

  6. Completed 800 USDT
    1 000 USDT
    Contract of 21/09/2026 - EQDe…0001
    Client (you)
    400
    Provider
    1 096

    Provider agreed

    The contract is completed: 800 USDT went to the provider and 4 USDT to the service as royalty. You have 400 USDT left, which you can withdraw.

Q&A

Frequently asked questions

Where is the third party?

You might ask: where is the third party? Where is the arbitration? There isn't any. The provider risks their deposit, so it's in their interest to finish the job: get their collateral back and get paid.

By default, the provider deposit is 30% of the client's amount. It might seem the provider could use this as leverage, since they have 30% at stake and you have 100%. If you part ways, you lose more. But the question is who loses more of their own money, not in percentages. Those 30% may be worth more to the provider than your 100% are to you. Nobody wants to lose their own money for nothing.

Another risk: the client receives the work and may not pay. There are two options here. The first is to offer to settle up early, when the work is almost done. The second is to ask the client to put in an extra deposit of up to 30% on top of the amount. Once the contract is completed, the client gets that deposit back, so they have a reason to close the deal.

This is blockchain: no banks and no borders. The service fee is 0.4%, capped at 15 GRAM or 20 USDT.

Why should I trust you?

You don't have to. The smart contract is public. Your contract's address is a hash of its initial data and code. If the code were swapped, the address would be different.

You can open the code and inspect it (for example, in Tonviewer). The main catch is that you need to understand the code, but in return it's 100% proof. You can also hand the code to an AI model. Below is ContractUSDT, piece by piece.

ContractUSDT, piece by piece

This is the list of imports: constants and helper functions.

contract_usdt.tolk
1import "@contracts/common-messages"
2import "@contracts/errors"
3import "@contracts/contract_USDT/usdt-utils"
4import "fees-management"
5import "messages"
6import "storage"

A brief description of the contract.

8contract ContractUSDT {
9 author: "Alexandr_K"
10 version: "0.0.4"
11 description: "Escrow contract USDT"
12 storage: StorageUsdt
13 incomingMessages: AllowedMessage
14}

The list of actions available on the contract.

16type AllowedMessage =
17 | TransferNotificationForRecipient
18 | ChangePercent
19 | AcceptPercent
20 | RefundUsdt
21 | ErrRefund
22 | ErrMode
23 | BurnContract

A helper function that checks whether the contract is active. Once everyone has deposited their amounts, the contract becomes active, and funds can no longer simply be withdrawn.

28fun StorageUsdt.isActive(self, extra: ExtraFieldsUsdt): bool {
29 if (extra.completed) {
30 return false;
31 }
32 return self.clientAmount >= extra.clientAmountInit &&
33 self.providerDeposit >= extra.providerDepositInit;
34}

A function for change. Each message carries a little GRAM to pay for processing the transaction. This function returns whatever is left over.

67fun sendExcessesTon(to: address, amount: coins) {
68 if (amount > MIN_MESSAGE_VALUE_BOUND) {
69 val excessesMsg = createMessage({
70 bounce: BounceMode.NoBounce,
71 dest: to,
72 value: amount,
73 });
74 excessesMsg.send(SEND_MODE_IGNORE_ERRORS);
75 }
76}

Calculates the jetton wallet address. It depends on the contract address.

78fun contractUsdtWallet(): address {
79 return calcAddressOfJettonWallet(contract.getAddress());
80}

If a transfer fails, the money comes back and the contract corrects its records of who holds how much.

105fun onBouncedMessage(in: InMessageBounced) {
106 in.bouncedBody.skipBouncedPrefix();
107 val msg = lazy BounceOpToHandle.fromSlice(in.bouncedBody);
108 var storage = lazy StorageUsdt.load();
109 match (msg) {
110 TransferBounce => {
111 if (msg.queryId == QueryRole.Client as uint64) {
112 storage.clientAmount += msg.jettonAmount;
113 } else if (msg.queryId == QueryRole.Provider as uint64) {
114 storage.providerDeposit += msg.jettonAmount;
115 } else if (msg.queryId == QueryRole.Admin as uint64) {
116 var extra: ExtraFieldsUsdt = lazy storage.extra.load();
117 extra.royaltyInit += msg.jettonAmount;
118 storage.extra = extra.toCell();
119 } else {
120 // ErrRefund (QueryRole.None) и неизвестные id: учёт не уменьшали
121 return;
122 }
123 }
124 }
125 storage.save();
126}

If the message body is empty, do nothing.

131fun onInternalMessage(in: InMessage) {
132 if (in.body.isEmpty()) {
133 return;
134 }

Next come the variable declarations.

137val msg = lazy AllowedMessage.fromSlice(in.body);
138var storage: StorageUsdt = lazy StorageUsdt.load();
139val tonBalanceBeforeMsg = contract.getOriginalBalance() - in.valueCoins;
140val storageFee = MIN_TONS_FOR_STORAGE_THIS_CONTRACT -
141min(tonBalanceBeforeMsg, MIN_TONS_FOR_STORAGE_THIS_CONTRACT);
142var msgValue = in.valueCoins - storageFee - CONTRACT_GAS_CONSUMPTION;

USDT top-up: accepts transfers and records how much USDT each party has.

145TransferNotificationForRecipient => {
146 assert (in.senderAddress == contractUsdtWallet()) throw Errors.InvalidWallet;
147 assert (msg.transferInitiator != null) throw Errors.InvalidData;
148 if (msg.transferInitiator == storage.clientAddress) {
149 storage.clientAmount += msg.jettonAmount;
150 storage.save();
151 } else if (msg.transferInitiator == storage.providerAddress) {
152 storage.providerDeposit += msg.jettonAmount;
153 storage.save();
154 }
155 sendExcessesTon(msg.transferInitiator, msgValue);
156}

Changes the payout percentage. If only half the work is done, you can propose 50%. If the provider agrees, the contract is fulfilled. If not, keep negotiating.

162ChangePercent => {
163 assert (msgValue > 0) throw Errors.NotEnoughGas;
164 assert (in.senderAddress == storage.clientAddress) throw Errors.UnauthorizedSender;
165 assert (msg.percent >= 0 && msg.percent <= 100) throw Errors.WrongPercent;
166 var extra: ExtraFieldsUsdt = lazy storage.extra.load();
167 assert (extra.completed == false) throw Errors.ContractIsCompleted;
168 assert (storage.isActive(extra)) throw Errors.ContractIsNotActive;
169 extra.percent = msg.percent;
170 if (msg.percent == 100) {
171 extra.completed = true;
172 storage.providerDeposit += extra.clientAmountInit - extra.royaltyInit;
173 storage.clientAmount -= extra.clientAmountInit;
174 }
175 storage.extra = extra.toCell();
176 storage.save();
177 sendExcessesTon(in.senderAddress, msgValue);
178}

If the provider agrees, the contract pays out their share plus their deposit, and the rest goes back to you. At 0%, the whole deal is cancelled and everyone gets their money back. In that case, the 0.4% service fee is not charged.

183AcceptPercent => {
184 assert (msgValue > 0) throw Errors.NotEnoughGas;
185 assert (in.senderAddress == storage.providerAddress) throw Errors.UnauthorizedSender;
186 var extra: ExtraFieldsUsdt = lazy storage.extra.load();
187 assert (extra.completed == false) throw Errors.ContractIsCompleted;
188 assert (extra.percent != 100) throw Errors.WrongPercent;
189 assert (msg.percent == extra.percent) throw Errors.WrongPercent;
190 extra.completed = true;
191 val percentValue = extra.clientAmountInit / 100 * msg.percent;
192 if (msg.percent == 0) {
193 extra.royaltyInit = 0;
194 }
195 storage.providerDeposit += percentValue - extra.royaltyInit;
196 storage.clientAmount -= percentValue;
197 storage.extra = extra.toCell();
198 storage.save();
199 sendExcessesTon(in.senderAddress, msgValue);
200}

The biggest block: it handles refunds.

208RefundUsdt => {
209 msgValue -= (MIN_MESSAGE_VALUE_BOUND + MIN_FORWARD_TON_AMOUNT);
210 assert (msgValue > 0) throw Errors.NotEnoughGas;
211 assert (msg.jettonAmount > 0) throw Errors.InvalidData;
212 var extra: ExtraFieldsUsdt = lazy storage.extra.load();
213 val transferRecipient: address = msg.addressRefund != null
214 ? msg.addressRefund
215 : in.senderAddress;
216 val isActive = storage.isActive(extra);
217 var queryId: uint64;
218 var jettonAmount: coins;
219 if (in.senderAddress == storage.clientAddress) {
220 queryId = QueryRole.Client;
221 if (isActive) {
222 val deposit: uint72 = (extra.clientAmountInit / 100) * 30;
223 val locked = extra.clientAmountInit + deposit;
224 assert (storage.clientAmount > locked) throw Errors.NotEnoughJettons;
225 jettonAmount = min(storage.clientAmount - locked, msg.jettonAmount);
226 } else {
227 jettonAmount = min(storage.clientAmount, msg.jettonAmount);
228 }
229 storage.clientAmount -= jettonAmount;
230 } else if (in.senderAddress == storage.providerAddress) {
231 queryId = QueryRole.Provider;
232 if (isActive) {
233 assert (
234 storage.providerDeposit > extra.providerDepositInit
235 ) throw Errors.NotEnoughJettons;
236 jettonAmount = min(
237 storage.providerDeposit - extra.providerDepositInit,
238 msg.jettonAmount,
239 );
240 } else {
241 jettonAmount = min(storage.providerDeposit, msg.jettonAmount);
242 }
243 storage.providerDeposit -= jettonAmount;
244 } else if (in.senderAddress == extra.adminAddress) {
245 queryId = QueryRole.Admin;
246 val maxRefundUsdt: uint72 = extra.completed ? extra.royaltyInit : 0;
247 jettonAmount = min(maxRefundUsdt, msg.jettonAmount);
248 extra.royaltyInit -= jettonAmount;
249 storage.extra = extra.toCell();
250 } else {
251 throw Errors.UnauthorizedSender;
252 }
253 assert (jettonAmount > 0) throw Errors.NotEnoughJettons;
254 storage.save();
255 sendAskToTransferUsdt(
256 queryId,
257 jettonAmount,
258 transferRecipient,
259 msgValue + MIN_MESSAGE_VALUE_BOUND + MIN_FORWARD_TON_AMOUNT,
260 );
261}

Error mode. If a wallet is lost or funds bypassed the accounting, one party can hand control over to the other. Strictly speaking, funds bypassing the accounting is impossible unless someone does it on purpose, but the option stays just in case. Forgetting or losing a wallet, on the other hand, is quite possible.

271ErrMode => {
272 assert (msgValue > 0) throw Errors.NotEnoughGas;
273 var extra: ExtraFieldsUsdt = lazy storage.extra.load();
274 var noticeTo: address? = null;
275 var noticeAboutClient: bool = false;
276 if (in.senderAddress == storage.clientAddress) {
277 if (msg.value == ErrModeVal.ProviderFullRights as int8) {
278 extra.errMode = ErrModeVal.ProviderFullRights;
279 } else if (msg.value == ErrModeVal.BlockErrorMode as int8) {
280 extra.errMode = ErrModeVal.BlockErrorMode;
281 } else if (
282 msg.value == ErrModeVal.ClientFullRightsAfterHalfYear as int8 &&
283 extra.errMode != ErrModeVal.BlockErrorMode as int8
284 ) {
285 extra.errMode = ErrModeVal.ClientFullRightsAfterHalfYear;
286 extra.errModeAt = blockchain.now() as uint64;
287 noticeTo = storage.providerAddress;
288 noticeAboutClient = true;
289 }
290 } else if (in.senderAddress == storage.providerAddress) {
291 if (msg.value == ErrModeVal.ClientFullRights as int8) {
292 extra.errMode = ErrModeVal.ClientFullRights;
293 } else if (msg.value == ErrModeVal.BlockErrorMode as int8) {
294 extra.errMode = ErrModeVal.BlockErrorMode;
295 } else if (
296 msg.value == ErrModeVal.ProviderFullRightsAfterHalfYear as int8 &&
297 extra.errMode != ErrModeVal.BlockErrorMode as int8
298 ) {
299 extra.errMode = ErrModeVal.ProviderFullRightsAfterHalfYear;
300 extra.errModeAt = blockchain.now() as uint64;
301 noticeTo = storage.clientAddress;
302 noticeAboutClient = false;
303 }
304 } else {
305 throw Errors.UnauthorizedSender;
306 }
307 if (noticeTo != null) {
308 assert (msgValue > HALF_YEAR_NOTICE_VALUE) throw Errors.NotEnoughGas;
309 msgValue -= HALF_YEAR_NOTICE_VALUE;
310 }
311 storage.extra = extra.toCell();
312 storage.save();
313 if (noticeTo != null) {
314 sendHalfYearControlNotice(noticeTo, noticeAboutClient);
315 }
316 sendExcessesTon(in.senderAddress, msgValue);
317}
10enum ErrModeVal {
11 ClientFullRights = 1
12 ProviderFullRights = 2
13 ClientFullRightsAfterHalfYear = 16 // клиент получает полные права через полгода
14 ProviderFullRightsAfterHalfYear = 26 // исполнитель — через полгода
15 BlockErrorMode = 99
16}

ErrRefund is an emergency withdrawal of jettons that arrived without a notification and weren't recorded. It also helps when a wallet is lost (or, more precisely, its password is lost).

321ErrRefund => {
322 msgValue -= (MIN_MESSAGE_VALUE_BOUND + MIN_FORWARD_TON_AMOUNT);
323 assert (msgValue > 0) throw Errors.NotEnoughGas;
324 assert (msg.jettonAmount > 0) throw Errors.InvalidData;
325 val extra: ExtraFieldsUsdt = lazy storage.extra.load();
326 if (
327 !extra.allowsErrRefund(
328 in.senderAddress,
329 storage.clientAddress,
330 storage.providerAddress,
331 )
332 ) {
333 throw Errors.UnauthorizedSender;
334 }
335 val transferRecipient: address = msg.addressRefund != null
336 ? msg.addressRefund
337 : in.senderAddress;
338 sendAskToTransferUsdt(
339 QueryRole.None,
340 msg.jettonAmount as coins,
341 transferRecipient,
342 msgValue + MIN_MESSAGE_VALUE_BOUND + MIN_FORWARD_TON_AMOUNT,
343 );
344}
36fun ExtraFieldsUsdt.allowsErrRefund(
37 self,
38 sender: address,
39 client: address,
40 provider: address,
41): bool {
42 if (sender == client) {
43 if (self.errMode == ErrModeVal.ClientFullRights as int8) {
44 return true;
45 }
46 return self.errMode == ErrModeVal.ClientFullRightsAfterHalfYear as int8 &&
47 (blockchain.now() as uint64) >= self.errModeAt + HALF_YEAR;
48 }
49 if (sender == provider) {
50 if (self.errMode == ErrModeVal.ProviderFullRights as int8) {
51 return true;
52 }
53 return self.errMode == ErrModeVal.ProviderFullRightsAfterHalfYear as int8 &&
54 (blockchain.now() as uint64) >= self.errModeAt + HALF_YEAR;
55 }
56 return false;
57}

Burn destroys the contract so that an empty shell doesn't linger on the network. It can be burned by whoever created it (the client or the provider) or by the admin, and only when both the client's and the provider's balances are zero.

351BurnContract => {
352 assert (msgValue > 0) throw Errors.NotEnoughGas;
353 val extra: ExtraFieldsUsdt = lazy storage.extra.load();
354 val deployer = extra.deployerAddress(storage.clientAddress, storage.providerAddress);
355 assert (
356 in.senderAddress == deployer || in.senderAddress == extra.adminAddress
357 ) throw Errors.UnauthorizedSender;
358 assert (
359 storage.clientAmount == 0 && storage.providerDeposit == 0
360 ) throw Errors.BalancesNotEmpty;
361 if (extra.completed && extra.royaltyInit > 0) {
362 val jettonTon = MIN_MESSAGE_VALUE_BOUND + MIN_FORWARD_TON_AMOUNT;
363 msgValue -= jettonTon;
364 assert (msgValue > 0) throw Errors.NotEnoughGas;
365 sendAskToTransferUsdt(
366 QueryRole.Admin,
367 extra.royaltyInit as coins,
368 extra.adminAddress,
369 msgValue + jettonTon,
370 );
371 }
372 val burn = createMessage({
373 bounce: BounceMode.NoBounce,
374 dest: in.senderAddress,
375 value: 0,
376 });
377 burn.send(SEND_MODE_CARRY_ALL_BALANCE | SEND_MODE_DESTROY);
378}
59fun ExtraFieldsUsdt.deployerAddress(self, client: address, provider: address): address {
60 if (self.mode == QueryRole.Client as int8) {
61 return client;
62 }
63 assert (self.mode == QueryRole.Provider as int8) throw Errors.UnauthorizedSender;
64 return provider;
65}