Как войти в блокчейн через JavaSсript: создаем свой DeFi-проект на базе JS SDK смарт-контрактов Waves Enterprise

от автора

Всем привет, я Тимофей Семенюк, 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.

Полезные ссылки


ссылка на оригинал статьи https://habr.com/ru/company/web3_tech/blog/700626/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *