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

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

Комментарии (0)