Всем привет, я Тимофей Семенюк, fullstack-разработчик в команде Web3 Tech. Недавно мой коллега Степан писал о нашем Java/Kotlin SDK для смарт-контрактов. В этом посте я расскажу об аналогичном JavaScript SDK. А чтобы было интересней, в качестве примера создам на нем простой, но уже полноценный инструмент децентрализованных финансов — CPMM, Constant Product Market Maker (маркет-мейкер на основе постоянной формулы, такой, например, как Swop.fi).
Мы в Web3 Tech занимаемся корпоративными блокчейн-сервисами, основанными на приватных блокчейн-сетях — подробней о кейсах мы рассказывали в посте про open-source платформу. Но в этот раз в учебных целях мы зайдем на территорию Web 3.0 и создадим классический DeFi-сервис для работы в публичной сети, к которой может подключиться любой желающий.
Для начала — небольшая справка. DeFi (Decentralized Finances) — обобщенное понятие для всех сервисов, предоставляющих финансовые услуги в децентрализованном формате. Это означает, что все операции с вашими активами прозрачны, ваши средства всегда вам доступны, и наложить на них какие-либо ограничения практически невозможно. Этим DeFi отличается от централизованных финансов (CeFi) и живущих в этой парадигме сервисов (таких как, например, Binance).
Теперь — о роли смарт-контрактов. В сфере DeFi они, по сути, являются публичной офертой между пользователем и платформой. Смарт-контракты всегда доступны для аудита, что делает прозрачным взаимодействие пользователя и протокола. С другой стороны, из-за уязвимостей в протоколе пользовательские активы могут украсть, о чем мы все чаще слышим в новостях. Обширные возможности смарт-контрактов позволяют DeFi активно развиваться: в мире существуют проекты, где можно брать займы под залог криптовалют, торговать/обменивать криптовалюты, торговать всеми видами деривативов, включая активы фондовых рынков, индексы и прочие.
Что такое AMM
Любая экономика основана на обмене — товаров, денежных средств, материальных благ. На фондовых рынках обмен активами традиционно происходит по схеме сопоставления ордеров (order book). Покупатель выставляет цену покупки актива, продавец — цену продажи; эти условия сопоставляются, и при совпадении заключается сделка. По сути, это peer-to-peer взаимодействие между продавцом и покупателем.
В децентрализованных финансах всё немного иначе — свои коррективы вносит ликвидность, объемы активов на платформе. Даже в самых больших DeFi-сервисах ликвидность гораздо меньше, чем на централизованных платформах. Высока вероятность того, что продавец и покупатель просто не смогут найти друг друга на приемлемых условиях. Поэтому стоимости активов определяются здесь по математической формуле — для этого и существуют AMM, Automated Market Maker.
Помимо сопоставления продавцов и покупателей, AMM также отвечают за то, чтобы собирать комиссии за обмен. В дальнейшем они распределяются между теми, кто предоставил свои активы для использования в DeFi-сервисе. Это мы тоже воплотим в проекте,но пока остановимся подробней на том, как наш AMM будет определять цены активов.
Что такое CPMM
Существует множество реализаций AMM, и мы остановимся на варианте Constant Product Market Maker (CPMM). Его использует Uniswap, одна из крупнейших децентрализованных бирж в мире. CPMM основывается на простой формуле:
X * Y = K
Здесь X — ликвидность (количество на бирже) токена А; Y — ликвидность токена B; K — некая константа. Допустим, мы хотим обменять некоторое количество токенов Y на токены X; пусть это будет ΔY. Количество токенов X, которое для этого потребуется, можно рассчитать по формуле:
ΔX =K / Y + ΔY
А средняя цена обмена, соответственно, будет выглядеть так:
Avg. Price = ΔX / ΔY
В итоге после обмена в пуле уменьшится количество токенов X и, согласно формулам, уменьшится цена токена Y по отношению к X. На графике ниже отражено, как работает эта зависимость:
Теперь разработаем такой CPMM на базе нашей платформы с помощью JS SDK для смарт-контрактов.
CPMM на платформе Waves Enterprise с помощью JS Contract SDK
В этом примере мы сделаем AMM-пул выдуманных токенов Habr/Rbah. Для начала нам нужно развернуть ноду в локальном окружении. О том, как это сделать, писали в одном из предыдущих постов.
Теперь развернем в бойлерплейт проекта:
npm create we-contract CPMM --path ./cpmm-example
Эта команда создаст тестовый контракт в папке cpmm-example и установит все зависимости, после чего мы можем приступать к разработке контракта. Смарт-контракты у нас работают в виде докер-сервисов, по этой теме в блоге ранее вышел пост.
Наш смарт-контракт будет состоять как минимум из трех функций (экшенов) и конструктора:
addLiquidity — добавить ликвидность в пул;
removeLiquidity — забрать ликвидность из пула;
swap — обменять токен.
claimRewards — забрать награду за поставленную ликвидность в пул.
Взаимодействие в рамках CPMM выглядит так:
Начнем с инициализации контракта. Создадим метод, который будет принимать идентификаторы токенов пула, процент комиссии при обмене в пользу поставщиков ликвидности, а затем будет записывать их в стейт.
Для этого создадим метод класса с декоратором @Action({onInit: true})
. Этот метод будет вызываться при инициализации контракта транзакцией CreateContractTransaction (type 103). Параметры вызова контракта пробрасываются в метод с помощью декоратора @Param(paramName)
.
Также инициализируем переменные состояния контракта. Reserve0 и reserve1 — это текущее состояние ликвидности в пуле, то есть параметры X и Y в указанной выше формуле AMM. TotalSupply — это количество выпущенных LP-токенов для поставщиков ликвидности. В начальный момент оно равно нулю.
Вот так реализуется метод:
@Action({onInit: true})
async _constructor(
@Param('asset0') asset0: string,
@Param('asset1') asset1: string,
@Param('feeRate') feeRate: number,
) {
this.feeRate.set(feeRate);
this.asset0.set(asset0);
this.asset1.set(asset1);
this.totalSupply.set(0);
this.reserve0.set(0);
this.reserve1.set(0);
}
Функция addLiquidity
Реализуем функцию addLiquidity. Для этого в созданном нами классе CPMM добавим метод addLiquidity и аннотируем его декоратором Action:
@Action
async addLiquidity(
@Payments payments: AttachedPayments
) {
const [
reserve0,
reserve1,
totalSupply
] = await preload(this, ['reserve0', 'reserve1', 'totalSupply'])
const amountIn0 = payments[0].amount;
const amountIn1 = payments[1].amount;
if (reserve0.gt(0) || reserve1.gt(0)) {
assert(amountIn1.mul(reserve0).eq(amountIn0.mul(reserve1)), "Providing liquidity rebalances pool")
}
let shares: BN;
if (totalSupply === 0) {
shares = sqrt(amountIn0.mul(amountIn1))
} else {
shares = BN.min(
amountIn0.mul(totalSupply).div(reserve0),
amountIn1.mul(totalSupply).div(reserve1)
);
}
assert(!shares.isZero(), 'issued lp tokens should > 0')
await this.mint(shares);
}
Функция addLiquidity принимает от провайдера ликвидности платежи в двух токенах, для которых этот пул инициализован. При этом стоимость актива после добавления ликвидности не изменяется. Затем функция считает количество LP-токенов — токенов провайдера ликвидности — которые должен получить пользователь. Для этого мы выбрали простую формулу:
f(x, y) = sqrt(A * B)
Через функцию mint эти токены выпускаются и отправляются пользователю. Реализация функции mint:
private async mint(qty: BN, recipient: string) {
let assetId = await this.assetId.get()
let LPAsset: Asset;
if (!assetId) {
const nonce = 1;
assetId = await Asset.calculateAssetId(nonce);
LPAsset = Asset.from(assetId)
LPAsset.issue({
name: 'ExampleAMM_Pair_LP',
description: 'ExampleAMM LP Shares',
assetId: assetId,
nonce: nonce,
decimals: 8,
isReissuable: true,
quantity: qty.toNumber()
})
this.assetId.set(assetId);
} else {
LPAsset = Asset.from(assetId);
LPAsset.reissue({
quantity: qty.toNumber(),
isReissuable: true
})
}
LPAsset.transfer(recipient, qty.toNumber())
}
Метод swap
Приступим к реализации основной функции — к методу swap. С его помощью пользователь, отправивший платеж в токене А, получит взамен токен Б по текущей цене. Формулу расчета мы указали выше:
ΔX =K / Y + ΔY
Создадим метод swap в нашем контракте и аннотируем его декоратором Action, чтобы метод был доступен для вызова. Добавим в параметры вызова payments (нам точно понадобятся данные о приложенном платеже) и контекст — в нем хранятся все данные из транзакции. Пока что нам понадобится только sender — адрес отправителя:
```
@Action()
async swap(
@Payments payments: AttachedPayments,
@Ctx ctx: ExecutionContext
) {
}
```
Каждое чтение ключа на контракте — это вызов RPC. Чтобы улучшить производительность и не перегружать ноду, можно значения, нужные нам при выполнении, загружать предварительно. Для этого воспользуемся методом preload. Он предзагрузит нужные нам значения за один RPC, и далее в рамках вызова мы будем использовать уже закешированные значения.
const [feeRate, asset0, asset1]: [
number, string, string
] = await preload(
this,
['feeRate', 'asset0', 'asset1', 'reserve0', 'reserve1']
);
Проверка платежа
Далее нам нужно проверить, что платеж действительно приложен и исчисляется в одном из двух токенов нашего пула. Для этого напишем небольшой хелпер:
function assert(cond: boolean, err: string) {
if (!cond) {
throw new ContractError(err)
}
}
Опишем проверки в методе. Если платеж не удовлетворяет нашим требованиям, то такую транзакцию мы будем отклонять:
const from = payments[0];
assert(!from, 'Payment required!')
assert(
asset0 !== from.assetId || asset1 !== from.assetId,
`Attached payment should be only of ${asset0} or ${asset1}`
)
Опишем основную логику метода:
проверяем, что обмениваем токены Habr -> Rbah или наоборот;
вычитаем комиссию пула из приложенного платежа;
производим обмен.
Реализация этой части метода:
let [tokenOut, reserveIn, reserveOut] = asset0 === from.assetId
? [asset1, this.reserve0, this.reserve1]
: [asset0, this.reserve1, this.reserve0]
const amountInWithFee = from.amount.mul(new BN(feeRate / (10 ** 6)));
const amountOut = amountInWithFee.muln(await reserveOut.get()).div(amountInWithFee.addn(await reserveIn.get()));
const reserveOutAfter = amountOut.subn(await reserveOut.get()).abs();
const reserveInAfter = MathUtils.dsum(await reserveIn.get(), amountInWithFee.toNumber());
reserveIn.set(reserveInAfter.toNumber());
reserveOut.set(reserveOutAfter.toNumber());
Asset.from(tokenOut).transfer(ctx.tx.sender, amountOut.toNumber())
Полный код метода
@Action()
async swap(
@Payments payments: AttachedPayments,
@Ctx ctx: ExecutionContext
) {
const [feeRate, asset0, asset1]: [
number, string, string
] = await preload(
this,
['feeRate', 'asset0', 'asset1', 'reserve0', 'reserve1']
) as any;
const from = payments[0];
assert(!from, 'Payment required!')
assert(
asset0 !== from.assetId || asset1 !== from.assetId,
`Attached payment should be only of ${asset0} or ${asset1}`
)
let [tokenOut, reserveIn, reserveOut] = asset0 === from.assetId
? [asset1, this.reserve0, this.reserve1]
: [asset0, this.reserve1, this.reserve0]
const amountInWithFee = from.amount.mul(new BN(feeRate / (10 ** 6)));
const amountOut = amountInWithFee.muln(await reserveOut.get()).div(amountInWithFee.addn(await reserveIn.get()));
const reserveOutAfter = amountOut.subn(await reserveOut.get()).abs();
const reserveInAfter = MathUtils.dsum(await reserveIn.get(), amountInWithFee.toNumber());
reserveIn.set(reserveInAfter.toNumber());
reserveOut.set(reserveOutAfter.toNumber());
Asset.from(tokenOut).transfer(ctx.tx.sender, amountOut.toNumber())
}
Тестирование «HabrAMM»
Для начала нам нужно выпустить токены, которые мы впоследствии положим в пул. Для этого проведем транзакции Issue (type 3) для токенов Habr/Rbah. Воспользуемся JS SDK для подписания и отправки транзакций. Предварительно установим пакет @wavesenterprise/sdk в наш проект командой
``
npm i –save-dev @wavesenterprise/sdk
```
Напишем простой скрипт для выпуска токенов:
const SEED_LOCAL = ‘your seed here'
const NODE_LOCAL = 'http://localhost:6862'
const sdk = new We(NODE_LOCAL);
async function issue({name, desc}) {
const config = await sdk.node.config();
const fee = config[TRANSACTION_TYPES.Issue];
const keyPair = await Keypair.fromExistingSeedPhrase(SEED_LOCAL);
const tx = TRANSACTIONS.Issue.V2({
fee: fee,
reissuable: false,
quantity: 10000000000,
decimals: 6,
name: name,
description: desc,
amount: 10000000000,
senderPublicKey: await keyPair.publicKey()
})
const signedTx = await sdk.signer.getSignedTx(tx, SEED_LOCAL);
const sentTx = await sdk.broadcast(signedTx);
await waitForTx(sentTx.id)
console.log('Token successfully issued')
}
В транзакции Issue мы указали название и описание нашего токена, количество выпускаемых токенов и то, является ли токен перевыпускаемым. После выполнения скрипта и добавления транзакций в блокчейн id транзакции станет нашим assetId.
Сборка и деплой контракта
В развернутом нами проекте есть Dockerfile и скрипт build.sh. Он создаст контейнер, и на выходе мы получим хеш образа, с которым сформируем транзакцию создания контракта. Этот образ можно запушить на hub.docker.io, но в моем случае я развернул локально docker registry и публиковать контракт буду локально. Выполним команду:
./build.sh localhost:5001/habr-amm:latest
После успешного выполнения увидим сообщение:
```
image - localhost:5001/habr-amm:latest
imageHash - ec5c0ec4163bcd78d8317b4b18f13271a61fe555bfd66e56bb9136b7bb3fc2b7
```
ImageHash — это и есть хеш образа, с которым мы будем формировать транзакцию создания токена. По этому же принципу сформируем скрипт создания контракта.
Код скрипта
async function deploy() {
const config = await sdk.node.config();
const fee = config[TRANSACTION_TYPES];
const keyPair = await Keypair.fromExistingSeedPhrase(SEED_LOCAL);
const tx = TRANSACTIONS.CreateContract.V5({
fee,
imageHash: "ec5c0ec4163bcd78d8317b4b18f13271a61fe555bfd66e56bb9136b7bb3fc2b7",
image: "habr-amm:latest",
validationPolicy: {type: "any"},
senderPublicKey: await keyPair.publicKey(),
params: [
{
key: 'asset0',
type: 'string',
value: '8nAvDr6rVGNn4HVvv1f6ovmopbq2otSoPWtaK5Eogr9A'
},
{
key: 'asset1',
type: 'string',
value: 'Hteuf5cn2zU6XLHNV225M4S3WdfgRfB1BMsGZZa6a2vc'
},
{
key: 'feeRate',
type: 'integer',
value: 30000
}
],
payments: [],
contractName: "HabrAMM",
apiVersion: "1.0"
});
const signedTx = await sdk.signer.getSignedTx(tx, SEED_LOCAL);
const sentTx = await sdk.broadcast(signedTx);
}
ID транзакции и будет идентификатором транзакции. Убедимся, что она выполнена, через запрос:
http://localhost:6862/transactions/info/6AjT2SntNQm56d3DLHxnoXLB1StQWh1wFGqRzhq5wS51
Отлично, транзакция выполнена и добавлена в блокчейн. В докере появился контейнер. Теперь добавим ликвидность в наш пул через созданный экшен addLiquidity. Сформируем и отправим транзакцию:
const tx = TRANSACTIONS.CallContract.V5({
fee,
contractId: '6AjT2SntNQm56d3DLHxnoXLB1StQWh1wFGqRzhq5wS51',
senderPublicKey: await keyPair.publicKey(),
params: [
{
key: 'action', value: 'addLiquidity', type: 'string'
}
],
payments: [
{assetId: '8nAvDr6rVGNn4HVvv1f6ovmopbq2otSoPWtaK5Eogr9A', amount: 10000000},
{assetId: 'Hteuf5cn2zU6XLHNV225M4S3WdfgRfB1BMsGZZa6a2vc', amount: 10000000}
],
contractVersion: 18,
atomicBadge: null,
apiVersion: "1.0"
})
Так мы инициализируем пул в соотношении 1:1, то есть 1 Habr = 1 Rbah. Отправим транзакцию и посмотрим результат выполнения через метод:
http://localhost:6862/contracts/executed-tx-for/6StFo39eXQ3WcVNNA2XzzeVMq5PBDJcApRGU9Ycsci3X
В ответе увидим, что был создан новый LP-токен, подтверждающий владение ликвидностью в пуле HabrAMM. Теперь у нас на балансе вместо токенов есть Liquidity Provider Token, и на контракте применились изменения. А в пуле появилась ликвидность, и мы можем попробовать обменять наши токены.
Первый обмен в HabrAMM
Вызовем метод swap нашего контракта. Сформированная транзакция выглядит так:
const tx = TRANSACTIONS.CallContract.V5({
fee,
contractId: '6AjT2SntNQm56d3DLHxnoXLB1StQWh1wFGqRzhq5wS51',
senderPublicKey: await keyPair.publicKey(),
params: [
{
key: 'action', value: 'swap', type: 'string'
}
],
payments: [
{assetId: 8nAvDr6rVGNn4HVvv1f6ovmopbq2otSoPWtaK5Eogr9A, amount: 100000},
],
contractVersion: 18,
atomicBadge: null,
apiVersion: "1.0"
});
Распространим транзакцию и посмотрим результат выполнения транзакции методом
http://localhost:6862/contracts/executed-tx-for/r7rLmkWUgVbHp9UocL4bCNoJ17wSsH2hkdvv1k8H7jg
Видим, что транзакция исполнилась, применилась комиссия в 3% и в результате мы обменяли 100000 Habr (97000 с учетом комиссии) на 96906 Rbah. При этом обмен прошел по коэффициенту не 1:1, как мы инициализировали AMM, а по ~1.001:1 (97000 / 96906). Баланс токенов AMM сместился, и цена поменялась на десятую процента.
Выводы и планы
Итак, мы смогли реализовать простой Constant Product AMM на блокчейне Waves Enterprise с помощью JS Contract SDK. AMM — это основа DeFi на любом блокчейне. Вокруг AMM можно строить любые DEX, создавать платформы для торговли синтетическими активами, торговли с плечом, деривативами, акциями и сырьевыми активами. С учетом текущей геополитической ситуации и активного развития сферы цифровых активов, можно предположить, что подобные инструменты будут развиваться еще активней благодаря своей прозрачности и невозможности изъятия активов у трейдеров.
В ближайшем будущем планируется проработать инструменты для деплоя и билда контрактов, разработать инструменты их тестирования. Также в будущем планируется перевести SDK контрактов на WebAssembly.