From e55921406a6344c664d25f4b1138d293b460e93c Mon Sep 17 00:00:00 2001 From: LukasDeco Date: Thu, 2 May 2024 09:13:00 -0600 Subject: [PATCH] feat: slippage calculations and validations --- lib/client/client.ts | 12 ++- .../indexer/market-clients/ammMarkets.ts | 26 +++++-- lib/client/rpc/market-clients/ammMarkets.ts | 74 +++++++++++++------ lib/trading.ts | 25 +++++++ lib/types/amm.ts | 4 +- tests/client/rpc/markets.test.ts | 20 ++++- 6 files changed, 122 insertions(+), 39 deletions(-) create mode 100644 lib/trading.ts diff --git a/lib/client/client.ts b/lib/client/client.ts index ccca2bd..9ade360 100644 --- a/lib/client/client.ts +++ b/lib/client/client.ts @@ -19,6 +19,7 @@ import { import { SwapType } from "@metadaoproject/futarchy-ts"; import { Observable } from "rxjs"; import { SpotObservation, TwapObservation } from "@/types/prices"; +import { BN } from "@coral-xyz/anchor"; export interface FutarchyClient { daos: FutarchyDaoClient; @@ -95,17 +96,20 @@ export interface FutarchyAmmMarketsClient { outputAmountMin: number ): Promise; removeLiquidity( - ammAddr: AmmMarket, - lpTokensToBurn: number + ammMarket: AmmMarket, + lpTokensToBurn: BN, + slippage: BN ): Promise; validateAddLiquidity( ammMarket: AmmMarket, quoteAmount: number, - maxBaseAmount: number + maxBaseAmount: number, + slippage: number ): LiquidityAddError | null; addLiquidity( ammAddr: AmmMarket, quoteAmount: number, - maxBaseAmount: number + maxBaseAmount: number, + slippage: number ): Promise; } diff --git a/lib/client/indexer/market-clients/ammMarkets.ts b/lib/client/indexer/market-clients/ammMarkets.ts index 0d0f026..041b663 100644 --- a/lib/client/indexer/market-clients/ammMarkets.ts +++ b/lib/client/indexer/market-clients/ammMarkets.ts @@ -12,6 +12,7 @@ import { } from "@/types"; import { PublicKey } from "@solana/web3.js"; import { SwapType } from "@metadaoproject/futarchy-ts"; +import { BN } from "@coral-xyz/anchor"; export class FutarchyIndexerAmmMarketsClient implements FutarchyAmmMarketsClient @@ -29,25 +30,29 @@ export class FutarchyIndexerAmmMarketsClient validateAddLiquidity( ammMarket: AmmMarket, - quoteAmount: number, - maxBaseAmount: number + quoteAmount: BN, + maxBaseAmount: BN, + slippage: BN ): LiquidityAddError | null { return this.rpcMarketsClient.validateAddLiquidity( ammMarket, quoteAmount, - maxBaseAmount + maxBaseAmount, + slippage ); } async addLiquidity( ammMarket: AmmMarket, - quoteAmount: number, - maxBaseAmount: number + quoteAmount: BN, + maxBaseAmount: BN, + slippage: BN ): Promise { return this.rpcMarketsClient.addLiquidity( ammMarket, quoteAmount, - maxBaseAmount + maxBaseAmount, + slippage ); } @@ -66,9 +71,14 @@ export class FutarchyIndexerAmmMarketsClient async removeLiquidity( ammMarket: AmmMarket, - lpTokensToBurn: number + lpTokensToBurn: BN, + slippage: BN ): Promise { - return this.rpcMarketsClient.removeLiquidity(ammMarket, lpTokensToBurn); + return this.rpcMarketsClient.removeLiquidity( + ammMarket, + lpTokensToBurn, + slippage + ); } async swap( diff --git a/lib/client/rpc/market-clients/ammMarkets.ts b/lib/client/rpc/market-clients/ammMarkets.ts index cba0297..05792d9 100644 --- a/lib/client/rpc/market-clients/ammMarkets.ts +++ b/lib/client/rpc/market-clients/ammMarkets.ts @@ -1,5 +1,6 @@ import { FutarchyAmmMarketsClient } from "@/client"; import { enrichTokenMetadata } from "@/tokens"; +import { calculateMaxWithSlippage, calculateMinWithSlippage } from "@/trading"; import { TransactionSender } from "@/transactions"; import { TokenWithBalance } from "@/types"; import { @@ -77,7 +78,8 @@ export class FutarchyAmmMarketsRPCClient implements FutarchyAmmMarketsClient { validateAddLiquidity( ammMarket: AmmMarket, quoteAmount: number, - maxBaseAmount: number + maxBaseAmount: number, + slippage: number ): LiquidityAddError | null { const quoteAmountArg = new BN( quoteAmount * @@ -87,17 +89,25 @@ export class FutarchyAmmMarketsRPCClient implements FutarchyAmmMarketsClient { maxBaseAmount * new BN(10).pow(new BN(ammMarket.baseToken.decimals)).toNumber() ); + const quoteAmountWithSlippage = calculateMaxWithSlippage( + quoteAmountArg, + slippage + ); + const baseAmountWithSlippage = calculateMaxWithSlippage( + baseAmountArg, + slippage + ); // base passed in should be ammBaseAmount honestly... - const baseReserve = new BN(ammMarket.baseAmount); - const quoteReserve = new BN(ammMarket.quoteAmount); + const baseReserve = ammMarket.baseAmount; + const quoteReserve = ammMarket.quoteAmount; - const ammBaseAmount = quoteAmountArg + const ammBaseAmount = new BN(quoteAmountWithSlippage) .mul(baseReserve) .div(quoteReserve) .add(new BN(1)); - if (baseAmountArg.toNumber() < ammBaseAmount.toNumber()) { - console.error( - `liquidity max base exceeded. baseAmountArg: ${baseAmountArg.toNumber()}. quoteAmountArg: ${quoteAmountArg.toNumber()}, ammBaseAmount: ${ammBaseAmount.toNumber()}` + if (baseAmountWithSlippage < ammBaseAmount.toNumber()) { + console.warn( + `liquidity max base exceeded. baseAmountArg: ${baseAmountWithSlippage}. quoteAmountArg: ${quoteAmountWithSlippage}, ammBaseAmount: ${ammBaseAmount.toNumber()}` ); return "AddLiquidityMaxBaseExceeded"; } @@ -107,14 +117,16 @@ export class FutarchyAmmMarketsRPCClient implements FutarchyAmmMarketsClient { async addLiquidity( ammMarket: AmmMarket, quoteAmount: number, - maxBaseAmount: number + maxBaseAmount: number, + slippage: number ): Promise { if (!this.transactionSender) return []; const validationError = this.validateAddLiquidity( ammMarket, quoteAmount, - maxBaseAmount + maxBaseAmount, + slippage ); if (validationError) { return validationError; @@ -124,25 +136,36 @@ export class FutarchyAmmMarketsRPCClient implements FutarchyAmmMarketsClient { quoteAmount * new BN(10).pow(new BN(ammMarket.quoteToken.decimals)).toNumber() ); + const maxBaseAmountArg = new BN( + maxBaseAmount * + new BN(10).pow(new BN(ammMarket.baseToken.decimals)).toNumber() + ); - // we just pass this in as min LP tokens and this replicates the calculation in the program - // TODO multiple this times slippage - const minLpTokensToMint = quoteAmountArg + const quoteAmountWithSlippage = calculateMaxWithSlippage( + quoteAmountArg, + slippage + ); + const maxBaseAmountWithSlippage = calculateMaxWithSlippage( + maxBaseAmountArg, + slippage + ); + + const minLpTokensToMint = new BN(quoteAmountWithSlippage) .mul(new BN(ammMarket.lpMintSupply)) .div(ammMarket.quoteAmount); - const maxBaseAmountArg = new BN( - maxBaseAmount * - new BN(10).pow(new BN(ammMarket.baseToken.decimals)).toNumber() + const minLpTokensWithSlippage = calculateMinWithSlippage( + minLpTokensToMint.toNumber(), + slippage ); const ix = this.ammClient.addLiquidityIx( ammMarket.publicKey, ammMarket.baseMint, ammMarket.quoteMint, - quoteAmountArg, - maxBaseAmountArg, - minLpTokensToMint, + new BN(quoteAmountWithSlippage), + new BN(maxBaseAmountWithSlippage), + new BN(minLpTokensWithSlippage), this.rpcProvider.publicKey ); const tx = await ix.transaction(); @@ -168,9 +191,18 @@ export class FutarchyAmmMarketsRPCClient implements FutarchyAmmMarketsClient { return simulation; } - async removeLiquidity(ammMarket: AmmMarket, lpTokensToBurn: BN) { - const minBaseAmount = new BN(0); - const minQuoteAmount = new BN(0); + async removeLiquidity( + ammMarket: AmmMarket, + lpTokensToBurn: BN, + slippage: BN + ) { + const lpRatio = lpTokensToBurn.div(new BN(ammMarket.lpMintSupply)); + const minQuoteAmount = lpRatio + .mul(new BN(ammMarket.quoteAmount)) + .mul(new BN(1 - slippage.toNumber())); + const minBaseAmount = lpRatio + .mul(new BN(ammMarket.baseAmount)) + .mul(new BN(1 - slippage.toNumber())); const ix = this.ammClient.removeLiquidityIx( ammMarket.publicKey, ammMarket.baseMint, diff --git a/lib/trading.ts b/lib/trading.ts new file mode 100644 index 0000000..10e6670 --- /dev/null +++ b/lib/trading.ts @@ -0,0 +1,25 @@ +/** + * Calculates the maximum value considering the slippage percentage. + * + * @param {number} maxValue - The original maximum value before slippage adjustment. + * @param {number} slippage - The slippage rate as a whole percentage (not decimal). i.e. if your slippage is 0.3%, pass in 0.3. + * This value will be divided by 100 internally to calculate the slippage percentage. + * @returns {number} The maximum value adjusted for slippage. + */ +export function calculateMaxWithSlippage(maxValue: number, slippage: number) { + const slippagePercent = slippage / 100; + return maxValue * (1 + slippagePercent); +} + +/** + * Calculates the minimum value considering the slippage percentage. + * + * @param {number} minValue - The original minimum value before slippage adjustment. + * @param {number} slippage - The slippage rate as a whole percentage (not decimal). i.e. if your slippage is 0.3%, pass in 0.3. + * This value will be divided by 100 internally to calculate the slippage percentage. + * @returns {number} The minimum value adjusted for slippage. + */ +export function calculateMinWithSlippage(minValue: number, slippage: number) { + const slippagePercent = slippage / 100; + return minValue * (1 - slippagePercent); +} diff --git a/lib/types/amm.ts b/lib/types/amm.ts index c166d8d..be4f7f5 100644 --- a/lib/types/amm.ts +++ b/lib/types/amm.ts @@ -12,8 +12,8 @@ export class AmmMarketFetchRequest implements MarketFetchRequest { export type AmmMarket = Market & { type: "amm"; - baseAmount: number; - quoteAmount: number; + baseAmount: BN; + quoteAmount: BN; lpMintSupply: number; }; diff --git a/tests/client/rpc/markets.test.ts b/tests/client/rpc/markets.test.ts index c2825ea..c5cd7cb 100644 --- a/tests/client/rpc/markets.test.ts +++ b/tests/client/rpc/markets.test.ts @@ -2,7 +2,7 @@ import { FutarchyRPCClient } from "@/client"; import { autocratVersionToTwapMap } from "@/constants"; import { TransactionSender } from "@/transactions"; import { AmmMarketFetchRequest, OpenbookMarketFetchRequest } from "@/types"; -import { AnchorProvider, Program } from "@coral-xyz/anchor"; +import { AnchorProvider, BN, Program } from "@coral-xyz/anchor"; import { Connection, PublicKey } from "@solana/web3.js"; import { describe, test, expect, beforeAll } from "bun:test"; import { createMockWallet } from "tests/test-utils"; @@ -51,7 +51,7 @@ describe("FutarchyRPCClient Integration Test", () => { console.log(marketData); // Log to verify data or perform assertions expect(marketData).toBeDefined(); // Simple check, adjust according to expected data structure }, 60000); - test.skip("addLiquidity test. This should likely not run in CI for now", async () => { + test("addLiquidity test. This should likely not run in CI for now", async () => { const request = new AmmMarketFetchRequest( new PublicKey("HbSYiZ8JRKqNHTx2EJUr6c5wQMvMjNx1rmHkTUtVi9qC") ); @@ -59,13 +59,25 @@ describe("FutarchyRPCClient Integration Test", () => { if (marketData?.type === "amm") { const txs = await rpcClient.markets.amm.addLiquidity( marketData, - 0.1, - 0.1 + 0.01, + 20, + 0.3 ); console.log(txs); // Log to verify data or perform assertions expect(txs).toBeDefined(); // Simple check, adjust according to expected data structure } }, 60000); + test.skip("remove liquidity test. This should likely not run in CI for now", async () => { + const request = new AmmMarketFetchRequest( + new PublicKey("HbSYiZ8JRKqNHTx2EJUr6c5wQMvMjNx1rmHkTUtVi9qC") + ); + const marketData = await rpcClient.markets.fetchMarket(request); + if (marketData?.type === "amm") { + const txs = await rpcClient.markets.amm.removeLiquidity(marketData, 10); + console.log(txs); // Log to verify data or perform assertions + expect(txs).toBeDefined(); // Simple check, adjust according to expected data structure + } + }, 60000); // Add more tests for other methods like cancelOrder, fetchOrderBook, etc. });