Це тестова версія.

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