Skip to content

Commit

Permalink
Fix: ExactOut + SOL input WSOL handling and missing refresh (#363)
Browse files Browse the repository at this point in the history
* fix missing refresh

* fix SOL input ExactOut edge-case

* add test cases

* add estimateMaxLiquidityFromTokenAmounts
  • Loading branch information
yugure-orca authored Oct 8, 2024
1 parent c50984c commit 69eb2f6
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 11 deletions.
2 changes: 1 addition & 1 deletion legacy-sdk/whirlpool/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
21 changes: 18 additions & 3 deletions legacy-sdk/whirlpool/src/instructions/composites/swap-async.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
resolveOrCreateATAs,
TransactionBuilder,
U64_MAX,
ZERO,
} from "@orca-so/common-sdk";
import type { PublicKey } from "@solana/web3.js";
Expand All @@ -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;
Expand All @@ -31,7 +33,7 @@ export async function swapAsync(
_opts: WhirlpoolAccountFetchOptions,
): Promise<TransactionBuilder> {
const { wallet, whirlpool, swapInput } = params;
const { aToB, amount } = swapInput;
const { aToB, amount, otherAmountThreshold, amountSpecifiedIsInput } = swapInput;
const txBuilder = new TransactionBuilder(
ctx.connection,
ctx.wallet,
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion legacy-sdk/whirlpool/src/quotes/public/swap-quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,10 @@ async function swapQuoteByToken(
fetcher: WhirlpoolAccountFetcherInterface,
opts?: WhirlpoolAccountFetchOptions,
): Promise<SwapQuoteParam> {
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(
Expand Down
38 changes: 32 additions & 6 deletions legacy-sdk/whirlpool/src/utils/public/pool-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,28 +155,53 @@ 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,
lowerTick: number,
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,
Expand All @@ -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
Expand Down
159 changes: 159 additions & 0 deletions legacy-sdk/whirlpool/tests/sdk/whirlpools/swap/swap-edge-case.test.ts
Original file line number Diff line number Diff line change
@@ -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/
);
});
});
});

0 comments on commit 69eb2f6

Please sign in to comment.