

Escrow Smart Contract
Клієнт і виконавець вносять гроші в смарт-контракт. Оплата йде виконавцю, коли роботу зроблено й обидві сторони згодні.
Як це працює
П'ять кроків до контракту
Створіть Smart Contract за допомогою простого візарда.
Створіть Smart Contract за допомогою простого візарда.
- Крок 1Роль у контрактіВиберіть свою роль у контракті. У контракті лише дві ролі — клієнт і виконавець. Третього арбітражу немає.9:41LTE
Роль у контракті
- Крок 2Вибір валютиВиберіть валюту контракту. GRAM — нативна валюта TON — для швидких і невеликих угод, бо валюта досить волатильна. USDT — стабільніша валюта для великих і довгих угод.9:41LTE
Вибір валюти
- Крок 3Сума контрактуВведіть суму контракту у вибраній валюті. Цю суму буде перераховано виконавцю після виконання робіт.9:41LTE
Сума контракту
Сума контракту — вартість роботи. Вносить клієнт.
Від 10% до 90%. За замовчуванням 30%.
0,4% від суми, але не більше ніж 15 GRAM.
- Крок 4Адреси гаманцівВставте адреси гаманців. Надалі через ці гаманці здійснюватимуться внесення й виведення валюти.9:41LTE
Адреси гаманців
- Крок 5Перевірка контрактуПеревірте введені дані контракту перед деплоєм.9:41LTE
Перевірка контракту
Роль у контракті
Після створення
Картка контракту
Ви — клієнт, валюта USDT. Шість знімків тієї самої картки, що й у реальному кабінеті. Усі можливі дії на цьому кроці.
Щойно створили
Баланси нульові. Можна внести суму угоди, одразу суму з надбавкою до 30% або видалити контракт, поки ніхто нічого не вніс.
Поповнили суму й чекаємо
Сума угоди вже на контракті, депозиту виконавця ще немає. Поки статус не «Активний», свої гроші можна вивести. Надбавку до 30% ще можна довнести.
Виконавець поповнив
Обидві обумовлені суми на місці — контракт активний. Суми заморожено, зняти USDT не можна. Можна прийняти роботу, запропонувати іншу частку або довнести надбавку (дод. депозит від клієнта).
Додали депозит 20%
Понад суму угоди лежить +200 USDT. До стелі 30% ще 100 USDT. Виведення досі закрите: активний контракт утримує суму контракту плюс надбавку.
Запропонували 80% виконавцю.
Виконавцю піде 800 USDT, якщо він погодиться. Кнопка «Прийняти роботу» зникла. Вона з'явиться знову, лише якщо виставити 100%. Пропозицію можна замінити, надбавку — довносити.
Виконавець погодився
Контракт завершено: 800 USDT пішли виконавцю, 4 USDT — роялті сервісу. Вам залишилося 400 USDT, їх можна вивести.
Q&A
Часті запитання
Де третя сторона?
Її немає. Виконавець ризикує депозитом. Йому вигідно довести роботу до кінця.
За замовчуванням депозит виконавця — 30% від суми угоди. Може здатися, що в нього перевага: він ризикує 30%, а ви 100%. Але важливі не відсотки, а гроші. Його 30% — це його власні гроші, і втратити їх йому може бути так само боляче, як вам свої 100%. Просто так своїми грошима ніхто не жертвує.
Інший ризик: клієнт отримав роботу й може не віддати гроші. Тоді два варіанти. Перший — коли робота майже готова, запропонувати розрахуватися заздалегідь. Другий — попросити внести надбавку до 30% понад суму (депозит). Після завершення контракту клієнт отримає свій депозит назад, тому йому є сенс закрити угоду (контракт).
Чому я маю вам вірити?
Вірити не потрібно. Смарт-контракт публічний. Адреса вашого контракту — хеш початкових даних і коду. Підмінили код — адреса буде іншою.
Його можна відкрити й подивитися (наприклад, у Tonviewer). Єдине, потрібно розуміти код — у цьому основна проблема. Зате це 100% доказ. Можна віддати код ШІ-моделі. Нижче — код контракту з максимально простим поясненням.
ContractUSDT
Це список імпортів: константи й допоміжні функції.
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: StorageUsdt13 incomingMessages: AllowedMessage14}Список доступних дій з контрактом.
16type AllowedMessage =17 | TransferNotificationForRecipient18 | ChangePercent19 | AcceptPercent20 | RefundUsdt21 | ErrRefund22 | ErrMode23 | 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 != null214 ? msg.addressRefund215 : 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.providerDepositInit235 ) 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 int8284 ) {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 int8298 ) {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 = 112 ProviderFullRights = 213 ClientFullRightsAfterHalfYear = 16 // клиент получает полные права через полгода14 ProviderFullRightsAfterHalfYear = 26 // исполнитель — через полгода15 BlockErrorMode = 9916}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 != null336 ? msg.addressRefund337 : 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.adminAddress357 ) throw Errors.UnauthorizedSender;358 assert (359 storage.clientAmount == 0 && storage.providerDeposit == 0360 ) 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