diff --git a/package-lock.json b/package-lock.json index 24c9299..d389136 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@indigo-labs/dexter", - "version": "5.0.3", + "version": "5.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@indigo-labs/dexter", - "version": "5.0.3", + "version": "5.1.0", "license": "MIT", "dependencies": { "@types/crypto-js": "^4.1.1", diff --git a/src/dex/api/sundaeswap-api.ts b/src/dex/api/sundaeswap-v1-api.ts similarity index 98% rename from src/dex/api/sundaeswap-api.ts rename to src/dex/api/sundaeswap-v1-api.ts index c80973c..1e5e9a5 100644 --- a/src/dex/api/sundaeswap-api.ts +++ b/src/dex/api/sundaeswap-v1-api.ts @@ -6,7 +6,7 @@ import { SundaeSwapV1 } from '../sundaeswap-v1'; import { RequestConfig } from '@app/types'; import { appendSlash } from '@app/utils'; -export class SundaeSwapApi extends BaseApi { +export class SundaeSwapV1Api extends BaseApi { protected readonly api: AxiosInstance; protected readonly dex: SundaeSwapV1; diff --git a/src/dex/api/sundaeswap-v3-api.ts b/src/dex/api/sundaeswap-v3-api.ts new file mode 100644 index 0000000..2e04a85 --- /dev/null +++ b/src/dex/api/sundaeswap-v3-api.ts @@ -0,0 +1,114 @@ +import { BaseApi } from './base-api'; +import { Asset, Token } from '../models/asset'; +import { LiquidityPool } from '../models/liquidity-pool'; +import axios, { AxiosInstance } from 'axios'; +import { SundaeSwapV1 } from '../sundaeswap-v1'; +import { RequestConfig } from '@app/types'; +import { appendSlash } from '@app/utils'; +import { SundaeSwapV3 } from '@dex/sundaeswap-v3'; + +export class SundaeSwapV3Api extends BaseApi { + + protected readonly api: AxiosInstance; + protected readonly dex: SundaeSwapV3; + + constructor(dex: SundaeSwapV3, requestConfig: RequestConfig) { + super(); + + this.dex = dex; + this.api = axios.create({ + timeout: requestConfig.timeout, + baseURL: `${appendSlash(requestConfig.proxyUrl)}https://stats.sundaeswap.finance/graphql`, + headers: { + 'Content-Type': 'application/json', + } + }); + } + + liquidityPools(assetA: Token, assetB?: Token): Promise { + const maxPerPage: number = 100; + + const assetAId: string = (assetA === 'lovelace') + ? '' + : assetA.identifier('.'); + let assetBId: string = (assetB && assetB !== 'lovelace') + ? assetB.identifier('.') + : ''; + + const getPaginatedResponse = (page: number): Promise => { + return this.api.post('', { + operationName: 'getPoolsByAssetIds', + query: ` + query getPoolsByAssetIds($assetIds: [String!]!, $pageSize: Int, $page: Int) { + pools(assetIds: $assetIds, pageSize: $pageSize, page: $page) { + ...PoolFragment + } + } + fragment PoolFragment on Pool { + assetA { + ...AssetFragment + } + assetB { + ...AssetFragment + } + assetLP { + ...AssetFragment + } + name + fee + quantityA + quantityB + quantityLP + ident + assetID + } + fragment AssetFragment on Asset { + assetId + decimals + } + `, + variables: { + page: page, + pageSize: maxPerPage, + assetIds: [assetBId !== '' ? assetBId : assetAId], + }, + }).then((response: any) => { + const pools = response.data.data.pools; + const liquidityPools = pools.map((pool: any) => { + let liquidityPool: LiquidityPool = new LiquidityPool( + SundaeSwapV1.identifier, + pool.assetA.assetId + ? Asset.fromIdentifier(pool.assetA.assetId, pool.assetA.decimals) + : 'lovelace', + pool.assetB.assetId + ? Asset.fromIdentifier(pool.assetB.assetId, pool.assetB.decimals) + : 'lovelace', + BigInt(pool.quantityA), + BigInt(pool.quantityB), + this.dex.poolAddress, + this.dex.orderAddress, + this.dex.orderAddress, + ); + + liquidityPool.identifier = pool.ident; + liquidityPool.lpToken = Asset.fromIdentifier(pool.assetLP.assetId); + liquidityPool.poolFeePercent = Number(pool.fee); + liquidityPool.totalLpTokens = BigInt(pool.quantityLP); + + return liquidityPool; + }); + + if (pools.length < maxPerPage) { + return liquidityPools; + } + + return getPaginatedResponse(page + 1).then((nextPagePools: LiquidityPool[]) => { + return liquidityPools.concat(nextPagePools); + }); + }); + }; + + return getPaginatedResponse(0); + } + +} diff --git a/src/dex/base-dex.ts b/src/dex/base-dex.ts index c7366ec..78fd3f2 100644 --- a/src/dex/base-dex.ts +++ b/src/dex/base-dex.ts @@ -56,7 +56,7 @@ export abstract class BaseDex { /** * Fees associated with submitting a swap order. */ - abstract swapOrderFees(): SwapFee[]; + abstract swapOrderFees(liquidityPool?: LiquidityPool, swapInToken?: Token, swapInAmount?: bigint): SwapFee[]; /** * Adjust the payment for the DEX order address to include the swap in amount. diff --git a/src/dex/definitions/sundaeswap-v3/order.ts b/src/dex/definitions/sundaeswap-v3/order.ts new file mode 100644 index 0000000..88afb5b --- /dev/null +++ b/src/dex/definitions/sundaeswap-v3/order.ts @@ -0,0 +1,82 @@ +import { DatumParameterKey } from '@app/constants'; + +export default { + constructor: 0, + fields: [ + { + bytes: DatumParameterKey.PoolIdentifier + }, + { + constructor: 0, + fields: [ + { + constructor: 0, + fields: [ + { + constructor: 0, + fields: [ + { + constructor: 0, + fields: [ + { + bytes: DatumParameterKey.SenderPubKeyHash + } + ] + }, + { + constructor: 0, + fields: [ + { + constructor: 0, + fields: [ + { + constructor: 0, + fields: [ + { + bytes: DatumParameterKey.SenderStakingKeyHash + } + ] + } + ] + } + ] + } + ] + }, + { + constructor: 1, + fields: [] + } + ] + }, + { + constructor: 1, + fields: [] + } + ] + }, + { + int: DatumParameterKey.ScooperFee + }, + { + constructor: 0, + fields: [ + { + constructor: DatumParameterKey.Action, + fields: [] + }, + { + int: DatumParameterKey.SwapInAmount + }, + { + constructor: 0, + fields: [ + { + int: DatumParameterKey.MinReceive + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/dex/definitions/sundaeswap-v3/pool.ts b/src/dex/definitions/sundaeswap-v3/pool.ts new file mode 100644 index 0000000..188b3b5 --- /dev/null +++ b/src/dex/definitions/sundaeswap-v3/pool.ts @@ -0,0 +1,51 @@ +import { DatumParameterKey } from '@app/constants'; + +export default { + constructor: 0, + fields: [ + { + constructor: 0, + fields: [ + { + constructor: 0, + fields: [ + { + bytes: DatumParameterKey.PoolAssetAPolicyId + }, + { + bytes: DatumParameterKey.PoolAssetAAssetName + } + ] + }, + { + constructor: 0, + fields: [ + { + bytes: DatumParameterKey.PoolAssetBPolicyId + }, + { + bytes: DatumParameterKey.PoolAssetBAssetName + } + ] + } + ] + }, + { + bytes: DatumParameterKey.PoolIdentifier + }, + { + int: DatumParameterKey.TotalLpTokens + }, + { + constructor: 0, + fields: [ + { + int: DatumParameterKey.LpFeeNumerator + }, + { + int: DatumParameterKey.LpFeeDenominator + } + ] + } + ] +} \ No newline at end of file diff --git a/src/dex/sundaeswap-v1.ts b/src/dex/sundaeswap-v1.ts index cde1ad5..6dcf61a 100644 --- a/src/dex/sundaeswap-v1.ts +++ b/src/dex/sundaeswap-v1.ts @@ -18,7 +18,7 @@ import { AddressType, DatumParameterKey } from '@app/constants'; import pool from '@dex/definitions/sundaeswap-v1/pool'; import order from '@dex/definitions/sundaeswap-v1/order'; import { BaseApi } from '@dex/api/base-api'; -import { SundaeSwapApi } from '@dex/api/sundaeswap-api'; +import { SundaeSwapV1Api } from '@dex/api/sundaeswap-v1-api'; import { Script } from 'lucid-cardano'; export class SundaeSwapV1 extends BaseDex { @@ -41,7 +41,7 @@ export class SundaeSwapV1 extends BaseDex { constructor(requestConfig: RequestConfig = {}) { super(); - this.api = new SundaeSwapApi(this, requestConfig); + this.api = new SundaeSwapV1Api(this, requestConfig); } public async liquidityPoolAddresses(provider: BaseDataProvider): Promise { diff --git a/src/dex/sundaeswap-v3.ts b/src/dex/sundaeswap-v3.ts new file mode 100644 index 0000000..13093eb --- /dev/null +++ b/src/dex/sundaeswap-v3.ts @@ -0,0 +1,245 @@ +import { LiquidityPool } from './models/liquidity-pool'; +import { BaseDataProvider } from '@providers/data/base-data-provider'; +import { Asset, Token } from './models/asset'; +import { BaseDex } from './base-dex'; +import { + AssetBalance, + DatumParameters, + DefinitionConstr, + DefinitionField, + PayToAddress, + RequestConfig, SpendUTxO, + SwapFee, + UTxO +} from '@app/types'; +import { DefinitionBuilder } from '@app/definition-builder'; +import { correspondingReserves, tokensMatch } from '@app/utils'; +import { AddressType, DatumParameterKey } from '@app/constants'; +import pool from '@dex/definitions/sundaeswap-v1/pool'; +import order from '@dex/definitions/sundaeswap-v1/order'; +import { BaseApi } from '@dex/api/base-api'; +import { Script } from 'lucid-cardano'; +import { SundaeSwapV3Api } from '@dex/api/sundaeswap-v3-api'; + +export class SundaeSwapV3 extends BaseDex { + + public static readonly identifier: string = 'SundaeSwapV1'; + public readonly api: BaseApi; + + /** + * On-Chain constants. + */ + public readonly orderAddress: string = ''; + public readonly poolAddress: string = ''; + public readonly lpTokenPolicyId: string = ''; + public readonly cancelDatum: string = 'd87a80'; + public readonly orderScript: Script = { + type: 'PlutusV1', + script: '', + }; + + constructor(requestConfig: RequestConfig = {}) { + super(); + + this.api = new SundaeSwapV3Api(this, requestConfig); + } + + public async liquidityPoolAddresses(provider: BaseDataProvider): Promise { + return Promise.resolve([this.poolAddress]); + } + + async liquidityPools(provider: BaseDataProvider): Promise { + const utxos: UTxO[] = await provider.utxos(this.poolAddress); + + return await Promise.all( + utxos.map(async (utxo: UTxO) => { + return await this.liquidityPoolFromUtxo(provider, utxo); + }) + ) + .then((liquidityPools: (LiquidityPool | undefined)[]) => { + return liquidityPools.filter((liquidityPool?: LiquidityPool) => { + return liquidityPool !== undefined; + }) as LiquidityPool[]; + }); + } + + public async liquidityPoolFromUtxo(provider: BaseDataProvider, utxo: UTxO): Promise { + if (! utxo.datumHash) { + return Promise.resolve(undefined); + } + + const relevantAssets: AssetBalance[] = utxo.assetBalances.filter((assetBalance: AssetBalance) => { + const assetBalanceId: string = assetBalance.asset === 'lovelace' ? 'lovelace' : assetBalance.asset.identifier(); + + return ! assetBalanceId.startsWith(this.lpTokenPolicyId); + }); + + // Irrelevant UTxO + if (! [2, 3].includes(relevantAssets.length)) { + return Promise.resolve(undefined); + } + + // Could be ADA/X or X/X pool + const assetAIndex: number = relevantAssets.length === 2 ? 0 : 1; + const assetBIndex: number = relevantAssets.length === 2 ? 1 : 2; + + const liquidityPool: LiquidityPool = new LiquidityPool( + SundaeSwapV3.identifier, + relevantAssets[assetAIndex].asset, + relevantAssets[assetBIndex].asset, + relevantAssets[assetAIndex].quantity, + relevantAssets[assetBIndex].quantity, + utxo.address, + this.orderAddress, + this.orderAddress, + ); + + // Load additional pool information + const lpToken: Asset = utxo.assetBalances.find((assetBalance) => { + return assetBalance.asset !== 'lovelace' && assetBalance.asset.policyId === this.lpTokenPolicyId; + })?.asset as Asset; + + if (lpToken) { + lpToken.nameHex = '6c' + lpToken.nameHex; + liquidityPool.lpToken = lpToken; + liquidityPool.identifier = lpToken.identifier(); + } + + try { + const builder: DefinitionBuilder = await (new DefinitionBuilder()) + .loadDefinition(pool); + const datum: DefinitionField = await provider.datumValue(utxo.datumHash); + const parameters: DatumParameters = builder.pullParameters(datum as DefinitionConstr); + + liquidityPool.identifier = typeof parameters.PoolIdentifier === 'string' + ? parameters.PoolIdentifier + : ''; + liquidityPool.poolFeePercent = typeof parameters.LpFeeNumerator === 'number' && typeof parameters.LpFeeDenominator === 'number' + ? (parameters.LpFeeNumerator / parameters.LpFeeDenominator) * 100 + : 0; + liquidityPool.totalLpTokens = typeof parameters.TotalLpTokens === 'number' + ? BigInt(parameters.TotalLpTokens) + : 0n; + } catch (e) { + return liquidityPool; + } + + return liquidityPool; + } + + estimatedGive(liquidityPool: LiquidityPool, swapOutToken: Token, swapOutAmount: bigint): bigint { + const [reserveOut, reserveIn]: bigint[] = correspondingReserves(liquidityPool, swapOutToken); + + const receive: bigint = (reserveIn * reserveOut) / (reserveOut - swapOutAmount) - reserveIn; + const swapFee: bigint = ((receive * BigInt(Math.floor(liquidityPool.poolFeePercent * 100))) + BigInt(10000) - 1n) / 10000n; + + return receive + swapFee; + } + + estimatedReceive(liquidityPool: LiquidityPool, swapInToken: Token, swapInAmount: bigint): bigint { + const [reserveIn, reserveOut]: bigint[] = correspondingReserves(liquidityPool, swapInToken); + + const swapFee: bigint = ((swapInAmount * BigInt(Math.floor(liquidityPool.poolFeePercent * 100))) + BigInt(10000) - 1n) / 10000n; + + return reserveOut - (reserveIn * reserveOut) / (reserveIn + swapInAmount - swapFee); + } + + priceImpactPercent(liquidityPool: LiquidityPool, swapInToken: Token, swapInAmount: bigint): number { + const reserveIn: bigint = tokensMatch(swapInToken, liquidityPool.assetA) + ? liquidityPool.reserveA + : liquidityPool.reserveB; + + return (1 - (Number(reserveIn) / Number(reserveIn + swapInAmount))) * 100; + } + + public async buildSwapOrder(liquidityPool: LiquidityPool, swapParameters: DatumParameters, spendUtxos: SpendUTxO[] = []): Promise { + const scooperFee: SwapFee | undefined = this.swapOrderFees(liquidityPool).find((fee: SwapFee) => fee.id === 'scooperFee'); + const deposit: SwapFee | undefined = this.swapOrderFees(liquidityPool).find((fee: SwapFee) => fee.id === 'deposit'); + + if (! scooperFee || ! deposit) { + return Promise.reject('Parameters for datum are not set.'); + } + + const swapInToken: string = (swapParameters.SwapInTokenPolicyId as string) + (swapParameters.SwapInTokenAssetName as string); + const swapOutToken: string = (swapParameters.SwapOutTokenPolicyId as string) + (swapParameters.SwapOutTokenAssetName as string); + const swapDirection: number = [swapInToken, swapOutToken].sort((a: string, b: string) => { + return a.localeCompare(b); + })[0] === swapInToken ? 0 : 1; + + swapParameters = { + ...swapParameters, + [DatumParameterKey.ScooperFee]: scooperFee.value, + [DatumParameterKey.Action]: swapDirection, + }; + + const datumBuilder: DefinitionBuilder = new DefinitionBuilder(); + await datumBuilder.loadDefinition(order) + .then((builder: DefinitionBuilder) => { + builder.pushParameters(swapParameters); + }); + + return [ + this.buildSwapOrderPayment( + swapParameters, + { + address: this.orderAddress, + addressType: AddressType.Contract, + assetBalances: [ + { + asset: 'lovelace', + quantity: scooperFee.value + deposit.value, + }, + ], + datum: datumBuilder.getCbor(), + isInlineDatum: false, + spendUtxos: spendUtxos, + } + ) + ]; + } + + public async buildCancelSwapOrder(txOutputs: UTxO[], returnAddress: string): Promise { + const relevantUtxo: UTxO | undefined = txOutputs.find((utxo: UTxO) => { + return utxo.address === this.orderAddress; + }); + + if (! relevantUtxo) { + return Promise.reject('Unable to find relevant UTxO for cancelling the swap order.'); + } + + return [ + { + address: returnAddress, + addressType: AddressType.Base, + assetBalances: relevantUtxo.assetBalances, + isInlineDatum: false, + spendUtxos: [{ + utxo: relevantUtxo, + redeemer: this.cancelDatum, + validator: this.orderScript, + signer: returnAddress, + }], + } + ]; + } + + public swapOrderFees(liquidityPool: LiquidityPool): SwapFee[] { + return [ + { + id: 'protocolFee', + title: 'Sundae Protocol Fee', + description: 'Sundae Protocol Fee', + value: liquidityPool.extra.protocolFee ?? 2_500000n, + isReturned: false, + }, + { + id: 'deposit', + title: 'Deposit', + description: 'A small ADA deposit that you will get back when your order is processed or cancelled.', + value: 2_000000n, + isReturned: true, + }, + ]; + } + +} diff --git a/src/dexter.ts b/src/dexter.ts index 55860dc..bc4691e 100644 --- a/src/dexter.ts +++ b/src/dexter.ts @@ -12,12 +12,13 @@ import { BaseMetadataProvider } from '@providers/asset-metadata/base-metadata-pr import { TokenRegistryProvider } from '@providers/asset-metadata/token-registry-provider'; import { CancelSwapRequest } from '@requests/cancel-swap-request'; import { FetchRequest } from '@requests/fetch-request'; -import axios from "axios"; -import axiosRetry from "axios-retry"; +import axios from 'axios'; +import axiosRetry from 'axios-retry'; import { SplitSwapRequest } from '@requests/split-swap-request'; import { TeddySwap } from '@dex/teddyswap'; import { Spectrum } from '@dex/spectrum'; import { SplitCancelSwapRequest } from '@requests/split-cancel-swap-request'; +import { SundaeSwapV3 } from '@dex/sundaeswap-v3'; export class Dexter { @@ -59,6 +60,7 @@ export class Dexter { this.availableDexs = { [Minswap.identifier]: new Minswap(this.requestConfig), [SundaeSwapV1.identifier]: new SundaeSwapV1(this.requestConfig), + [SundaeSwapV3.identifier]: new SundaeSwapV3(this.requestConfig), [MuesliSwap.identifier]: new MuesliSwap(this.requestConfig), [WingRiders.identifier]: new WingRiders(this.requestConfig), [VyFinance.identifier]: new VyFinance(this.requestConfig), diff --git a/tests/sundaeswap.test.ts b/tests/sundaeswap-v1.test.ts similarity index 95% rename from tests/sundaeswap.test.ts rename to tests/sundaeswap-v1.test.ts index 35d20ac..8791557 100644 --- a/tests/sundaeswap.test.ts +++ b/tests/sundaeswap-v1.test.ts @@ -2,7 +2,7 @@ import { Asset, Dexter, LiquidityPool, - SundaeSwap, + SundaeSwapV1, MockDataProvider, SwapRequest, MockWalletProvider, @@ -13,7 +13,7 @@ import { UTxO, } from '../src'; -describe('SundaeSwap', () => { +describe('SundaeSwapV1', () => { const walletProvider: MockWalletProvider = new MockWalletProvider(); walletProvider.loadWalletFromSeedPhrase(['']); @@ -25,7 +25,7 @@ describe('SundaeSwap', () => { describe('Set Swap In', () => { const liquidityPool: LiquidityPool = new LiquidityPool( - SundaeSwap.identifier, + SundaeSwapV1.identifier, 'lovelace', asset, 3699642000000n, @@ -48,7 +48,7 @@ describe('SundaeSwap', () => { }); it('Can build swap order', () => { - const sundaeswap: SundaeSwap = new SundaeSwap(); + const sundaeswap: SundaeSwapV1 = new SundaeSwapV1(); const defaultSwapParameters: DatumParameters = { [DatumParameterKey.PoolIdentifier]: '1234', [DatumParameterKey.SenderPubKeyHash]: walletProvider.publicKeyHash(), @@ -77,7 +77,7 @@ describe('SundaeSwap', () => { describe('Set Swap Out', () => { const liquidityPool: LiquidityPool = new LiquidityPool( - SundaeSwap.identifier, + SundaeSwapV1.identifier, 'lovelace', asset, 1032791394311n, @@ -99,10 +99,11 @@ describe('SundaeSwap', () => { }); describe('SundaeSwap Cancel Order', () => { - let sundaeswap: SundaeSwap; + let sundaeswap: SundaeSwapV1; const returnAddress = 'addr1'; + beforeEach(() => { - sundaeswap = new SundaeSwap(); + sundaeswap = new SundaeSwapV1(); }); it('should successfully cancel an order', async () => {