From 44e1057cbdb7dac7057e51a797b32839ef53e3d3 Mon Sep 17 00:00:00 2001 From: Richard Nguyen Date: Thu, 25 Jul 2024 12:01:19 +0700 Subject: [PATCH] implement Pool V2 creation transaction (#32) * implement Pool V2 creation transaction * rename --- examples/example.ts | 20 ++++ src/adapter.ts | 57 +++++++++++ src/calculate.ts | 22 +++++ src/dex-v2.ts | 209 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/types/asset.ts | 9 ++ src/types/constants.ts | 12 ++- src/types/factory.ts | 91 ++++++++++++++++++ src/types/pool.ts | 8 +- src/types/string.ts | 11 +++ 10 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 src/dex-v2.ts create mode 100644 src/types/factory.ts create mode 100644 src/types/string.ts diff --git a/examples/example.ts b/examples/example.ts index b6bc611..4114414 100644 --- a/examples/example.ts +++ b/examples/example.ts @@ -22,6 +22,7 @@ import { calculateWithdraw, calculateZapIn, Dex, + DexV2, NetworkId, PoolV1, } from "../src"; @@ -349,6 +350,25 @@ async function _cancelTxExample( }); } +async function _createPoolV2( + lucid: Lucid, + blockFrostAdapter: BlockfrostAdapter +): Promise { + const dexV2 = new DexV2(lucid, blockFrostAdapter); + const txComplete = await dexV2.createPoolTx({ + assetA: ADA, + assetB: { + policyId: "e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed72", + tokenName: "434d", + }, + amountA: 10_000000n, + amountB: 300_000000n, + tradingFeeNumerator: 100n, + }); + + return txComplete; +} + /** * Initialize Lucid Instance for Browser Environment * @param network Network you're working on diff --git a/src/adapter.ts b/src/adapter.ts index 6182f6e..e3a2abe 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -12,6 +12,7 @@ import { DexV2Constant, StableswapConstant, } from "./types/constants"; +import { FactoryV2 } from "./types/factory"; import { NetworkId } from "./types/network"; import { PoolV1, PoolV2, StablePool } from "./types/pool"; import { @@ -19,6 +20,7 @@ import { isValidPoolOutput, normalizeAssets, } from "./types/pool.internal"; +import { StringUtils } from "./types/string"; import { TxHistory } from "./types/tx.internal"; import { getScriptHashFromAddress } from "./utils/address-utils.internal"; @@ -393,4 +395,59 @@ export class BlockfrostAdapter { return null; } + + public async getAllFactoriesV2(): Promise<{ + factories: FactoryV2.State[]; + errors: unknown[]; + }> { + const v2Config = DexV2Constant.CONFIG[this.networkId]; + const utxos = await this.api.addressesUtxosAssetAll( + v2Config.factoryScriptHashBech32, + v2Config.factoryAsset + ); + + const factories: FactoryV2.State[] = []; + const errors: unknown[] = []; + for (const utxo of utxos) { + try { + if (!utxo.inline_datum) { + throw new Error( + `Cannot find datum of Factory V2, tx: ${utxo.tx_hash}` + ); + } + const factory = new FactoryV2.State( + this.networkId, + utxo.address, + { txHash: utxo.tx_hash, index: utxo.output_index }, + utxo.amount, + utxo.inline_datum + ); + factories.push(factory); + } catch (err) { + errors.push(err); + } + } + return { + factories: factories, + errors: errors, + }; + } + + public async getFactoryV2ByPair( + assetA: Asset, + assetB: Asset + ): Promise { + const factoryIdent = PoolV2.computeLPAssetName(assetA, assetB); + const { factories: allFactories } = await this.getAllFactoriesV2(); + for (const factory of allFactories) { + if ( + StringUtils.compare(factory.head, factoryIdent) < 0 && + StringUtils.compare(factoryIdent, factory.tail) < 0 + ) { + return factory; + } + } + + return null; + } } diff --git a/src/calculate.ts b/src/calculate.ts index 3d763d8..3e5b415 100644 --- a/src/calculate.ts +++ b/src/calculate.ts @@ -200,3 +200,25 @@ export function calculateZapIn(options: CalculateZapInOptions): bigint { (reserveOut - swapToAssetOutAmount) ); } + +export namespace DexV2Calculation { + export const MAX_LIQUIDITY = 9_223_372_036_854_775_807n; + export const DEFAULT_POOL_ADA = 4_500_000n; + // The amount of liquidity that will be locked in pool when creating pools + export const MINIMUM_LIQUIDITY = 10n; + + export type InitialLiquidityOptions = { + amountA: bigint; + amountB: bigint; + }; + export function calculateInitialLiquidity({ + amountA, + amountB, + }: InitialLiquidityOptions): bigint { + let x = sqrt(amountA * amountB); + if (x * x < amountA * amountB) { + x += 1n; + } + return x; + } +} diff --git a/src/dex-v2.ts b/src/dex-v2.ts new file mode 100644 index 0000000..ca4be65 --- /dev/null +++ b/src/dex-v2.ts @@ -0,0 +1,209 @@ +import invariant from "@minswap/tiny-invariant"; +import { + Assets, + Constr, + Data, + getAddressDetails, + Lucid, + TxComplete, +} from "lucid-cardano"; + +import { + Asset, + BlockfrostAdapter, + DexV2Calculation, + DexV2Constant, + MetadataMessage, + PoolV2, +} from "."; +import { FactoryV2 } from "./types/factory"; +import { NetworkId } from "./types/network"; + +/** + * Options for building Pool V2 Creation transaction + * @assetA + * @assetB + * @amountA + * @amountB + * @tradingFeeNumerator numerator of Pool's trading fee with denominator 10000 + * Eg: + * - fee 0.05% -> tradingFeeNumerator 5 + * - fee 0.3% -> tradingFeeNumerator 30 + * - fee 1% -> tradingFeeNumerator 100 + */ +export type CreatePoolV2Options = { + assetA: Asset; + assetB: Asset; + amountA: bigint; + amountB: bigint; + tradingFeeNumerator: bigint; +}; + +export class DexV2 { + private readonly lucid: Lucid; + private readonly networkId: NetworkId; + private readonly adapter: BlockfrostAdapter; + + constructor(lucid: Lucid, adapter: BlockfrostAdapter) { + this.lucid = lucid; + this.networkId = + lucid.network === "Mainnet" ? NetworkId.MAINNET : NetworkId.TESTNET; + this.adapter = adapter; + } + + async createPoolTx({ + assetA, + assetB, + amountA, + amountB, + tradingFeeNumerator, + }: CreatePoolV2Options): Promise { + const config = DexV2Constant.CONFIG[this.networkId]; + // Sort ascendingly assets and its amount + const [sortedAssetA, sortedAssetB, sortedAmountA, sortedAmountB] = + Asset.compare(assetA, assetB) < 0 + ? [assetA, assetB, amountA, amountB] + : [assetB, assetA, amountB, amountA]; + + const factory = await this.adapter.getFactoryV2ByPair( + sortedAssetA, + sortedAssetB + ); + invariant( + factory, + `cannot find available Factory V2 Utxo, the liquidity pool might be created before` + ); + + const initialLiquidity = DexV2Calculation.calculateInitialLiquidity({ + amountA: sortedAmountA, + amountB: sortedAmountB, + }); + const remainingLiquidity = + DexV2Calculation.MAX_LIQUIDITY - + (initialLiquidity - DexV2Calculation.MINIMUM_LIQUIDITY); + const lpAssetName = PoolV2.computeLPAssetName(sortedAssetA, sortedAssetB); + const lpAsset: Asset = { + policyId: config.lpPolicyId, + tokenName: lpAssetName, + }; + const poolBatchingStakeCredential = getAddressDetails( + config.poolBatchingAddress + )?.stakeCredential; + invariant( + poolBatchingStakeCredential, + `cannot parse Liquidity Pool batching address` + ); + const poolDatum: PoolV2.Datum = { + poolBatchingStakeCredential: poolBatchingStakeCredential, + assetA: sortedAssetA, + assetB: sortedAssetB, + totalLiquidity: initialLiquidity, + reserveA: sortedAmountA, + reserveB: sortedAmountB, + baseFee: { + feeANumerator: tradingFeeNumerator, + feeBNumerator: tradingFeeNumerator, + }, + feeSharingNumerator: undefined, + allowDynamicFee: false, + }; + + const poolValue: Assets = { + lovelace: DexV2Calculation.DEFAULT_POOL_ADA, + [Asset.toString(lpAsset)]: remainingLiquidity, + [config.poolAuthenAsset]: 1n, + }; + if (poolValue[Asset.toString(sortedAssetA)]) { + poolValue[Asset.toString(sortedAssetA)] += sortedAmountA; + } else { + poolValue[Asset.toString(sortedAssetA)] = sortedAmountA; + } + if (poolValue[Asset.toString(sortedAssetB)]) { + poolValue[Asset.toString(sortedAssetB)] += sortedAmountB; + } else { + poolValue[Asset.toString(sortedAssetB)] = sortedAmountB; + } + + const deployedScripts = DexV2Constant.DEPLOYED_SCRIPTS[this.networkId]; + + const factoryRefs = await this.lucid.utxosByOutRef([ + deployedScripts.factory, + ]); + invariant( + factoryRefs.length === 1, + "cannot find deployed script for Factory Validator" + ); + const factoryRef = factoryRefs[0]; + const authenRefs = await this.lucid.utxosByOutRef([deployedScripts.authen]); + invariant( + authenRefs.length === 1, + "cannot find deployed script for Authen Minting Policy" + ); + const authenRef = authenRefs[0]; + const factoryUtxos = await this.lucid.utxosByOutRef([ + { + txHash: factory.txIn.txHash, + outputIndex: factory.txIn.index, + }, + ]); + invariant(factoryUtxos.length === 1, "cannot find Utxo of Factory"); + const factoryUtxo = factoryUtxos[0]; + + const factoryRedeemer: FactoryV2.Redeemer = { + assetA: sortedAssetA, + assetB: sortedAssetB, + }; + + const newFactoryDatum1: FactoryV2.Datum = { + head: factory.head, + tail: lpAssetName, + }; + const newFactoryDatum2: FactoryV2.Datum = { + head: lpAssetName, + tail: factory.tail, + }; + + return this.lucid + .newTx() + .readFrom([factoryRef, authenRef]) + .collectFrom( + [factoryUtxo], + Data.to(FactoryV2.Redeemer.toPlutusData(factoryRedeemer)) + ) + .payToContract( + config.poolCreationAddress, + { + inline: Data.to(PoolV2.Datum.toPlutusData(poolDatum)), + }, + poolValue + ) + .payToContract( + config.factoryAddress, + { + inline: Data.to(FactoryV2.Datum.toPlutusData(newFactoryDatum1)), + }, + { + [config.factoryAsset]: 1n, + } + ) + .payToContract( + config.factoryAddress, + { + inline: Data.to(FactoryV2.Datum.toPlutusData(newFactoryDatum2)), + }, + { + [config.factoryAsset]: 1n, + } + ) + .mintAssets( + { + [Asset.toString(lpAsset)]: DexV2Calculation.MAX_LIQUIDITY, + [config.factoryAsset]: 1n, + [config.poolAuthenAsset]: 1n, + }, + Data.to(new Constr(1, [])) + ) + .attachMetadata(674, { msg: [MetadataMessage.CREATE_POOL] }) + .complete(); + } +} diff --git a/src/index.ts b/src/index.ts index a273057..f975a77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from "./adapter"; export * from "./calculate"; export * from "./dex"; +export * from "./dex-v2"; export * from "./types/asset"; export * from "./types/constants"; export * from "./types/network"; diff --git a/src/types/asset.ts b/src/types/asset.ts index 1de3dbe..8239186 100644 --- a/src/types/asset.ts +++ b/src/types/asset.ts @@ -1,5 +1,7 @@ import { Constr, Data } from "lucid-cardano"; +import { StringUtils } from "./string"; + export const ADA: Asset = { policyId: "", tokenName: "" @@ -51,4 +53,11 @@ export namespace Asset { tokenName: data.fields[1] as string } } + + export function compare(a1: Asset, a2: Asset): number { + if (a1.policyId === a2.policyId) { + return StringUtils.compare(a1.tokenName, a2.tokenName) + } + return StringUtils.compare(a1.policyId, a2.policyId) + } } \ No newline at end of file diff --git a/src/types/constants.ts b/src/types/constants.ts index 5658f12..a0b86cf 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -1,4 +1,4 @@ -import { Address, OutRef,Script } from "lucid-cardano"; +import { Address, OutRef, Script } from "lucid-cardano"; import { NetworkId } from "./network"; @@ -294,11 +294,14 @@ export namespace DexV2Constant { globalSettingAsset: string; lpPolicyId: string; globalSettingScriptHash: string; + globalSettingScriptHashBech32: string; orderScriptHash: string; poolScriptHash: string; poolScriptHashBech32: string; poolCreationAddress: Address; + factoryScriptHashBech32: string; factoryScriptHash: string; + factoryAddress: string; expiredOrderCancelAddress: string; poolBatchingAddress: string; } @@ -319,11 +322,14 @@ export namespace DexV2Constant { globalSettingAsset: "d6aae2059baee188f74917493cf7637e679cd219bdfbbf4dcbeb1d0b4d534753", lpPolicyId: "d6aae2059baee188f74917493cf7637e679cd219bdfbbf4dcbeb1d0b", globalSettingScriptHash: "d6aae2059baee188f74917493cf7637e679cd219bdfbbf4dcbeb1d0b", + globalSettingScriptHashBech32: "script1664wypvm4msc3a6fzayneamr0enee5sehham7nwtavwsk2s2vg9", orderScriptHash: "da9525463841173ad1230b1d5a1b5d0a3116bbdeb4412327148a1b7a", poolScriptHash: "d6ba9b7509eac866288ff5072d2a18205ac56f744bc82dcd808cb8fe", poolScriptHashBech32: "script166afkagfatyxv2y075rj62scypdv2mm5f0yzmnvq3ju0uqqmszv", poolCreationAddress: "addr_test1zrtt4xm4p84vse3g3l6swtf2rqs943t0w39ustwdszxt3l5rajt8r8wqtygrfduwgukk73m5gcnplmztc5tl5ngy0upqhns793", factoryScriptHash: "6e23fe172b5b50e2ad59aded9ee8d488f74c7f4686f91b032220adad", + factoryScriptHashBech32: "script1dc3lu9ettdgw9t2e4hkea6x53rm5cl6xsmu3kqezyzk66vpljxc", + factoryAddress: "addr_test1zphz8lsh9dd4pc4dtxk7m8hg6jy0wnrlg6r0jxcrygs2mtvrajt8r8wqtygrfduwgukk73m5gcnplmztc5tl5ngy0upqjgg24z", expiredOrderCancelAddress: "stake_test17rytpnrpxax5p8leepgjx9cq8ecedgly6jz4xwvvv4kvzfqz6sgpf", poolBatchingAddress: "stake_test17rann6nth9675m0y5tz32u3rfhzcfjymanxqnfyexsufu5glcajhf", }, @@ -333,11 +339,14 @@ export namespace DexV2Constant { globalSettingAsset: "f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c4d534753", lpPolicyId: "f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c", globalSettingScriptHash: "f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c", + globalSettingScriptHashBech32: "script17kqgctyepkrd549le97cnnhxa73qekzxzctrt9rcm945c880puk", orderScriptHash: "c3e28c36c3447315ba5a56f33da6a6ddc1770a876a8d9f0cb3a97c4c", poolScriptHash: "ea07b733d932129c378af627436e7cbc2ef0bf96e0036bb51b3bde6b", poolScriptHashBech32: "script1agrmwv7exgffcdu27cn5xmnuhsh0p0ukuqpkhdgm800xksw7e2w", poolCreationAddress: "addr1z84q0denmyep98ph3tmzwsmw0j7zau9ljmsqx6a4rvaau66j2c79gy9l76sdg0xwhd7r0c0kna0tycz4y5s6mlenh8pq777e2a", factoryScriptHash: "7bc5fbd41a95f561be84369631e0e35895efb0b73e0a7480bb9ed730", + factoryScriptHashBech32: "script100zlh4q6jh6kr05yx6trrc8rtz27lv9h8c98fq9mnmtnqfa47eg", + factoryAddress: "addr1z9aut775r22l2cd7ssmfvv0qudvftmaskulq5ayqhw0dwvzj2c79gy9l76sdg0xwhd7r0c0kna0tycz4y5s6mlenh8pqgjw6pl", expiredOrderCancelAddress: "stake178ytpnrpxax5p8leepgjx9cq8ecedgly6jz4xwvvv4kvzfq9s6295", poolBatchingAddress: "stake17y02a946720zw6pw50upt2arvxsvvpvaghjtl054h0f0gjsfyjz59", } @@ -422,6 +431,7 @@ export enum MetadataMessage { SWAP_EXACT_IN_LIMIT_ORDER = "SDK Minswap: Swap Exact In Limit Order", SWAP_EXACT_OUT_ORDER = "SDK Minswap: Swap Exact Out Order", WITHDRAW_ORDER = "SDK Minswap: Withdraw Order", + CREATE_POOL = "SDK Minswap: Create Pool", } export const FIXED_DEPOSIT_ADA = 2_000_000n; diff --git a/src/types/factory.ts b/src/types/factory.ts new file mode 100644 index 0000000..c496c9d --- /dev/null +++ b/src/types/factory.ts @@ -0,0 +1,91 @@ +import { Constr, Data } from "lucid-cardano"; + +import { Asset } from "./asset"; +import { DexV2Constant } from "./constants"; +import { NetworkId } from "./network"; +import { TxIn, Value } from "./tx.internal"; + +export namespace FactoryV2 { + export type Datum = { + head: string; + tail: string; + } + + export namespace Datum { + export function toPlutusData(datum: Datum): Constr { + return new Constr(0, [ + datum.head, + datum.tail + ]) + } + + export function fromPlutusData(data: Constr): Datum { + if (data.index !== 0) { + throw new Error(`Index of Factory V2 Datum must be 0, actual: ${data.index}`); + } + return { + head: data.fields[0] as string, + tail: data.fields[1] as string + } + } + } + + export type Redeemer = { + assetA: Asset, + assetB: Asset + } + + export namespace Redeemer { + export function toPlutusData(redeemer: Redeemer): Constr { + return new Constr(0, [ + Asset.toPlutusData(redeemer.assetA), + Asset.toPlutusData(redeemer.assetB) + ]) + } + + export function fromPlutusData(data: Constr): Redeemer { + if (data.index !== 0) { + throw new Error(`Index of Factory V2 Datum must be 0, actual: ${data.index}`); + } + return { + assetA: Asset.fromPlutusData(data.fields[0] as Constr), + assetB: Asset.fromPlutusData(data.fields[1] as Constr) + } + } + } + + export class State { + public readonly address: string; + public readonly txIn: TxIn; + public readonly value: Value; + public readonly datumCbor: string; + public readonly datum: Datum; + + constructor( + networkId: NetworkId, + address: string, + txIn: TxIn, + value: Value, + datum: string + ) { + this.address = address + this.txIn = txIn + this.value = value + this.datumCbor = datum + this.datum = Datum.fromPlutusData(Data.from(datum)) + + const config = DexV2Constant.CONFIG[networkId] + if (!value.find((v) => v.unit === config.factoryAsset && v.quantity === "1")) { + throw new Error("Cannot find the Factory Authentication Asset in the value") + } + } + + get head(): string { + return this.datum.head + } + + get tail(): string { + return this.datum.tail + } + } +} \ No newline at end of file diff --git a/src/types/pool.ts b/src/types/pool.ts index 53ef0ca..d1cce8b 100644 --- a/src/types/pool.ts +++ b/src/types/pool.ts @@ -373,7 +373,7 @@ export namespace PoolV2 { allowDynamicFee } = datum; return new Constr(0, [ - LucidCredential.toPlutusData(poolBatchingStakeCredential), + new Constr(0, [LucidCredential.toPlutusData(poolBatchingStakeCredential)]), Asset.toPlutusData(assetA), Asset.toPlutusData(assetB), totalLiquidity, @@ -392,6 +392,10 @@ export namespace PoolV2 { if (data.index !== 0) { throw new Error(`Index of Pool Datum must be 0, actual: ${data.index}`); } + const stakeCredentialConstr = data.fields[0] as Constr + if (stakeCredentialConstr.index !== 0) { + throw new Error(`Index of Stake Credential must be 0, actual: ${stakeCredentialConstr.index}`); + } let feeSharingNumerator: bigint | undefined = undefined; const maybeFeeSharingConstr = data.fields[8] as Constr; switch (maybeFeeSharingConstr.index) { @@ -412,7 +416,7 @@ export namespace PoolV2 { const allowDynamicFeeConstr = data.fields[9] as Constr; const allowDynamicFee = allowDynamicFeeConstr.index === 1; return { - poolBatchingStakeCredential: LucidCredential.fromPlutusData(data.fields[0] as Constr), + poolBatchingStakeCredential: LucidCredential.fromPlutusData(stakeCredentialConstr.fields[0] as Constr), assetA: Asset.fromPlutusData(data.fields[1] as Constr), assetB: Asset.fromPlutusData(data.fields[2] as Constr), totalLiquidity: data.fields[3] as bigint, diff --git a/src/types/string.ts b/src/types/string.ts new file mode 100644 index 0000000..3b3ab03 --- /dev/null +++ b/src/types/string.ts @@ -0,0 +1,11 @@ +export namespace StringUtils { + export function compare(s1: string, s2: string): number { + if (s1 < s2) { + return -1; + } + if (s1 === s2) { + return 0; + } + return 1; + } +} \ No newline at end of file