Это тестовая версия.

MainnetТестовые жетоны
TON Blockchain

Escrow Smart Contract

Клиент и исполнитель вносят деньги в смарт-контракт. Оплата уходит исполнителю, когда работа сделана и обе стороны согласны.

Как это работает

Пять шагов до контракта

Создайте Smart Contract с помощью простого визарда.

  1. Шаг 1Роль в контрактеВыберите свою роль в контракте. В контракте всего две роли — клиент и исполнитель. Третьего арбитража нет.
    9:41LTE

    Роль в контракте

    Роль в контракте

    Клиент — заказчик услуги, товара. Вносит 100% предоплату. Плюс возвратный депозит до 30% по желанию.

    Исполнитель — поставщик услуги или товара. Вносит возвратный депозит (по умолчанию 30%) от суммы контракта.

  2. Шаг 2Выбор валютыВыберите валюту, в которой будет контракт. GRAM — нативная валюта TON — для быстрых и небольших сделок, так как валюта достаточно волатильна. USDT — более стабильная валюта для больших и долгих сделок.
    9:41LTE

    Выбор валюты

    Валюта контракта

    GRAM — более быстрое создание. Можно сразу создать контракт и внести наличные одной транзакцией.

    USDT — создание в два этапа. Сначала создание контракта (у вас на кошельке должна быть нативная валюта GRAM, либо кошелёк, поддерживающий безгазовые транзакции. GRAM предпочтительнее.), потом внесение валюты USDT.USDT менее волатилен, чем GRAM.

  3. Шаг 3Сумма контрактаВведите сумму контракта в выбранной валюте. Эта сумма будет перечислена исполнителю после выполнения работ.
    9:41LTE

    Сумма контракта

    GRAM

    Сумма контракта — стоимость работы. Вносит клиент.

    30%

    От 10% до 90%. По умолчанию 30%.

    0,4%

    0,4% от суммы, но не более 15 GRAM.

  4. Шаг 4Адреса кошельковВставьте адреса кошельков. Впоследствии через эти кошельки будут осуществляться ввод и вывод валюты.
    9:41LTE

    Адреса кошельков

  5. Шаг 5Проверка контрактаПроверьте введенные данные контракта перед деплоем.
    9:41LTE

    Проверка контракта

    Условия контракта
    Валюта
    GRAM
    Сумма контракта
    1 000 GRAM
    Депозит исполнителя
    300 GRAM · 30%
    Комиссия сервиса
    4 GRAM · 0,4%

После создания

Карточка контракта

Вы — клиент, валюта USDT. Шесть снимков той же карточки, что и в реальном кабинете. Все возможные действия на этом шаге.

  1. Ожидает пополнений
    1 000 USDT
    Контракт от 21.09.2026 - EQDe…0001
    Клиент (вы)
    0 / 1 000
    Исполнитель
    0 / 300

    *Плюс 30% (max) возвращаемого депозита

    USDT

    Только создали

    Балансы нулевые. Можно внести сумму сделки, сразу сумму с надбавкой до 30% или удалить контракт, пока никто ничего не положил.

  2. Ожидает пополнений
    1 000 USDT
    Контракт от 21.09.2026 - EQDe…0001
    Клиент (вы)
    1 000 / 1 000
    Исполнитель
    0 / 300

    *Плюс 30% (max) возвращаемого депозита

    USDT

    Пополнили сумму и ждём

    Сумма сделки уже на контракте, депозита исполнителя ещё нет. Пока статус не «Активен», свои деньги можно вывести. Надбавку до 30% ещё можно довнести.

  3. Активен
    1 000 USDT
    Контракт от 21.09.2026 - EQDe…0001
    Клиент (вы)
    1 000 / 1 000
    Исполнитель
    300 / 300
    USDT

    Исполнитель должен согласиться. Сумма округлится до целого процента от суммы сделки.

    *Плюс 30% (max) возвращаемого депозита

    USDT

    Исполнитель пополнил

    Обе оговоренные суммы на месте — контракт активен. Суммы заморожены, снять USDT нельзя. Можно принять работу, предложить другую долю или довнести надбавку (доп. депозит от клиента).

  4. Активен
    1 000 USDT
    Контракт от 21.09.2026 - EQDe…0001
    Клиент (вы)
    1 200 / 1 000 + 200 · 20%
    Исполнитель
    300 / 300
    USDT

    Исполнитель должен согласиться. Сумма округлится до целого процента от суммы сделки.

    *Плюс 30% (max) возвращаемого депозита

    USDT

    Добавили депозит 20%

    Сверх суммы сделки лежит +200 USDT. До потолка 30% ещё 100 USDT. Вывод по-прежнему закрыт: активный контракт держит сумму контракта, плюс надбавку.

  5. Предложена выплата 800 USDT
    1 000 USDT
    Контракт от 21.09.2026 - EQDe…0001
    Клиент (вы)
    1 200 / 1 000 + 200 · 20%
    Исполнитель
    300 / 300
    USDT

    Сейчас предложено 800 USDT · 80%. Можно заменить.

    *Плюс 30% (max) возвращаемого депозита

    USDT

    Предложили 80% исполнителю.

    Исполнителю уйдёт 800 USDT, если он согласится. Кнопка «Принять работу» исчезла. Она появится уже только, если выставить 100%. Предложение можно заменить, надбавку — довносить.

  6. Завершён 800 USDT
    1 000 USDT
    Контракт от 21.09.2026 - EQDe…0001
    Клиент (вы)
    400
    Исполнитель
    1 096

    Исполнитель согласился

    Контракт завершён: 800 USDT ушли исполнителю, 4 USDT — роялти сервиса. Вам осталось 400 USDT, их можно вывести.

Q&A

Частые вопросы

Где третья сторона?

Её нет. Исполнитель рискует депозитом. Ему выгодно довести работу до конца.

По умолчанию депозит исполнителя — 30% от суммы сделки. Может показаться, что у него преимущество: он рискует 30%, а вы 100%. Но важны не проценты, а деньги. Его 30% — это его собственные деньги, и потерять их ему может быть так же больно, как вам свои 100%. Просто так своими деньгами никто не жертвует.

Другой риск: клиент получил работу и может не отдать деньги. Тогда два варианта. Первый — когда работа почти готова, предложить рассчитаться заранее. Второй — попросить положить надбавку до 30% сверх суммы (депозит). После завершения контракта, клиент получит свой депозит обратно, поэтому ему есть резон закрыть сделку (контракт).

Почему я должен вам верить?

Верить не нужно. Смарт-контракт публичен. Адрес вашего контракта — хеш начальных данных и кода. Подменили код — адрес будет другим.

Его можно открыть и посмотреть (например в Tonviewer). Единственное, нужно понимать код — в этом основная проблема. Но зато это 100% доказательство. Можно отдать код ИИ-модели. Ниже — код контракта с максимально простым пояснением.

ContractUSDT

Это список импортов: константы и вспомогательные функции.

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

Краткая характеристика контракта.

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

Список доступных действий с контрактом.

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

Вспомогательная функция: активен контракт или нет. Когда все внесли свои суммы, контракт становится активным — просто так уже не вывести.

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}

Функция для сдачи. К сообщению кладут немного GRAM на обработку транзакции. Лишнее она возвращает.

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}

Считает адрес жетон-кошелька. Он зависит от адреса контракта.

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

Если перевод сбойнул — деньги возвращаются, контракт поправляет записи: у кого сколько.

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}

Если тело сообщения пустое — ничего не делаем.

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

Дальше объявление переменных.

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: принимает переводы и записывает, сколько у кого USDT.

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}

Меняет процент выплаты. Работа сделана только наполовину — можно предложить 50%. Исполнитель согласился — контракт исполнен. Нет — договаривайтесь ещё.

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}

Если исполнитель соглашается, контракт отсчитает ему долю плюс депозит, остаток вам. 0% — вся сделка отменяется, все получают свои деньги назад. Комиссия 0,4% контракту не платится.

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}

Самый большой блок — управляет возвратами средств.

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}

Режим ошибки. Если кошелёк потерян или деньги прошли мимо учёта, сторона может передать контроль другой стороне. Ситуация «мимо учёта» строго говоря невозможна, если только не сделать это намеренно. Но пусть будет. Забыть или потерять кошелёк — вполне возможно.

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 — аварийный вывод жетонов, которые пришли без уведомления и не попали в учёт. Либо когда кошелёк утерян (правильнее сказать утерян пароль от него)

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 — уничтожить контракт, чтобы пустая оболочка не болталась в сети. Сжечь может тот, кто его создал (клиент или исполнитель) или админ — и только когда балансы клиента и исполнителя нулевые.

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}

Верифицированные исходники трёх контрактов. Хэш кода совпадает с тем, что в сети. Ссылка открывает исходник на TON Verifier.

ContractMinterContractUSDTContractTON