diff --git a/legacy-sdk/whirlpool/package.json b/legacy-sdk/whirlpool/package.json index f2f7b2c48..74a52cd5e 100644 --- a/legacy-sdk/whirlpool/package.json +++ b/legacy-sdk/whirlpool/package.json @@ -1,6 +1,6 @@ { "name": "@orca-so/whirlpools-sdk", - "version": "0.13.6", + "version": "0.13.7", "description": "Typescript SDK to interact with Orca's Whirlpool program.", "license": "Apache-2.0", "main": "dist/index.js", diff --git a/legacy-sdk/whirlpool/src/instructions/composites/swap-async.ts b/legacy-sdk/whirlpool/src/instructions/composites/swap-async.ts index 0dfb5800b..963e1cae8 100644 --- a/legacy-sdk/whirlpool/src/instructions/composites/swap-async.ts +++ b/legacy-sdk/whirlpool/src/instructions/composites/swap-async.ts @@ -1,6 +1,7 @@ import { resolveOrCreateATAs, TransactionBuilder, + U64_MAX, ZERO, } from "@orca-so/common-sdk"; import type { PublicKey } from "@solana/web3.js"; @@ -11,6 +12,7 @@ import type { SwapInput } from "../swap-ix"; import { swapIx } from "../swap-ix"; import { TokenExtensionUtil } from "../../utils/public/token-extension-util"; import { swapV2Ix } from "../v2"; +import { NATIVE_MINT } from "@solana/spl-token"; export type SwapAsyncParams = { swapInput: SwapInput; @@ -31,7 +33,7 @@ export async function swapAsync( _opts: WhirlpoolAccountFetchOptions, ): Promise { const { wallet, whirlpool, swapInput } = params; - const { aToB, amount } = swapInput; + const { aToB, amount, otherAmountThreshold, amountSpecifiedIsInput } = swapInput; const txBuilder = new TransactionBuilder( ctx.connection, ctx.wallet, @@ -41,12 +43,25 @@ export async function swapAsync( // No need to check if TickArrays are initialized after SparseSwap implementation const data = whirlpool.getData(); + + // In ExactOut mode, max input amount is otherAmountThreshold + const inputTokenMint = aToB ? data.tokenMintA : data.tokenMintB; + const maxInputAmount = amountSpecifiedIsInput ? amount : otherAmountThreshold; + if (inputTokenMint.equals(NATIVE_MINT) && maxInputAmount.eq(U64_MAX)) { + // Strictly speaking, the upper limit would be the wallet balance minus rent and fees, + // but that calculation is impractical. + // Since this function is called to perform a transaction, we can expect the otherAmountThreshold + // to be smaller than the wallet balance, and a run-time error would make the problem clear at worst. + // Here, the obviously impossible case (a value using defaultOtherAmountThreshold) will be an error. + throw new Error("Wrapping U64_MAX amount of SOL is not possible"); + } + const [resolvedAtaA, resolvedAtaB] = await resolveOrCreateATAs( ctx.connection, wallet, [ - { tokenMint: data.tokenMintA, wrappedSolAmountIn: aToB ? amount : ZERO }, - { tokenMint: data.tokenMintB, wrappedSolAmountIn: !aToB ? amount : ZERO }, + { tokenMint: data.tokenMintA, wrappedSolAmountIn: aToB ? maxInputAmount : ZERO }, + { tokenMint: data.tokenMintB, wrappedSolAmountIn: !aToB ? maxInputAmount : ZERO }, ], () => ctx.fetcher.getAccountRentExempt(), undefined, // use default diff --git a/legacy-sdk/whirlpool/src/quotes/public/swap-quote.ts b/legacy-sdk/whirlpool/src/quotes/public/swap-quote.ts index 8f1c2030b..15745dcb6 100644 --- a/legacy-sdk/whirlpool/src/quotes/public/swap-quote.ts +++ b/legacy-sdk/whirlpool/src/quotes/public/swap-quote.ts @@ -224,7 +224,10 @@ async function swapQuoteByToken( fetcher: WhirlpoolAccountFetcherInterface, opts?: WhirlpoolAccountFetchOptions, ): Promise { - const whirlpoolData = whirlpool.getData(); + // If we use whirlpool.getData() here, quote will not be the latest even if opts is IGNORE_CACHE + const whirlpoolData = await fetcher.getPool(whirlpool.getAddress(), opts); + invariant(!!whirlpoolData, "Whirlpool data not found"); + const swapMintKey = AddressUtil.toPubKey(inputTokenMint); const swapTokenType = PoolUtil.getTokenType(whirlpoolData, swapMintKey); invariant( diff --git a/legacy-sdk/whirlpool/src/utils/public/pool-utils.ts b/legacy-sdk/whirlpool/src/utils/public/pool-utils.ts index 297c72ce0..eb43af65e 100644 --- a/legacy-sdk/whirlpool/src/utils/public/pool-utils.ts +++ b/legacy-sdk/whirlpool/src/utils/public/pool-utils.ts @@ -155,6 +155,7 @@ export class PoolUtil { * @param upperTick - Position upper tick index * @param tokenAmount - The desired amount of tokens to deposit/withdraw * @returns An estimated amount of liquidity needed to deposit/withdraw the desired amount of tokens. + * @deprecated Please use {@link estimateMaxLiquidityFromTokenAmounts} instead. */ public static estimateLiquidityFromTokenAmounts( currTick: number, @@ -162,21 +163,45 @@ export class PoolUtil { upperTick: number, tokenAmount: TokenAmounts, ): BN { - if (upperTick < lowerTick) { + return this.estimateMaxLiquidityFromTokenAmounts( + PriceMath.tickIndexToSqrtPriceX64(currTick), + lowerTick, + upperTick, + tokenAmount, + ); + } + + /** + * Estimate the liquidity amount required to increase/decrease liquidity. + * + * @category Whirlpool Utils + * @param sqrtPriceX64 - Whirlpool's current sqrt price + * @param tickLowerIndex - Position lower tick index + * @param tickUpperIndex - Position upper tick index + * @param tokenAmount - The desired amount of tokens to deposit/withdraw + * @returns An estimated amount of liquidity needed to deposit/withdraw the desired amount of tokens. + */ + public static estimateMaxLiquidityFromTokenAmounts( + sqrtPriceX64: BN, + tickLowerIndex: number, + tickUpperIndex: number, + tokenAmount: TokenAmounts, + ): BN { + if (tickUpperIndex < tickLowerIndex) { throw new Error("upper tick cannot be lower than the lower tick"); } - const currSqrtPrice = PriceMath.tickIndexToSqrtPriceX64(currTick); - const lowerSqrtPrice = PriceMath.tickIndexToSqrtPriceX64(lowerTick); - const upperSqrtPrice = PriceMath.tickIndexToSqrtPriceX64(upperTick); + const currSqrtPrice = sqrtPriceX64; + const lowerSqrtPrice = PriceMath.tickIndexToSqrtPriceX64(tickLowerIndex); + const upperSqrtPrice = PriceMath.tickIndexToSqrtPriceX64(tickUpperIndex); - if (currTick >= upperTick) { + if (currSqrtPrice.gte(upperSqrtPrice)) { return estLiquidityForTokenB( upperSqrtPrice, lowerSqrtPrice, tokenAmount.tokenB, ); - } else if (currTick < lowerTick) { + } else if (currSqrtPrice.lt(lowerSqrtPrice)) { return estLiquidityForTokenA( lowerSqrtPrice, upperSqrtPrice, @@ -197,6 +222,7 @@ export class PoolUtil { } } + /** * Given an arbitrary pair of token mints, this function returns an ordering of the token mints * in the format [base, quote]. USD based stable coins are prioritized as the quote currency diff --git a/legacy-sdk/whirlpool/tests/sdk/whirlpools/swap/swap-edge-case.test.ts b/legacy-sdk/whirlpool/tests/sdk/whirlpools/swap/swap-edge-case.test.ts new file mode 100644 index 000000000..41030c9eb --- /dev/null +++ b/legacy-sdk/whirlpool/tests/sdk/whirlpools/swap/swap-edge-case.test.ts @@ -0,0 +1,159 @@ +import * as anchor from "@coral-xyz/anchor"; +import { Percentage } from "@orca-so/common-sdk"; +import * as assert from "assert"; +import BN from "bn.js"; +import { + PriceMath, + WhirlpoolContext, + buildWhirlpoolClient, + swapQuoteByInputToken, +} from "../../../../src"; +import { IGNORE_CACHE } from "../../../../src/network/public/fetcher"; +import { defaultConfirmOptions } from "../../../utils/const"; +import { NATIVE_MINT } from "@solana/spl-token"; +import { WhirlpoolTestFixture } from "../../../utils/fixture"; +import { swapQuoteByOutputToken } from "../../../../dist"; +import { SystemInstruction } from "@solana/web3.js"; +import { SwapUtils } from "../../../../dist/utils/public/swap-utils"; + +describe("swap edge case test", () => { + const provider = anchor.AnchorProvider.local( + undefined, + defaultConfirmOptions, + ); + + const program = anchor.workspace.Whirlpool; + const ctx = WhirlpoolContext.fromWorkspace(provider, program); + const fetcher = ctx.fetcher; + const client = buildWhirlpoolClient(ctx); + + describe("SOL Wrapping", () => { + async function buildTestFixture() { + const tickSpacing = 64; + const tickInitialIndex = -1988; + const tickUpperIndex = -64; + const tickLowerIndex = -3904; + const liquidityAmount = new BN(100000000000); + + return new WhirlpoolTestFixture(ctx).init( + { + tokenAIsNative: true, // build pool which is similar to SOL/mSOL + initialSqrtPrice: PriceMath.tickIndexToSqrtPriceX64(tickInitialIndex), + tickSpacing, + positions: [ + { tickLowerIndex, tickUpperIndex, liquidityAmount }, // In range position + ], + }, + ); + } + + it("ExactIn, SOL is input token", async () => { + const fixture = await buildTestFixture(); + const poolInitInfo = fixture.getInfos().poolInitInfo; + + const pool = await client.getPool(poolInitInfo.whirlpoolPda.publicKey, IGNORE_CACHE); + assert.ok(pool.getData().tokenMintA.equals(NATIVE_MINT)); + + const quote = await swapQuoteByInputToken( + pool, + pool.getData().tokenMintA, // SOL(tokenMintA) will be input + new BN(1_000_000_000), // 1 SOL (required input is obvilously 1 SOL + rent) + Percentage.fromFraction(0, 1000), + ctx.program.programId, + ctx.fetcher, + IGNORE_CACHE, + ); + + // ExactIn + assert.ok(quote.amountSpecifiedIsInput === true); + assert.ok(quote.aToB); + + // The value of mSOL > The value of SOL + assert.ok(quote.amount.eq(new BN(1_000_000_000))); // 1 SOL + assert.ok(quote.otherAmountThreshold.lt(new BN(900_000_000))); // < 0.9 mSOL + + const tx = await pool.swap(quote); + + // check wrapping instruction + const createAccountIx = tx.compressIx(true).instructions[0]; + const decoded = SystemInstruction.decodeCreateAccount(createAccountIx); + const tokenAccountRent = await fetcher.getAccountRentExempt(true); + const lamportsExpected = quote.amount.addn(tokenAccountRent); + assert.ok(lamportsExpected.eq(new BN(decoded.lamports))); + + await tx.buildAndExecute(); + }); + + it("ExactOut, SOL is input token", async () => { + const fixture = await buildTestFixture(); + const poolInitInfo = fixture.getInfos().poolInitInfo; + + const pool = await client.getPool(poolInitInfo.whirlpoolPda.publicKey, IGNORE_CACHE); + assert.ok(pool.getData().tokenMintA.equals(NATIVE_MINT)); + + const quote = await swapQuoteByOutputToken( + pool, + pool.getData().tokenMintB, // SOL(tokenMintA) will be input + new BN(1_000_000_000), // 1 mSOL (required input is obvilously larger than 1 SOL) + Percentage.fromFraction(0, 1000), + ctx.program.programId, + ctx.fetcher, + IGNORE_CACHE, + ); + + // ExactOut + assert.ok(quote.amountSpecifiedIsInput === false); + assert.ok(quote.aToB); + + // If WSOL amount is 1 WSOL, swap should be failed + assert.ok(quote.amount.eq(new BN(1_000_000_000))); // 1 mSOL + assert.ok(quote.otherAmountThreshold.gt(new BN(1_100_000_000))); // > 1.1 SOL + + const tx = await pool.swap(quote); + + // check wrapping instruction + const createAccountIx = tx.compressIx(true).instructions[0]; + const decoded = SystemInstruction.decodeCreateAccount(createAccountIx); + const tokenAccountRent = await fetcher.getAccountRentExempt(true); + const lamportsExpected = quote.otherAmountThreshold.addn(tokenAccountRent); + assert.ok(lamportsExpected.eq(new BN(decoded.lamports))); + + await tx.buildAndExecute(); + }); + + it("[Fail] ExactOut, SOL is input token, otherAmountThreshold is default value (U64_MAX)", async () => { + const fixture = await buildTestFixture(); + const poolInitInfo = fixture.getInfos().poolInitInfo; + + const pool = await client.getPool(poolInitInfo.whirlpoolPda.publicKey, IGNORE_CACHE); + assert.ok(pool.getData().tokenMintA.equals(NATIVE_MINT)); + + const quote = await swapQuoteByOutputToken( + pool, + pool.getData().tokenMintB, // SOL(tokenMintA) will be input + new BN(1_000_000_000), // 1 mSOL (required input is obvilously larger than 1 SOL) + Percentage.fromFraction(0, 1000), + ctx.program.programId, + ctx.fetcher, + IGNORE_CACHE, + ); + + // ExactOut + assert.ok(quote.amountSpecifiedIsInput === false); + assert.ok(quote.aToB); + + // If WSOL amount is 1 WSOL, swap should be failed + assert.ok(quote.amount.eq(new BN(1_000_000_000))); // 1 mSOL + assert.ok(quote.otherAmountThreshold.gt(new BN(1_100_000_000))); // > 1.1 SOL + + await assert.rejects( + pool.swap({ + ...quote, + // use default otherAmountThreshold (U64_MAX) + otherAmountThreshold: SwapUtils.getDefaultOtherAmountThreshold(quote.amountSpecifiedIsInput), + }), + /Wrapping U64_MAX amount of SOL is not possible/ + ); + }); + }); +});