import { Fraction } from '@uniswap/sdk-core'; import JSBI from 'jsbi'; import { SqrtPriceMath, TickMath } from '@uniswap/v3-sdk'; import { Logger } from '../../utils/logger'; export const fractionAbsoluteValue = function (fraction: Fraction): Fraction { const numeratorAbs = JSBI.lessThan(fraction.numerator, JSBI.BigInt(0)) ? JSBI.unaryMinus(fraction.numerator) : fraction.numerator; const denominatorAbs = JSBI.lessThan(fraction.denominator, JSBI.BigInt(0)) ? JSBI.unaryMinus(fraction.denominator) : fraction.denominator; return new Fraction(numeratorAbs, denominatorAbs); }; const PRECISION = JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(18)); const X128 = 2n ** 128n; export class PoolSharkCustomRouter { public quoter: any; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - logger is stored but not used yet private logger: Logger; constructor(_quoter: any, logger: Logger) { this.quoter = _quoter; this.logger = logger; } /** * * @param _pool the pool which swap() will be called upon. Must be initialized and have a reasonable amount of liquidity. * @param _desiredPositions the array of liquidity positions which will be created. * @param _inputT0Bal vault's current total t0 balance * @param _inputT1Bal vault's current t1 balance * @param t0Address the address of the t0 ERC20 * @param t1Address address of the t1 ERC20 * @param _maxIterations max # of times you want the algorithm to iterate before returning its best guess, if an acceptably small ratio error is never reached * @param ratioErrorTolerance max acceptable difference between * @returns */ public async getSwapAmount( _pool: any, _desiredPositions: Position[], _inputT0Bal: bigint, _inputT1Bal: bigint, t0Address: string, t1Address: string, _maxIterations: number, ratioErrorTolerance?: Fraction, //Possibly add maxSlippage // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { if (ratioErrorTolerance === undefined || ratioErrorTolerance === null) { ratioErrorTolerance = new Fraction(1, 1000); } //Check input parameters await _checkParams( _pool, _desiredPositions, _inputT0Bal, _inputT1Bal, t0Address, t1Address, _maxIterations, ); //Get init parameters const slot0 = await _pool.globalState(); const sqrtPriceX96 = BigInt(slot0.pool.price.toString()); //Calc total weights // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - totalWeight is calculated but not used yet let totalWeight = 0; for (let i = 0; i < _desiredPositions.length; i++) { totalWeight += _desiredPositions[i].weight; } // For the first loop, we know the eventual ideal optimalRatio will be between sqrtPriceX96 and post-swap sqrtPriceX96. We originally test using sqrtPriceX96, so setting // lower to sqrtPriceX96 just allows the loop to function mostly as normal for the first iteration. Later on in the loop, we'll go back and reset these to their proper values. // lowerSqrtPriceX96, upperSqrtPriceX96, and zeroForOne are the only values that persist from one loop to another. let lowerSqrtPriceX96: bigint = sqrtPriceX96; let upperSqrtPriceX96: bigint = sqrtPriceX96; let zeroForOne = true; //Not actually necessarily true at this point, but it's set in the first loop iteration so this won't do anything //If ratio is already correct, return 0 as swapAmount. const initRatio = new Fraction( _inputT0Bal.toString(), _inputT1Bal.toString(), ); const initSwapOptimalT0PerT1 = calculateOptimalT0PerT1( _desiredPositions, JSBI.BigInt(sqrtPriceX96.toString()), ); const alreadyBalanced = checkRatiosMatch( initRatio, initSwapOptimalT0PerT1, ratioErrorTolerance, ); if (alreadyBalanced) { console.log(`PoolSharkCustomRouter.getSwapAmount exited at iteration: 0`); return { amountToSwap: 0n, zeroForOne: false, }; } //For any subsequent loop, we pass in lower and upper and test the halfway point between them. for (let i = 0; i < _maxIterations; i++) { if (lowerSqrtPriceX96 > upperSqrtPriceX96) { throw new Error('CRASH'); } //Get halvedSqrtPriceX96 const middleSqrtPriceX96 = (lowerSqrtPriceX96 + upperSqrtPriceX96) / 2n; //Get optimal ratio const preSwapOptimalT0PerT1 = calculateOptimalT0PerT1( _desiredPositions, JSBI.BigInt(middleSqrtPriceX96.toString()), ); //Get this swap's zero for one. If it's different from persisting zeroForOne, we already know we've swapped too far, //so we can continue into the next iteration. const thisSwapZeroForOne = new Fraction( _inputT0Bal.toString(), _inputT1Bal.toString(), ).greaterThan(preSwapOptimalT0PerT1); if (i == 0) { //For the first iteration zeroForOne = thisSwapZeroForOne; } else { if (zeroForOne != thisSwapZeroForOne) { // If this is the last iteration, just return 0. If the 2nd to last iteration still swapped too much, it's a pretty safe bet that the swap amount // is very near 0 and can be ignored. if (i == _maxIterations - 1) { return { amountToSwap: 0n, zeroForOne: zeroForOne, }; } // If zero for one at this point, then algo thinks we want one for zero, which means too much was swapped. Too much being swapped from t0 to t1 means // the algorithm wanted too much t1, which means middleSqrtPrice was too low. // Reverse this if one for zero. if (zeroForOne) { lowerSqrtPriceX96 = middleSqrtPriceX96; } else { upperSqrtPriceX96 = middleSqrtPriceX96; } continue; } } //Get swap params to swap into halvedTick ratio const inputSwapAmount = calculateAmountToSwap( preSwapOptimalT0PerT1, sqrtPriceX96, zeroForOne, _inputT0Bal, _inputT1Bal, ); let limit: bigint; if (zeroForOne) { limit = 0n; } else { limit = BigInt('1461501637330902918203684832716283019655932542975'); } const quoteParams = { priceLimit: limit, amount: inputSwapAmount, exactIn: true, zeroForOne: zeroForOne, }; // Swap into optimal ratio const quoteResult = await this.quoter.quote(quoteParams); //New sqrt price (after that swap) represents a tick limit (either lowerTick or upperTick) const newSqrtPriceX96 = BigInt(quoteResult[2].toString()); if (i == 0) { //For the first loop, tick boundaries are initial sqrtPrice and post-swap sqrtPrice. // Whichever is not set between lower and upper retains its previous value equal to sqrtPriceX96. if (zeroForOne) { lowerSqrtPriceX96 = newSqrtPriceX96; } else { upperSqrtPriceX96 = newSqrtPriceX96; } // On to the next loop using these sqrtprice bounds. continue; } //Now get new post-swap ratio info etc. This will determine whether the middle-tick swap went too far or not far enough. //Get post-swap token amounts let postSwapT0Amount: bigint; let postSwapT1Amount: bigint; if (zeroForOne) { postSwapT0Amount = _inputT0Bal - inputSwapAmount; postSwapT1Amount = _inputT1Bal + BigInt(quoteResult[1].toString()); } else { postSwapT0Amount = _inputT0Bal + BigInt(quoteResult[1].toString()); postSwapT1Amount = _inputT1Bal - inputSwapAmount; } //Get post-swap optimal ratio at new sqrtPrice const postSwapOptimalT0PerT1 = calculateOptimalT0PerT1( _desiredPositions, JSBI.BigInt(newSqrtPriceX96.toString()), ); const postSwapHeldT0PerT1: Fraction = new Fraction( postSwapT0Amount.toString(), postSwapT1Amount.toString(), ); /* If post-swap optimal ratio of t0/t1 is greater than post-swap owned ratio of t0/t1 (and so post-swap the next trade would be one for zero), the tick is too low, so next iteration should be between halvedSqrtPrice and upper. Otherwise, the opposite. */ const postSwapZeroForOne = postSwapHeldT0PerT1.greaterThan( postSwapOptimalT0PerT1, ); if (postSwapZeroForOne) { lowerSqrtPriceX96 = middleSqrtPriceX96; } else { upperSqrtPriceX96 = middleSqrtPriceX96; } //Before continuing to next loop, check if operation is complete (post-swap unutilized liquidity is acceptably small) const ratioAchieved = checkRatiosMatch( postSwapHeldT0PerT1, postSwapOptimalT0PerT1, ratioErrorTolerance, ); if (ratioAchieved || i >= _maxIterations - 1) { //maxIterations - 1 because iteration 0 is an iteration, so when i = 19 there have been 20 iterations. console.log( `PoolSharkCustomRouter.getSwapAmount exited at iteration: ${i + 1}`, ); return { amountToSwap: inputSwapAmount, zeroForOne: zeroForOne, }; } } // Default return if loop completes without returning return undefined; } } // Used only for debugging // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - toString is for debugging purposes function toString(fraction: Fraction) { return ( fraction.numerator.toString() + ' / ' + fraction.denominator.toString() ); } function checkRatiosMatch( heldTokens: Fraction, desiredTokens: Fraction, errorTolerance: Fraction, ) { return ( heldTokens.equalTo(desiredTokens) || fractionAbsoluteValue( desiredTokens.divide(heldTokens).subtract(1), ).lessThan(errorTolerance) ); } // Throws an error if input parameters are invalid. async function _checkParams( _pool: any, _desiredPositions: Position[], _inputT0Bal: bigint, _inputT1Bal: bigint, t0Address: string, t1Address: string, _maxIterations: number, ) { //Get all necessary info from pool const poolT0Address = await _pool.token0(); const poolT1Address = await _pool.token1(); const tickSpacing = await _pool.tickSpacing(); const poolSlot0 = await _pool.globalState(); //Check unlocked to see if pool has been initialized yet //Check (through uniswap factory) that pool is the correct one for this token pair + initialized //Note: verify that pool is correct address, because this check is not meant to be a security check and will not work as such. if (poolSlot0.unlocked != true) { throw new Error('Pool is not initialized'); } if (poolT0Address.toLowerCase() != t0Address.toLowerCase()) { throw new Error('Incorrect token 0 address'); } if (poolT1Address.toLowerCase() != t1Address.toLowerCase()) { throw new Error('Incorrect token 1 address'); } //Check that maxIterations > 0 if (_maxIterations <= 0) { throw new Error('maxIterations must be greater than 0'); } //Check desired positions: for (let i = 0; i < _desiredPositions.length; i++) { const position = _desiredPositions[i]; const lowerTick = position.lowerTick; const upperTick = position.upperTick; //Make sure neither lower tick nor upper tick are out of bounds if ( lowerTick < -887272 || lowerTick > 887272 || upperTick < -887272 || upperTick > 887272 ) { throw new Error('Tick is out of bounds'); } //Make sure there is space between lower and upper ticks, and lower tick is less than upper tick if (upperTick - lowerTick <= 0) { throw new Error('lowerTick must be less than upperTick'); } //Make sure lower and upper ticks are both initializable if (lowerTick % tickSpacing != 0 || upperTick % tickSpacing != 0) { throw new Error( 'Desired position lower and upper ticks must be multiples of tick spacing', ); } if (position.weight <= 0) { throw new Error('Desired position weight must be greater than 0'); } } } function calculateOptimalT0PerT1( _desiredPositions: Position[], sqrtRatioX96: JSBI, ): Fraction { let totalT0Needed = JSBI.BigInt(0); let totalT1Needed = JSBI.BigInt(0); for (let i = 0; i < _desiredPositions.length; i++) { const position = _desiredPositions[i]; const upperSqrtRatioX96 = TickMath.getSqrtRatioAtTick(position.upperTick); const lowerSqrtRatioX96 = TickMath.getSqrtRatioAtTick(position.lowerTick); let workingSqrtRatioX96 = sqrtRatioX96; //Represents current sqrt ratio x96, unless current liquidity position does not contain current tick, in which case it will //represent either upper or lower bound depending on needs. Simplifies following math. if (JSBI.greaterThan(sqrtRatioX96, upperSqrtRatioX96)) { workingSqrtRatioX96 = upperSqrtRatioX96; } else if (JSBI.lessThan(sqrtRatioX96, lowerSqrtRatioX96)) { workingSqrtRatioX96 = lowerSqrtRatioX96; } const t0Needed = SqrtPriceMath.getAmount0Delta( workingSqrtRatioX96, upperSqrtRatioX96, JSBI.multiply(PRECISION, JSBI.BigInt(position.weight)), true, ); const t1Needed = SqrtPriceMath.getAmount1Delta( lowerSqrtRatioX96, workingSqrtRatioX96, JSBI.multiply(PRECISION, JSBI.BigInt(position.weight)), true, ); totalT0Needed = JSBI.add(totalT0Needed, t0Needed); totalT1Needed = JSBI.add(totalT1Needed, t1Needed); } return new Fraction(totalT0Needed.toString(), totalT1Needed.toString()); } //Returns amount to swap. If zeroForOne this will be in terms of t0, otherwise it will be in terms of t1 export function calculateAmountToSwap( desiredT0PerT1: Fraction, sqrtPriceX96: bigint, zeroForOne: boolean, t0Amount: bigint, t1Amount: bigint, ): bigint { // Normalize to native BigInt — callers may pass ethers.js BigNumber or other // non-primitive types that cause "Cannot mix BigInt and other types" at runtime. const t0 = BigInt(t0Amount.toString()); const t1 = BigInt(t1Amount.toString()); const sqrtPrice = BigInt(sqrtPriceX96.toString()); //Get token1PerToken0X128 const token1PerToken0X128 = (sqrtPrice * sqrtPrice) / 2n ** 64n; if (desiredT0PerT1.equalTo(0)) { if (!zeroForOne) { throw new Error('calculateAmountToSwap called with wrong parameters'); } return t0; } if (desiredT0PerT1.denominator.toString() == '0') { if (zeroForOne) { throw new Error('calculateAmountToSwap called with wrong parameters'); } return t1; } const desiredT0PerT1X128 = (BigInt(desiredT0PerT1.numerator.toString()) * X128) / BigInt(desiredT0PerT1.denominator.toString()); //First, use desiredT0PerT1 and token1PerToken0X128 to get desired VALUES of each token in terms of t1 //Desired t0 value per t1 represents how much t0 will be worth in terms of t1 compared to t1 value. If we want //t0 to be worth double as much as t1, desiredt0ValuePerT1 will be 2. const desiredT0ValuePerT1X128 = (desiredT0PerT1X128 * token1PerToken0X128) / X128; const desiredT0ValueFractionX128 = (desiredT0ValuePerT1X128 * X128) / (desiredT0ValuePerT1X128 + X128); //Next find total value in terms of t1 const totalT0ValueInTermsOfT1X128 = t0 * token1PerToken0X128; const t1ValX128 = t1 * X128; const totalValInTermsOfT1X128 = totalT0ValueInTermsOfT1X128 + t1ValX128; //Use desiredValues to find how much of total value should be in terms of t0 vs t1 const newT0ValInTermsOfT1X128 = (desiredT0ValueFractionX128 * totalValInTermsOfT1X128) / X128; const newT1ValX128 = totalValInTermsOfT1X128 - newT0ValInTermsOfT1X128; //Convert back to each token being represented in terms of itself rather than in terms of its value X128 const newT0ValX128 = (newT0ValInTermsOfT1X128 * X128) / token1PerToken0X128; let newT0Val = newT0ValX128 / X128; let newT1Val = newT1ValX128 / X128; //Handle decimals--BigInt division will ALWAYS round down, so we want it to round up instead if that would be more accurate if (newT0Val * X128 * 2n <= newT0ValX128 * 2n - X128) { //t0 was rounded down when it should have been rounded up newT0Val = newT0Val + 1n; } if (newT1Val * X128 * 2n <= newT1ValX128 * 2n - X128) { //t1 was rounded down when it should have been rounded up newT1Val = newT1Val + 1n; } //Return whichever token is being swapped into the other if (zeroForOne) { return t0 - newT0Val; } else { return t1 - newT1Val; } } //Weight represents ABSOLUTE weight. If one position is double as wide as another, and both have equal weight, then both will have //equal liquidity, i.e. the wider one will have less concentrated liquidity but the same absolute amount. export type Position = { lowerTick: number; upperTick: number; weight: number; }; export type SwapResult = { amountToSwap: bigint; zeroForOne: boolean; };