dApp на RIDE: зачётка, которая живёт в сети
Зачем это нужно
Смарт-аккаунт из прошлой главы умеет только одно: сказать «да» или «нет» на свою же исходящую транзакцию. Этого хватает для многоподписного счёта, но не хватает для приложения.
Чтобы появилось приложение, скрипт должен уметь две вещи: принимать вызовы снаружи и менять состояние. Именно это и добавляет второй вид скрипта — DAPP.
Мы напишем на нём ту же зачётку, что уже писали на Solidity и чейнкодом Fabric. Сравнение получится честным: одна задача, третья платформа. Всё ниже — прогон на локальном узле Waves 1.6.4.
Устройство вызываемой функции
{-# STDLIB_VERSION 6 #-}
{-# CONTENT_TYPE DAPP #-}
{-# SCRIPT_TYPE ACCOUNT #-}
@Callable(i)
func zavesti(id: String, fio: String) = {
let kluch = "z_" + id
match getString(this, kluch) {
case s: String => throw("Zachetka " + id + " uzhe zavedena")
case _ => [ StringEntry(kluch, fio), IntegerEntry(kluch + "_ball", 0) ]
}
}
Попробуем вернуть одно действие вместо списка:
@Callable(i)
func f() = { IntegerEntry("x", 1) }
Compilation failed: [CallableFunction needs to return
(List[BinaryEntry|BooleanEntry|Burn|DeleteEntry|IntegerEntry|Issue|Lease|LeaseCancel|
Reissue|ScriptTransfer|SponsorFee|StringEntry], Any) | List[…]
Компилятор заодно перечислил все допустимые действия. Удобно: список можно не запоминать.
Что можно положить в список
| Действие | Что делает |
|---|---|
IntegerEntry, StringEntry | записать число или строку в своё состояние |
BooleanEntry, BinaryEntry | записать да-нет или байты |
DeleteEntry | удалить ключ |
ScriptTransfer | отправить деньги со счёта dApp |
Issue, Reissue, Burn | выпустить, допечатать, сжечь токен |
Lease, LeaseCancel, SponsorFee | аренда и спонсирование комиссий |
Функция ничего не делает сама — она возвращает список. Пока функция считает, ничего не записано; если где-то по дороге сработает throw, не запишется вообще ничего. Это и есть встроенная «всё или ничего», которую в Solidity обеспечивает revert.
Кто вызывает и чьё состояние меняется
Внутри функции доступны два разных адреса, и путать их нельзя:
this— аккаунт, на котором стоит скрипт. Записи идут в его состояние, деньгиScriptTransferуходят с его счёта.i.caller— тот, кто вызвал функцию. Он подписал транзакцию типа 16 и заплатил комиссию.
Отсюда типичный приём — привязать данные к вызвавшему составным ключом:
func kluch(a: Address) = "vklad_" + a.toString()
Состояние вызвавшего dApp изменить не может. Он пишет только в своё. Поэтому все данные пользователей хранятся в состоянии самого приложения, и ключи делают составными — иначе двум пользователям не разойтись.
Вместе с i приезжает ещё несколько полезных вещей:
| Поле | Что в нём |
|---|---|
i.caller | адрес вызвавшего |
i.callerPublicKey | его публичный ключ |
i.payments | что приложено к вызову (следующая глава) |
i.transactionId | идентификатор этой транзакции |
i.fee | заплаченная комиссия |
Устанавливаем и вызываем
Установка — та же транзакция SetScript, что и для смарт-аккаунта:
сложность: 182 | размер: 752
в блоке: 24
Вызов — транзакция типа 16:
const { invokeScript } = require('@waves/waves-transactions');
const tx = invokeScript({
dApp: '3M52rTnLTitKusqJ9JCtJs4JCiptN9Yq4RX',
call: { function: 'zavesti', args: [
{ type: 'string', value: 'z-1' },
{ type: 'string', value: 'Kovalik Oleg' },
] },
chainId: 'R',
}, seedПользователя);
тип: 16 | комиссия: 500000
в блоке: 24 | статус: succeeded
изменения состояния: {"data":[{"key":"z_z-1","type":"string","value":"Kovalik Oleg"},
{"key":"z_z-1_ball","type":"integer","value":0}],
"transfers":[],"issues":[],"burns":[],"invokes":[]}
Проверяем состояние dApp:
[{"key":"z_z-1","type":"string","value":"Kovalik Oleg"},
{"key":"z_z-1_ball","type":"integer","value":0}]
Отказы
Вторая функция — с проверкой диапазона:
@Callable(i)
func postavit(id: String, ball: Int) = {
let kluch = "z_" + id
strict fio = getStringValue(this, kluch)
if (ball < 2 || ball > 5) then throw("Ball vne diapazona 2..5")
else [ IntegerEntry(kluch + "_ball", ball) ]
}
Обратите внимание на strict: чтение нужно ради побочного эффекта — упасть, если зачётки нет. Под let эта проверка бы не выполнилась.
Три отказа подряд, все с живого узла:
повторное заведение:
{"error":306,"message":"Error while executing dApp: Zachetka z-1 uzhe zavedena"}
балл вне диапазона:
{"error":306,"message":"Error while executing dApp: Ball vne diapazona 2..5"}
несуществующая функция:
{"error":306,"message":"Error while executing dApp: @Callable function 'netTakoy' doesn't exist in the script"}
error: 306 — «скрипт dApp упал». В отличие от смарт-аккаунта, здесь узел передаёт ваш текст из throw. Поэтому сообщения стоит писать осмысленно: они и есть интерфейс ошибок вашего приложения.
Та же задача на трёх платформах
Стоит сравнить прямо сейчас, пока все три реализации свежи.
| Solidity | Чейнкод Fabric | dApp на RIDE | |
|---|---|---|---|
| Где данные | поля контракта | общее хранилище | состояние аккаунта |
| Проверка | require | throw | throw |
| Возврат результата | изменяет поля | putState | список действий |
| «Всё или ничего» | revert | набор изменений | список не вернулся |
| Кто вызвал | msg.sender | ctx.clientIdentity | i.caller |
| Стоимость вызова | ~96 000 газа | нет платы | 0,005 WAVES |
Логика во всех трёх одинаковая, а вот способ вернуть результат — разный. В RIDE он самый непривычный и самый строгий: функция описывает изменения, а применяет их сеть.
Типичные ошибки
Ошибка 1. Вернуть действие вместо списка.
CallableFunction needs to return (List[BinaryEntry|BooleanEntry|…], Any) | List[…]
Даже одно действие оборачивают в квадратные скобки.
Ошибка 2. Проверка под let.
Та же ловушка, что и в прошлой главе, но здесь дороже: проверка в dApp защищает чужие деньги.
Ошибка 3. Писать в состояние вызвавшего.
Нельзя. Данные пользователей живут в состоянии dApp под составными ключами.
Ошибка 4. Две функции с одинаковым именем.
Compilation failed: [Value 'f' can't be defined because function with this name is already defined]
Перегрузки функций в RIDE нет.
Ошибка 5. Не проверить applicationStatus.
Вызов может попасть в блок и не примениться. Проверять надо не код ответа, а состояние или stateChanges.
Шпаргалка по главе
| Что нужно | Как |
|---|---|
| Объявить вызываемую функцию | @Callable(i) + func имя(арг: Тип) = [ … ] |
| Записать в состояние | IntegerEntry(k, v), StringEntry(k, v) |
| Удалить ключ | DeleteEntry(k) |
| Отправить деньги | ScriptTransfer(adres, summa, unit) |
| Отказать | throw("текст") |
| Прочитать своё состояние | getInteger(this, k), getStringValue(this, k) |
| Узнать вызвавшего | i.caller |
| Вызвать снаружи | invokeScript({ dApp, call: { function, args }, chainId }, seed) |
| Посмотреть результат | поле stateChanges в /transactions/info/<id> |
Словарь главы
| Термин | Что это |
|---|---|
| dApp | Аккаунт со скриптом CONTENT_TYPE DAPP |
@Callable | Пометка функции, вызываемой снаружи |
i | Сведения о вызове: кто, с чем, какой транзакцией |
this | Сам dApp: его состояние и его счёт |
| Список действий | То, что возвращает функция; применяет его сеть |
stateChanges | Что именно изменил вызов; видно в ответе узла |
error: 306 | Скрипт dApp упал; текст — ваш из throw |
| Составной ключ | "vklad_" + i.caller.toString() — способ разойтись пользователям |
Цель: скорость ≥ 90 зн/мин, точность ≥ 88%
{-# CONTENT_TYPE DAPP #-}
Что должен уметь после главы
- написать dApp с вызываемыми функциями и установить его на аккаунт;
- объяснить, почему функция возвращает список, а не пишет сама;
- перечислить основные действия и сказать, что делает каждое;
- отличить
thisотi.callerи хранить данные пользователей правильно; - вызвать функцию снаружи и прочитать
stateChanges; - разобрать три отказа dApp и объяснить, откуда берётся текст ошибки.
Проверка по главе
Сводная проверка по всей главе: 10 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- dApp на RIDE — структура и ограничения.
- Действия скрипта — полный список с полями.
- Транзакция вызова — поля и комиссия.
- Waves IDE — редактор с развёртыванием в тестовую сеть в один клик.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.