From 7f05704f392b1039cef357771bf50f895fe08461 Mon Sep 17 00:00:00 2001 From: meep <33380758+odcheung@users.noreply.github.com> Date: Wed, 5 Jul 2023 20:36:36 +0800 Subject: [PATCH] Implement RouterUtils.getPriceImpactForRoute (#108) - Add a function in RouterUtils to allow calculation of price impact - Supports multi-split & multi-hop routes - bump to v0.11.1 --- sdk/package.json | 4 +- .../composites/swap-with-route.ts | 4 +- sdk/src/prices/price-module.ts | 7 +- sdk/src/router/public/index.ts | 12 + sdk/src/router/public/router-utils.ts | 60 +++- sdk/src/router/quote-map.ts | 7 +- sdk/src/utils/math/constants.ts | 3 + sdk/src/utils/public/price-math.ts | 4 + .../router/router-util#priceImpact.test.ts | 281 ++++++++++++++++++ 9 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 sdk/src/utils/math/constants.ts create mode 100644 sdk/tests/sdk/router/router-util#priceImpact.test.ts diff --git a/sdk/package.json b/sdk/package.json index fd6e7ed..5e32166 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@orca-so/whirlpools-sdk", - "version": "0.11.0", + "version": "0.11.1", "description": "Typescript SDK to interact with Orca's Whirlpool program.", "license": "Apache-2.0", "main": "dist/index.js", @@ -64,4 +64,4 @@ "url": "https://github.com/orca-so/whirlpools/issues" }, "homepage": "https://www.orca.so" -} +} \ No newline at end of file diff --git a/sdk/src/instructions/composites/swap-with-route.ts b/sdk/src/instructions/composites/swap-with-route.ts index ed58ffc..4dcf720 100644 --- a/sdk/src/instructions/composites/swap-with-route.ts +++ b/sdk/src/instructions/composites/swap-with-route.ts @@ -97,7 +97,9 @@ export async function getSwapFromRoute( ] ); - const inputAmount = quoteOne.amountSpecifiedIsInput ? quoteOne.estimatedAmountIn : quoteOne.otherAmountThreshold; + const inputAmount = quoteOne.amountSpecifiedIsInput + ? quoteOne.estimatedAmountIn + : quoteOne.otherAmountThreshold; addOrNative(mintOneA.toString(), quoteOne.aToB ? inputAmount : ZERO); addOrNative(mintOneB.toString(), !quoteOne.aToB ? inputAmount : ZERO); addOrNative(mintTwoA.toString(), ZERO); diff --git a/sdk/src/prices/price-module.ts b/sdk/src/prices/price-module.ts index bb76e55..4bf1bf1 100644 --- a/sdk/src/prices/price-module.ts +++ b/sdk/src/prices/price-module.ts @@ -93,7 +93,12 @@ export class PriceModule { AddressUtil.toStrings(filteredPoolAddresses) ); - const tickArrayMap = await PriceModuleUtils.fetchTickArraysForPools(fetcher, poolMap, config, opts); + const tickArrayMap = await PriceModuleUtils.fetchTickArraysForPools( + fetcher, + poolMap, + config, + opts + ); const mints = Array.from( Object.values(poolMap).reduce((acc, pool) => { acc.add(pool.tokenMintA.toBase58()); diff --git a/sdk/src/router/public/index.ts b/sdk/src/router/public/index.ts index 5e3f3e2..24ac33f 100644 --- a/sdk/src/router/public/index.ts +++ b/sdk/src/router/public/index.ts @@ -90,6 +90,7 @@ export type SubTradeRoute = { * @param vaultA The address of the first vault in the pool. * @param vaultB The address of the second vault in the pool. * @param quote The {@link SwapQuote} for this hop. + * @param snapshot A snapshot of the whirlpool condition when this hop was made */ export type TradeHop = { amountIn: BN; @@ -102,6 +103,17 @@ export type TradeHop = { vaultA: Address; vaultB: Address; quote: SwapQuote; + snapshot: TradeHopSnapshot; +}; + +/** + * A snapshot of the whirlpool condition when a trade hop was made. + * @category Router + */ +export type TradeHopSnapshot = { + aToB: boolean; + sqrtPrice: BN; + feeRate: Percentage; }; /** diff --git a/sdk/src/router/public/router-utils.ts b/sdk/src/router/public/router-utils.ts index 911ae6b..1f3604e 100644 --- a/sdk/src/router/public/router-utils.ts +++ b/sdk/src/router/public/router-utils.ts @@ -8,10 +8,14 @@ import { } from "@orca-so/common-sdk"; import { Account } from "@solana/spl-token"; import { PublicKey } from "@solana/web3.js"; -import { ExecutableRoute, RoutingOptions, TradeRoute } from "."; +import BN from "bn.js"; +import Decimal from "decimal.js"; +import { ExecutableRoute, RoutingOptions, Trade, TradeRoute } from "."; import { WhirlpoolContext } from "../../context"; import { getSwapFromRoute } from "../../instructions/composites/swap-with-route"; import { PREFER_CACHE } from "../../network/public/fetcher"; +import { U64 } from "../../utils/math/constants"; +import { PriceMath } from "../../utils/public"; import { isWalletConnected } from "../../utils/wallet-utils"; /** @@ -135,6 +139,60 @@ export class RouterUtils { return null; } + /** + * Calculate the price impact for a route. + * @param trade The trade the user used to derive the route. + * @param route The route to calculate the price impact for. + * @returns A Decimal object representing the percentage value of the price impact (ex. 3.01%) + */ + static getPriceImpactForRoute(trade: Trade, route: TradeRoute): Decimal { + const { amountSpecifiedIsInput } = trade; + + const totalBaseValue = route.subRoutes.reduce((acc, route) => { + const directionalHops = amountSpecifiedIsInput + ? route.hopQuotes + : route.hopQuotes.slice().reverse(); + const baseOutputs = directionalHops.reduce((acc, quote, index) => { + const { snapshot } = quote; + const { aToB, sqrtPrice, feeRate } = snapshot; + // Inverse sqrt price will cause 1bps precision loss since ticks are spaces of 1bps + const directionalSqrtPrice = aToB ? sqrtPrice : PriceMath.invertSqrtPriceX64(sqrtPrice); + + // Convert from in/out -> base_out/in using the directional price & fee rate + let nextBaseValue; + const price = directionalSqrtPrice.mul(directionalSqrtPrice).div(U64); + if (amountSpecifiedIsInput) { + const amountIn = index === 0 ? quote.amountIn : acc[index - 1]; + const feeAdjustedAmount = amountIn + .mul(feeRate.denominator.sub(feeRate.numerator)) + .div(feeRate.denominator); + nextBaseValue = price.mul(feeAdjustedAmount).div(U64); + } else { + const amountOut = index === 0 ? quote.amountOut : acc[index - 1]; + const feeAdjustedAmount = amountOut.mul(U64).div(price); + nextBaseValue = feeAdjustedAmount + .mul(feeRate.denominator) + .div(feeRate.denominator.sub(feeRate.numerator)); + } + + acc.push(nextBaseValue); + return acc; + }, new Array()); + + return acc.add(baseOutputs[baseOutputs.length - 1]); + }, new BN(0)); + + const totalBaseValueDec = new Decimal(totalBaseValue.toString()); + const totalAmountEstimatedDec = new Decimal( + amountSpecifiedIsInput ? route.totalAmountOut.toString() : route.totalAmountIn.toString() + ); + const priceImpact = amountSpecifiedIsInput + ? totalBaseValueDec.sub(totalAmountEstimatedDec).div(totalBaseValueDec) + : totalAmountEstimatedDec.sub(totalBaseValueDec).div(totalAmountEstimatedDec); + + return priceImpact.mul(100); + } + /** * Get the tick arrays addresses that are touched by a route. * @param route The route to get the tick arrays from. diff --git a/sdk/src/router/quote-map.ts b/sdk/src/router/quote-map.ts index 953c082..83e797c 100644 --- a/sdk/src/router/quote-map.ts +++ b/sdk/src/router/quote-map.ts @@ -5,7 +5,7 @@ import BN from "bn.js"; import { SwapErrorCode } from "../errors/errors"; import { PREFER_CACHE, WhirlpoolAccountFetcherInterface } from "../network/public/fetcher"; import { SwapQuoteParam, swapQuoteWithParams } from "../quotes/public"; -import { Path } from "../utils/public"; +import { Path, PoolUtil } from "../utils/public"; import { SwapQuoteRequest, batchBuildSwapQuoteParams } from "./batch-swap-quote"; import { RoutingOptions, Trade, TradeHop } from "./public"; @@ -124,6 +124,11 @@ function populateQuoteMap( vaultA, vaultB, quote, + snapshot: { + aToB: swapParam.aToB, + sqrtPrice: whirlpoolData.sqrtPrice, + feeRate: PoolUtil.getFeeRate(whirlpoolData.feeRate), + }, }; } catch (e: any) { const errorCode: SwapErrorCode = e.errorCode; diff --git a/sdk/src/utils/math/constants.ts b/sdk/src/utils/math/constants.ts new file mode 100644 index 0000000..2c75413 --- /dev/null +++ b/sdk/src/utils/math/constants.ts @@ -0,0 +1,3 @@ +import { ONE, U64_MAX } from "@orca-so/common-sdk"; + +export const U64 = U64_MAX.add(ONE); diff --git a/sdk/src/utils/public/price-math.ts b/sdk/src/utils/public/price-math.ts index 4c0c409..9e33bdc 100644 --- a/sdk/src/utils/public/price-math.ts +++ b/sdk/src/utils/public/price-math.ts @@ -126,6 +126,8 @@ export class PriceMath { /** * Utility to invert the price Pb/Pa to Pa/Pb + * NOTE: precision is lost in this conversion + * * @param price Pb / Pa * @param decimalsA Decimals of original token A (i.e. token A in the given Pb / Pa price) * @param decimalsB Decimals of original token B (i.e. token B in the given Pb / Pa price) @@ -139,6 +141,8 @@ export class PriceMath { /** * Utility to invert the sqrtPriceX64 from X64 repr. of sqrt(Pb/Pa) to X64 repr. of sqrt(Pa/Pb) + * NOTE: precision is lost in this conversion + * * @param sqrtPriceX64 X64 representation of sqrt(Pb / Pa) * @returns inverted sqrtPriceX64, i.e. X64 representation of sqrt(Pa / Pb) */ diff --git a/sdk/tests/sdk/router/router-util#priceImpact.test.ts b/sdk/tests/sdk/router/router-util#priceImpact.test.ts new file mode 100644 index 0000000..59d845a --- /dev/null +++ b/sdk/tests/sdk/router/router-util#priceImpact.test.ts @@ -0,0 +1,281 @@ +import { Percentage } from "@orca-so/common-sdk"; +import { PublicKey } from "@solana/web3.js"; +import * as assert from "assert"; +import BN from "bn.js"; +import Decimal from "decimal.js"; +import { PriceMath, RouterUtils } from "../../../src"; +import { U64 } from "../../../src/utils/math/constants"; + +const maxDecimalAccuracy = 4; +describe("RouterUtil - Price Impact tests", () => { + // Mock a Orca -> USDC ExactIn trade that has no split route and goes through a single hop (ORCA -> USDC) + it("ExactIn, a->b true, single-hop, 1 split", () => { + const params: RouteTestParam = { + amountSpecifiedIsInput: true, + totalAmountIn: new BN("1000000"), + totalAmountOut: new BN("581050"), + subRouteParams: [ + { + hops: [ + { + aToB: true, + feeRate: Percentage.fromFraction(3000, 1000000), + sqrtPrice: new BN("14082503933855903449"), + amountIn: new BN("1000000"), + amountOut: new BN("581050"), + }, + ], + }, + ], + }; + const { trade, routes } = buildRouteTest(params); + const impact = RouterUtils.getPriceImpactForRoute(trade, routes).toDecimalPlaces( + maxDecimalAccuracy + ); + const expect = calculateImpact(params).toDecimalPlaces(maxDecimalAccuracy); + assert.equal(impact.toString(), expect.toString()); + }); + + // Mock a Orca -> USDC ExactOut trade that has no split route and goes through a single hop (ORCA -> USDC) + it("ExactOut, a->b false, single-hop, 1 split", () => { + const params: RouteTestParam = { + amountSpecifiedIsInput: false, + totalAmountIn: new BN("5833496"), + totalAmountOut: new BN("10000000"), + subRouteParams: [ + { + hops: [ + { + aToB: false, + feeRate: Percentage.fromFraction(3000, 1000000), + sqrtPrice: new BN("14067691597581169278"), + amountIn: new BN("5833496"), + amountOut: new BN("10000000"), + }, + ], + }, + ], + }; + const { trade, routes } = buildRouteTest(params); + const impact = RouterUtils.getPriceImpactForRoute(trade, routes).toDecimalPlaces( + maxDecimalAccuracy + ); + const expect = calculateImpact(params).toDecimalPlaces(maxDecimalAccuracy); + assert.equal(impact.toString(), expect.toString()); + }); + + // Mock a ORCA -> USDC trade that has 2 split route and goes through a multi-hop (ORCA -> SOL -> USDC) + it("ExactIn, mix a->b, single & multi-hop, 2 splits", () => { + const params: RouteTestParam = { + amountSpecifiedIsInput: true, + totalAmountIn: new BN("40000000000"), + totalAmountOut: new BN("22277933969"), + subRouteParams: [ + { + hops: [ + { + aToB: false, + feeRate: Percentage.fromFraction(3000, 1000000), + sqrtPrice: new BN("3363616053614750676"), + amountIn: new BN("32000000000"), + amountOut: new BN("925083736236"), + }, + { + aToB: true, + feeRate: Percentage.fromFraction(3000, 1000000), + sqrtPrice: new BN("2567715337494939945"), + amountIn: new BN("925083736236"), + amountOut: new BN("17871834810"), + }, + ], + }, + { + hops: [ + { + aToB: true, + feeRate: Percentage.fromFraction(3000, 1000000), + sqrtPrice: new BN("14082503933855903449"), + amountIn: new BN("8000000000"), + amountOut: new BN("4406099159"), + }, + ], + }, + ], + }; + const { trade, routes } = buildRouteTest(params); + const impact = RouterUtils.getPriceImpactForRoute(trade, routes).toDecimalPlaces( + maxDecimalAccuracy + ); + const expect = calculateImpact(params).toDecimalPlaces(maxDecimalAccuracy); + assert.equal(impact.toString(), expect.toString()); + }); + + // Mock an ExactOut ORCA -> USDC trade that has 2 split route and goes through a multi-hop (ORCA -> SOL -> USDC) + it("ExactOut, mix a->b, single & multi-hop, 2 splits", () => { + const params: RouteTestParam = { + amountSpecifiedIsInput: false, + totalAmountIn: new BN("64800628033"), + totalAmountOut: new BN("34000000000"), + subRouteParams: [ + { + hops: [ + { + aToB: true, + feeRate: Percentage.fromFraction(3000, 1000000), + sqrtPrice: new BN("14067691597581169278"), + amountIn: new BN("13107594181"), + amountOut: new BN("6800000000"), + }, + ], + }, + { + hops: [ + { + aToB: false, + feeRate: Percentage.fromFraction(3000, 1000000), + sqrtPrice: new BN("3366318822902200326"), + amountIn: new BN("51693033852"), + amountOut: new BN("1403541983350"), + }, + { + aToB: true, + feeRate: Percentage.fromFraction(3000, 1000000), + sqrtPrice: new BN("2572953144905521240"), + amountIn: new BN("1403541983350"), + amountOut: new BN("27200000000"), + }, + ], + }, + ], + }; + const { trade, routes } = buildRouteTest(params); + const impact = RouterUtils.getPriceImpactForRoute(trade, routes).toDecimalPlaces( + maxDecimalAccuracy + ); + const expect = calculateImpact(params).toDecimalPlaces(maxDecimalAccuracy); + assert.equal(impact.toString(), expect.toString()); + }); + + // NOTE: The precision kept in these calculation slightly differs from the U64 calculation that we get from the RouterUtil function. + function calculateImpact(params: RouteTestParam): Decimal { + const { amountSpecifiedIsInput, totalAmountIn, totalAmountOut } = params; + + const finalBaseValue = params.subRouteParams + .map((subRoute) => { + const { hops } = subRoute; + const directionalHops = amountSpecifiedIsInput ? hops : hops.slice().reverse(); + const hopResults: Decimal[] = new Array(hops.length); + directionalHops.forEach((hop, index) => { + const { aToB, feeRate, sqrtPrice, amountIn, amountOut } = hop; + const directionalSqrtPrice = aToB + ? new Decimal(sqrtPrice.toString()) + : new Decimal(PriceMath.invertSqrtPriceX64(sqrtPrice).toString()); + const directionalPrice = directionalSqrtPrice + .pow(2) + .div(U64.toString()) + .div(U64.toString()); + if (amountSpecifiedIsInput) { + const amountInDec = + index === 0 ? new Decimal(amountIn.toString()) : hopResults[index - 1]; + const amountOutDec = amountInDec + .times(new Decimal(1).sub(feeRate.toDecimal())) + .times(directionalPrice); + hopResults[index] = amountOutDec.round(); + } else { + const amountOutDec = + index === 0 ? new Decimal(amountOut.toString()) : hopResults[index - 1]; + const amountInDec = amountOutDec + .div(new Decimal(1).sub(feeRate.toDecimal())) + .div(directionalPrice); + hopResults[index] = amountInDec.round(); + } + }); + return hopResults[hops.length - 1]; + }) + .reduce((acc, cur) => acc.add(cur), new Decimal(0)); + + if (amountSpecifiedIsInput) { + const totalAmountOutDec = new Decimal(totalAmountOut.toString()); + return finalBaseValue.sub(totalAmountOutDec).div(finalBaseValue).mul(100); + } else { + const totalAmountInDec = new Decimal(totalAmountIn.toString()); + return totalAmountInDec.sub(finalBaseValue).div(totalAmountInDec).mul(100); + } + } + + type TradeHopTestParam = { + aToB: boolean; + feeRate: Percentage; + sqrtPrice: BN; + amountIn: BN; + amountOut: BN; + }; + type SubRouteTestParam = { + hops: TradeHopTestParam[]; + }; + type RouteTestParam = { + amountSpecifiedIsInput: boolean; + subRouteParams: SubRouteTestParam[]; + totalAmountIn: BN; + totalAmountOut: BN; + }; + function buildRouteTest(params: RouteTestParam) { + return { + trade: { + tokenIn: "orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE", + tokenOut: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + tradeAmount: new BN(0), + amountSpecifiedIsInput: params.amountSpecifiedIsInput, + }, + routes: { + subRoutes: params.subRouteParams.map((subRouteParam) => { + return { + hopQuotes: subRouteParam.hops.map((hopParam) => { + return { + amountIn: hopParam.amountIn, + amountOut: hopParam.amountOut, + whirlpool: PublicKey.default, + inputMint: PublicKey.default, + outputMint: PublicKey.default, + mintA: PublicKey.default, + mintB: PublicKey.default, + vaultA: PublicKey.default, + vaultB: PublicKey.default, + quote: { + amount: new BN(0), + otherAmountThreshold: new BN(0), + sqrtPriceLimit: new BN(0), + amountSpecifiedIsInput: params.amountSpecifiedIsInput, + aToB: hopParam.aToB, + tickArray0: PublicKey.default, + tickArray1: PublicKey.default, + tickArray2: PublicKey.default, + estimatedAmountIn: new BN(0), + estimatedAmountOut: new BN(0), + estimatedEndTickIndex: 0, + estimatedEndSqrtPrice: new BN(0), + estimatedFeeAmount: new BN(0), + }, + snapshot: { + aToB: hopParam.aToB, + feeRate: hopParam.feeRate, + sqrtPrice: hopParam.sqrtPrice, + }, + }; + }), + path: { + startTokenMint: "startTokenMint", + endTokenMint: "endTokenMint", + edges: [], + }, + splitPercent: 30, + amountIn: new BN(0), + amountOut: new BN(0), + }; + }), + totalAmountIn: params.totalAmountIn, + totalAmountOut: params.totalAmountOut, + }, + }; + } +});