Применение смарт-аккаунтов Waves. Часть 1: от аукционов до бонусных программ
Блокчейн часто ассоциируется лишь с криптовалютами, но области применения технологии DLT значительно шире. Одно из самых перспективных направлений для применения блокчейна — смарт-контракт, выполняющийся автоматически и не требующий доверия между сторонами, его заключившими.
RIDE — язык для смарт-контрактов
Waves разработала специальный язык для смарт-контрактов — RIDE. Его полная документация находится здесь. А вот — статья на эту тему на Хабре.
Контракт на RIDE является предикатом и возвращает на выходе «true» или «false». Соответственно, транзакция либо записывается в блокчейн, либо отвергается. Смарт-контракт полностью гарантирует исполнение заданных условий. Генерация транзакций из контракта в RIDE на данный момент невозможна.
На сегодня существует два типа смарт-контрактов Waves: смарт-аккаунты и смарт-ассеты. Смарт-аккаунт — это обычный аккаунт пользователя, но для него задается скрипт, контролирующий все транзакции. Скрипт смарт-аккаунта может выглядеть, например, так:
match tx {
case t: TransferTransaction | MassTransferTransaction => false
case _ => true
}
tx — обрабатываемая транзакция, которую мы разрешаем, используя механизм паттерн-матчинга, только в случае, если она не является транзакцией перевода. Паттерн-матчинг в RIDE используется для проверки типа транзакции. В скрипте смарт-аккаунта могут быть обработаны все существующие типы транзакций.
Также в скрипте могут объявляться переменные, использоваться конструкции «if-then-else» и другие методы полноценной проверки условий. Чтобы контракты имели доказуемую завершаемость и сложность (стоимость), которую легко предсказать до начала выполнения контракта, RIDE не содержит циклов и операторов типа jump.
Среди других особенностей аккаунтов Waves — наличие «стейта», то есть состояния аккаунта. В стейт аккаунта можно записать бесконечное количество пар (ключ, значение) с помощью дата-транзакций (DataTransaction). Далее эту информацию можно обрабатывать как через REST API, так и напрямую в смарт-контракте.
Каждая транзакция может содержать массив пруфов (proofs), в который можно внести подпись участника, ID необходимой транзакции и т.д.
Работа с RIDE через IDE позволяет видеть скомпилированный вид контракта (если он компилируется), создавать новые аккаунты и задать для него скрипты, а также посылать транзакции через командную строку.
Для полноценного цикла, включающего создание аккаунта, установку на него смарт-контракта и отправку транзакций, можно также использовать библиотеку для взаимодействия с REST API (например, C#, C, Java, JavaScript, Python, Rust, Elixir). Для начала работы с IDE достаточно нажать кнопку NEW.
Возможности применения смарт-контрактов широки: от запрета транзакций на определенные адреса («черный список») до сложносоставных dApps.
Теперь рассмотрим конкретные примеры применения смарт-контрактов в бизнесе: при проведении аукционов, страховании и создании программ лояльности.
Аукционы
Одно из условий успешного проведения аукциона — прозрачность: участники должны быть уверены в невозможности манипуляций ставками. Этого можно достичь благодаря блокчейну, где неизменные данные обо всех ставках и времени, когда они были сделаны, будут доступны всем участникам.
На блокчейне Waves ставки могут записываться в стейт аккаунта аукциона посредством DataTransaction.
Также можно задать время начала и окончания аукциона с помощью номеров блоков: частота генерации блока в блокчейне Waves примерно равна 60 секундам.
1. Английский аукцион повышающейся цены
Участники английского аукциона делают ставки, соревнуясь друг с другом. Каждая новая ставка должна превышать предыдущую. Аукцион заканчивается, когда больше нет желающих превысить последнюю ставку. В этом случае участник, сделавший самую высокую ставку, должен предоставить заявленную сумму.
Существует и вариант аукциона, в котором продавец устанавливает минимальную цену лота, и финальная цена должна ее превышать. В противном случае лот остается непроданным.
В этом примере мы работаем с аккаунтом, специально созданным для проведения аукциона. Длительность аукциона составляет 3000 блоков, а начальная цена лота — 0,001 WAVES. Участник может сделать ставку, отправив DataTransaction с ключом «price» и значением своей ставки.
Цена новой ставки должна быть выше, чем текущая цена по этому ключу, и участник должен иметь на счету как минимум [новая_ставка + комиссия] токенов. Адрес участника должен быть записан в поле «sender» в DataTransaction, а текущая высота блока ставки должна находиться в границах периода аукциона.
Если по итогам аукциона участник назначил самую высокую цену, он может отправить ExchangeTransaction для оплаты соответствующего лота по указанной цене и валютной паре.
let startHeight = 384120
let finishHeight = startHeight + 3000
let startPrice = 100000
#извлекаем из транзакции адрес отправителя
let this = extract(tx.sender)
let token = base58'8jfD2JBLe23XtCCSQoTx5eAW5QCU6Mbxi3r78aNQLcNf'
match tx {
case d : DataTransaction =>
#проверяем, задана ли в стейте цена
let currentPrice = if isDefined(getInteger(this, "price"))
#извлекаем цену из стейта
then extract(getInteger(this, "price"))
else startPrice
#извлекаем цену из транзакции
let newPrice = extract(getInteger(d.data, "price"))
let priceIsBigger = newPrice > currentPrice
let fee = 700000
let hasMoney = wavesBalance(tx.sender) + fee >= newPrice
#убеждаемся, что в текущей транзакции два поля и что отправитель совпадает с указанным в транзакции
let correctFields = size(d.data) == 2 &&
d.sender == addressFromString(extract(getString(d.data,"sender")))
startHeight <= height && height <= finishHeight && priceIsBigger && hasMoney && correctFields
case e : ExchangeTransaction =>
let senderIsWinner = e.sender == addressFromString(extract(getString(this, "sender"))) #убеждаемся, что лот обменивает тот, кто его выиграл
let correctAssetPair = e.sellOrder.assetPair.amountAsset == token && ! isDefined(e.sellOrder.assetPair.priceAsset)
let correctAmount = e.amount == 1
let correctPrice = e.price == extract(getInteger(this, "price"))
height > finishHeight && senderIsWinner && correctAssetPair && correctAmount && correctPrice
case _ => false
}
2. Голландский аукцион снижающейся цены
На голландском аукционе лот изначально предлагается по цене, превышающей ту, что покупатель готов заплатить. Цена пошагово снижается до тех пор, пока один из участников не согласится купить лот по текущей цене.
В этом примере мы используем те же константы, что и в предыдущем, а также шаг цены при понижении delta. Скрипт аккаунта проверяет, действительно ли участник является первым, кто сделал ставку. В противном случае DataTransaction не принимается блокчейном.
let startHeight = 384120
let finishHeight = startHeight + 3000
let startPrice = 100000000
let delta = 100
#извлекаем из транзакции адрес отправителя
let this = extract(tx.sender)
let token = base58'8jfD2JBLe23XtCCSQoTx5eAW5QCU6Mbxi3r78aNQLcNf'
match tx {
case d : DataTransaction =>
let currentPrice = startPrice - delta * (height - startHeight)
#извлекаем из поступившей дата-транзакции поле "price"
let newPrice = extract(getInteger(d.data, "price"))
#убеждаемся, что в стейте текущего аккаунта не содержится поля "sender"
let noBetsBefore = !isDefined(getInteger(this, "sender"))
let fee = 700000
let hasMoney = wavesBalance(tx.sender) + fee >= newPrice
#убеждаемся, что в текущей транзакции только два поля
let correctFields = size(d.data) == 2 && newPrice == currentPrice && d.sender == addressFromString(extract(getString(d.data, "sender")))
startHeight <= height && height <= finishHeight && noBetsBefore && hasMoney && correctFields
case e : ExchangeTransaction =>
#убеждаемся, что отправитель текущей транзакции указан в стейте аккаунта по ключу sender
let senderIsWinner = e.sender == addressFromString(extract(getString(this, "sender")))
#убеждаемся, что аmount ассета указан корректно, и что прайс-ассет - waves
let correctAssetPair = e.sellOrder.assetPair.amountAsset == token && ! isDefined(e.sellOrder.assetPair.priceAsset)
let correctAmount = e.amount == 1
let correctPrice = e.price == extract(getInteger(this, "price"))
height > finishHeight && senderIsWinner && correctAssetPair && correctAmount && correctPrice
case _ => false
}
3. Аукцион «all-pay»
«All-pay» — аукцион, все участники которого оплачивают ставку, платят, независимо от того, кто выигрывает лот. Каждый новый участник оплачивает ставку, а выигрывает лот участник, сделавший максимальную ставку.
В нашем примере каждый участник аукциона делает ставку через DataTransaction с (key, value)* = («winner», address),(«price», price). Такая DataTransaction одобряется только в случае, если для этого участника уже имеется TransferTransaction с его подписью и его ставка выше всех предыдущих. Аукцион продолжается до достижения высоты endHeight.
let startHeight = 1000
let endHeight = 2000
let this = extract(tx.sender)
let token = base58'8jfD2JBLe23XtCCSQoTx5eAW5QCU6Mbxi3r78aNQLcNf'
match tx {
case d: DataTransaction =>
#извлекаем из поступившей дата-транзакции поле "price"
let newPrice = extract(getInteger(d.data, "price"))
#извлекаем из пруфов транзакции публичный ключ аккаунта
let pk = d.proofs[1]
let address = addressFromPublicKey(pk)
#извлекаем транзакцию доказательство из пруфов поступившей дата транзакции
let proofTx = extract(transactionById(d.proofs[2]))
height > startHeight && height < endHeight
&& size(d.data) == 2
#убеждаемся, что адрес победителя, извлеченный из текущей транзакции, совпадает с адресом, извлеченным из пруфов
&& extract(getString(d.data, "winner")) == toBase58String(address.bytes)
&& newPrice > extract(getInteger(this, "price"))
#проверяем, что транзакция подписана
&& sigVerify(d.bodyBytes, d.proofs[0], d.proofs[1])
#проверяем корректность транзакции, указанной в пруфах
&& match proofTx {
case tr : TransferTransaction =>
tr.sender == address &&
tr.amount == newPrice
case _ => false
}
case t: TransferTransaction =>
sigVerify(tx.bodyBytes, tx.proofs[0], tx.senderPublicKey)
|| (
height > endHeight
&& extract(getString(this, "winner")) == toBase58String((addressFromRecipient(t.recipient)).bytes)
&& t.assetId == token
&& t.amount == 1
)
case _ => sigVerify(tx.bodyBytes, tx.proofs[0], tx.senderPublicKey)
}
Страхование / Краудфандинг
Рассмотрим ситуацию, когда нужно застраховать активы пользователей от финансовых потерь. Например, пользователь хочет получить гарантию, что в случае обесценивания токена он сможет вернуть полную сумму, уплаченную за эти токены, и готов заплатить разумную сумму страховки.
Для реализации этого нужно выпустить «страховые токены». Затем на аккаунт страхователя устанавливается скрипт, позволяющий исполнять только те ExchangeTransactions, которые удовлетворяют определенным условиям.
Чтобы предотвратить двойную трату, нужно запросить у пользователя заблаговременную отправку DataTransaction на аккаунт страхователя с (key, value) = (purchaseTransactionId, sellOrderId) и запретить отправку DataTransactions с уже использованным ключом.
Поэтому пруфы пользователя должны содержать ID транзакции покупки страхового токена. Валютная пара должна быть такой же, как и в транзакции покупки. Стоимость также должна быть равна той, что зафиксирована при покупке, за вычетом цены страховки.
Подразумевается, что впоследствии страховой аккаунт выкупает страховые токены у пользователя по цене не ниже той, по которой он их приобрел: страховой аккаунт создает ExchangeTransaction, пользователь подписывает ордер (если транзакция составлена корректно), страховой аккаунт подписывает второй ордер и всю транзакцию и отправляет в блокчейн.
Если покупки не происходит, пользователь может создать ExchangeTransaction в соответствии с правилами, описанными в скрипте, и отправить транзакцию в блокчейн. Так пользователь может вернуть деньги, потраченные на покупку застрахованных токенов.
let insuranceToken = base58'8jfD2JBLe23XtCCSQoTx5eAW5QCU6Mbxi3r78aNQLcNf'
#извлекаем из транзакции адрес отправителя
let this = extract(tx.sender)
let freezePeriod = 150000
let insurancePrice = 10000
match tx {
#убеждаемся, что, если поступила дата-транзакция, то у нее ровно одно поле и в стейте еще нет такого ключа
case d : DataTransaction => size(d.data) == 1 && !isDefined(getBinary(this, d.data[0].key))
case e : ExchangeTransaction =>
#если у транзакции нет седьмого пруфа, проверяем корректность подписи
if !isDefined(e.proofs[7]) then
sigVerify(e.bodyBytes, e.proofs[0], e.senderPublicKey)
else
#если у транзакции есть седьмой пруф, извлекаем из него транзакцию и узнаём её высоту
let purchaseTx = transactionById(e.proofs[7])
let purchaseTxHeight = extract(transactionHeightById(e.proofs[7]))
#обрабатываем транзакцию из пруфа
match purchaseTx {
case purchase : ExchangeTransaction =>
let correctSender = purchase.sender == e.sellOrder.sender
let correctAssetPair = e.sellOrder.assetPair.amountAsset == insuranceToken &&
purchase.sellOrder.assetPair.amountAsset == insuranceToken &&
e.sellOrder.assetPair.priceAsset == purchase.sellOrder.assetPair.priceAsset
let correctPrice = e.price == purchase.price - insurancePrice && e.amount == purchase.amount
let correctHeight = height > purchaseTxHeight + freezePeriod
#убеждаемся, что в транзакции-пруфе указан верный ID текущей транзакции
let correctProof = extract(getBinary(this, toBase58String(purchase.id))) == e.sellOrder.id
correctSender && correctAssetPair && correctPrice && correctHeight && correctProof
case _ => false
}
case _ => sigVerify(tx.bodyBytes, tx.proofs[0], tx.senderPublicKey)
}
Страховой токен можно сделать смарт-ассетом — например, чтобы запретить его передачу третьим лицам.
Эта схема может быть реализована и для токенов краудфандинга, которые возвращаются владельцам, если необходимая сумма не была собрана.
Налоги с транзакций
Смарт-контракты применимы и в случаях, когда надо собирать налог с каждой транзакции с несколькими типами активов. Это можно сделать через новый ассет с установленным спонсорством для транзакций со смарт-ассетами:
1. Выпускаем FeeCoin, который будет отправлен пользователям по фиксированной цене: 0,01 WAVES = 0,001 FeeCoin.
2. Задаем спонсорство для FeeCoin и курс обмена: 0,001 WAVES = 0,001 FeeCoin.
3. Задаем следующий скрипт для смарт-ассета:
let feeAssetId = base58'8jfD2JBLe23XtCCSQoTx5eAW5QCU6Mbxi3r78aNQLcNf'
let taxDivisor = 10
match tx {
case t: TransferTransaction =>
t.feeAssetId == feeAssetId && t.fee == t.amount / taxDivisor
case e: ExchangeTransaction | MassTransferTransaction => false
case _ => true
}
Теперь каждый раз, когда кто-либо переводит N смарт-ассетов, он даст вам FeeCoin в сумме N / taxDivisor (который может быть куплен у вас по 10 *N / taxDivisor WAVES), а вы отдадите майнеру N / taxDivisor WAVES. В результате ваша прибыль (налог) составит 9*N / taxDivisor WAVES.
Также можно осуществлять налогообложение с помощью скрипта смарт-ассета и MassTransferTransaction:
let taxDivisor = 10
match tx {
case t : MassTransferTransaction =>
let twoTransfers = size(t.transfers) == 2
let issuerIsRecipient = t.transfers[0].recipient == addressFromString("3MgkTXzD72BTfYpd9UW42wdqTVg8HqnXEfc")
let taxesPaid = t.transfers[0].amount >= t.transfers[1].amount / taxDivisor
twoTransfers && issuerIsRecipient && taxesPaid
case _ => false
}
Кэшбэк и программы лояльности
Кэшбэк — тип программы лояльности, в котором покупателю возвращается часть суммы, потраченной на товар или услугу.
При реализации этого кейса с помощью смарт-аккаунта мы должны проверить пруфы так же, как делали это в кейсе страхования. Для предотвращения двойной траты перед получением кэшбэка пользователь должен отправить DataTransaction с (key, value) = (purchaseTransactionId, cashbackTransactionId).
Также мы должны установить запрет на уже существующие ключи с помощью DataTransaction. cashbackDivisor — единица, деленная на долю кэшбэка. Т.е. в случае, если доля кэшбека равна 0.1, то cashbackDivisor 1 / 0.1 = 10.
let cashbackToken = base58'8jfD2JBLe23XtCCSQoTx5eAW5QCU6Mbxi3r78aNQLcNf'
#извлекаем из транзакции адрес отправителя
let this = extract(tx.sender)
let cashbackDivisor = 10
match tx {
#убеждаемся, что, если поступила дата-транзакция, то у нее ровно одно поле и в стейте еще нет такого ключа
case d : DataTransaction => size(d.data) == 1 && !isDefined(getBinary(this, d.data[0].key))
case e : TransferTransaction =>
#если у транзакции нет седьмого пруфа, проверяем корректность подписи
if !isDefined(e.proofs[7]) then
sigVerify(e.bodyBytes, e.proofs[0], e.senderPublicKey)
else
#если у транзакции есть седьмой пруф, извлекаем из него транзакцию и узнаём её высоту
let purchaseTx = transactionById(e.proofs[7])
let purchaseTxHeight = extract(transactionHeightById(e.proofs[7]))
#обрабатываем транзакцию из пруфа
match purchaseTx {
case purchase : TransferTransaction =>
let correctSender = purchase.sender == e.sender
let correctAsset = e.assetId == cashbackToken
let correctPrice = e.amount == purchase.amount / cashbackDivisor
#убеждаемся, что в транзакции-пруфе указан верный ID текущей транзакции
let correctProof = extract(getBinary(this, toBase58String(purchase.id))) == e.id
correctSender && correctAsset && correctPrice && correctProof
case _ => false
}
case _ => sigVerify(tx.bodyBytes, tx.proofs[0], tx.senderPublicKey)
}
Атомарный своп
Атомарный своп позволяет пользователям обмениваться ассетами без помощи биржи. При атомарном свопе от обоих участников сделки требуется ее подтверждение в течение определенного промежутка времени.
Если хотя бы один из участников не предоставляет корректного подтверждения транзакции в течение отведенного на сделку времени, транзакция отменяется и обмена не происходит.
В нашем примере мы будем использовать такой скрипт смарт-аккаунта:
let Bob = Address(base58'3NBVqYXrapgJP9atQccdBPAgJPwHDKkh6A8')
let Alice = Address(base58'3PNX6XwMeEXaaP1rf5MCk8weYeF7z2vJZBg')
let beforeHeight = 100000
let secret = base58'BN6RTYGWcwektQfSFzH8raYo9awaLgQ7pLyWLQY4S4F5'
match tx {
case t: TransferTransaction =>
let txToBob = t.recipient == Bob && sha256(t.proofs[0]) == secret && 20 + beforeHeight >= height
let backToAliceAfterHeight = height >= 21 + beforeHeight && t.recipient == Alice
txToBob || backToAliceAfterHeight
case _ => false
}
В следующей статье мы рассмотрим применение смарт-аккаунтов в финансовых инструментах — таких, как опционы, фьючерсы и векселя.