From db85bdd21679f1119360829778dcf0ca3c8eccd7 Mon Sep 17 00:00:00 2001 From: Sara Reynolds <30504811+snreynolds@users.noreply.github.com> Date: Mon, 23 Sep 2024 20:32:40 +0100 Subject: [PATCH] feat(v4-sdk): Create and Add Implementation (#98) Co-authored-by: Eric Zhong Co-authored-by: Siyu Jiang (See-You John) <91580504+jsy1218@users.noreply.github.com> --- sdks/v4-sdk/src/PositionManager.ts | 213 +++---- sdks/v4-sdk/src/actionConstants.ts | 2 + sdks/v4-sdk/src/entities/index.ts | 1 + sdks/v4-sdk/src/entities/pool.ts | 2 + sdks/v4-sdk/src/entities/position.ts | 269 ++++++++- sdks/v4-sdk/src/internalConstants.ts | 19 + sdks/v4-sdk/src/multicall.ts | 10 +- sdks/v4-sdk/src/posm.test.ts | 268 +++++++++ sdks/v4-sdk/src/utils/abi.ts | 524 ++++++++++++++++++ sdks/v4-sdk/src/utils/currencyMap.ts | 10 + sdks/v4-sdk/src/utils/index.ts | 1 + sdks/v4-sdk/src/utils/priceTickConversions.ts | 54 ++ sdks/v4-sdk/src/utils/v4Planner.ts | 8 +- sdks/v4-sdk/src/utils/v4PositionPlanner.ts | 81 +++ 14 files changed, 1295 insertions(+), 167 deletions(-) create mode 100644 sdks/v4-sdk/src/actionConstants.ts create mode 100644 sdks/v4-sdk/src/posm.test.ts create mode 100644 sdks/v4-sdk/src/utils/abi.ts create mode 100644 sdks/v4-sdk/src/utils/currencyMap.ts create mode 100644 sdks/v4-sdk/src/utils/priceTickConversions.ts create mode 100644 sdks/v4-sdk/src/utils/v4PositionPlanner.ts diff --git a/sdks/v4-sdk/src/PositionManager.ts b/sdks/v4-sdk/src/PositionManager.ts index 53c5e4269..54340fb34 100644 --- a/sdks/v4-sdk/src/PositionManager.ts +++ b/sdks/v4-sdk/src/PositionManager.ts @@ -1,5 +1,3 @@ -// @ts-nocheck TODO: remove once implemented - import { BigintIsh, Percent, @@ -8,11 +6,17 @@ import { Currency, NativeCurrency, } from '@uniswap/sdk-core' +import JSBI from 'jsbi' import { Position } from './entities/position' import { MethodParameters, toHex } from './utils/calldata' +import { MSG_SENDER } from './actionConstants' import { Interface } from '@ethersproject/abi' -import { Pool, PoolKey } from './entities' +import { PoolKey } from './entities' import { Multicall } from './multicall' +import invariant from 'tiny-invariant' +import { NO_NATIVE, PositionFunctions, ZERO } from './internalConstants' +import { V4PositionPlanner } from './utils' +import { abi } from './utils/abi' export interface CommonOptions { /** @@ -23,6 +27,11 @@ export interface CommonOptions { * Optional data to pass to hooks */ hookData?: string + + /** + * When the transaction expires, in epoch seconds. + */ + deadline: BigintIsh } export interface ModifyPositionSpecificOptions { @@ -30,11 +39,6 @@ export interface ModifyPositionSpecificOptions { * Indicates the ID of the position to increase liquidity for. */ tokenId: BigintIsh - - /** - * When the transaction expires, in epoch seconds. - */ - deadline: BigintIsh } export interface MintSpecificOptions { @@ -59,7 +63,7 @@ export interface MintSpecificOptions { */ export interface CommonAddLiquidityOptions { /** - * Whether to spend ether. If true, one of the pool tokens must be WETH, by default false + * Whether to spend ether. If true, one of the currencies must be the NATIVE currency. */ useNative?: NativeCurrency @@ -155,13 +159,20 @@ export type RemoveLiquidityOptions = CommonOptions & RemoveLiquiditySpecificOpti export type CollectOptions = CommonOptions & CollectSpecificOptions // type guard -// @ts-ignore function isMint(options: AddLiquidityOptions): options is MintOptions { return Object.keys(options).some((k) => k === 'recipient') } +function shouldCreatePool(options: MintOptions): boolean { + if (options.createPool) { + invariant(options.sqrtPriceX96 !== undefined, 'NO_SQRT_PRICE') + return true + } + return false +} + export abstract class V4PositionManager { - public static INTERFACE: Interface = new Interface([]) + public static INTERFACE: Interface = new Interface(abi) /** * Cannot be constructed. @@ -178,150 +189,84 @@ export abstract class V4PositionManager { } } - public static addCallParameters(_position: Position, _options: AddLiquidityOptions): MethodParameters { + public static addCallParameters(position: Position, options: AddLiquidityOptions): MethodParameters { /** * Cases: * - if pool does not exist yet, encode initializePool * then, - * - if is mint, encode MINT_POSITION and then SETTLE_PAIR - * - else, encode INCREASE_LIQUIDITY and then SETTLE_PAIR + * - if is mint, encode MINT_POSITION. If it is on a NATIVE pool, encode a SWEEP. Finally encode a SETTLE_PAIR + * - else, encode INCREASE_LIQUIDITY. If it is on a NATIVE pool, encode a SWEEP. Finally encode a SETTLE_PAIR */ - const calldatas = ['0x', '0x'] - let value = toHex(0) + invariant(JSBI.greaterThan(position.liquidity, ZERO), 'ZERO_LIQUIDITY') - return { - calldata: Multicall.encodeMulticall(calldatas), - value, - } - } + const calldataList: string[] = [] + const planner = new V4PositionPlanner() - /** - * Produces the calldata for completely or partially exiting a position - * @param position The position to exit - * @param options Additional information necessary for generating the calldata - * @returns The call parameters - */ - public static removeCallParameters(_position: Position, _options: RemoveLiquidityOptions): MethodParameters { - /** - * cases: - * - if liquidityPercentage is 100%, encode BURN_POSITION and then TAKE_PAIR - * - else, encode DECREASE_LIQUIDITY and then TAKE_PAIR - */ - const calldatas = ['0x', '0x'] + const isMintAction = isMint(options) - return { - calldata: Multicall.encodeMulticall(calldatas), - value: toHex(0), + // Encode initialize pool. + if (isMintAction && shouldCreatePool(options)) { + // No planner used here because initializePool is not supported as an Action + calldataList.push( + V4PositionManager.encodeInitializePool(position.pool.poolKey, options.sqrtPriceX96!, options.hookData) + ) } - } - public static collectCallParameters(_options: CollectOptions): MethodParameters { - /** - * Collecting in V4 is just a DECREASE_LIQUIDITY with 0 liquidity and then a TAKE_PAIR - */ - const calldatas = ['0x', '0x'] + // adjust for slippage + const maximumAmounts = position.mintAmountsWithSlippage(options.slippageTolerance) + const amount0Max = toHex(maximumAmounts.amount0) + const amount1Max = toHex(maximumAmounts.amount1) + + // mint + if (isMintAction) { + const recipient: string = validateAndParseAddress(options.recipient) + planner.addMint( + position.pool, + position.tickLower, + position.tickUpper, + position.liquidity, + amount0Max, + amount1Max, + recipient, + options.hookData + ) + } else { + // increase + planner.addIncrease(options.tokenId, position.liquidity, amount0Max, amount1Max, options.hookData) + } - return { - calldata: Multicall.encodeMulticall(calldatas), - value: toHex(0), + // need to settle both currencies when minting / adding liquidity + planner.addSettlePair(position.pool.currency0, position.pool.currency1) + + // Any sweeping must happen after the settling. + let value: string = toHex(0) + if (options.useNative) { + invariant(position.pool.currency0.isNative || position.pool.currency1.isNative, NO_NATIVE) + let nativeCurrency: Currency = position.pool.currency0.isNative + ? position.pool.currency0 + : position.pool.currency1 + value = position.pool.currency0.isNative ? toHex(amount0Max) : toHex(amount1Max) + planner.addSweep(nativeCurrency, MSG_SENDER) } - } - public static transferFromParameters(options: TransferOptions): MethodParameters { - const recipient = validateAndParseAddress(options.recipient) - const sender = validateAndParseAddress(options.sender) + calldataList.push(V4PositionManager.encodeModifyLiquidities(planner.finalize(), options.deadline)) - let calldata: string - calldata = V4PositionManager.INTERFACE.encodeFunctionData('transferFrom(address,address,uint256)', [ - sender, - recipient, - toHex(options.tokenId), - ]) return { - calldata: calldata, - value: toHex(0), + calldata: Multicall.encodeMulticall(calldataList), + value, } } - /** - * ---- Private functions to encode calldata for different actions on the PositionManager contract ----- - */ - // Initialize a pool private static encodeInitializePool(poolKey: PoolKey, sqrtPriceX96: BigintIsh, hookData?: string): string { - throw new Error('not implemented') - } - - // @ts-ignore - private static encodeMint( - pool: Pool, - tickLower: number, - tickUpper: number, - liquidity: BigintIsh, - amount0Max: BigintIsh, - amount1Max: BigintIsh, - owner: string, - hookData?: string - ): string { - throw new Error('not implemented') - } - - // @ts-ignore - private static encodeIncrease( - tokenId: BigintIsh, - liquidity: BigintIsh, - amount0Max: BigintIsh, - amount1Max: BigintIsh, - hookData?: string - ): string { - throw new Error('not implemented') - } - - // @ts-ignore - private static encodeDecrease( - tokenId: BigintIsh, - liquidity: BigintIsh, - amount0Min: BigintIsh, - amount1Min: BigintIsh, - hookData?: string - ): string { - throw new Error('not implemented') - } - - // @ts-ignore - private static encodeBurn( - tokenId: BigintIsh, - amount0Min: BigintIsh, - amount1Min: BigintIsh, - hookData?: string - ): string { - throw new Error('not implemented') - } - - // @ts-ignore - private static encodeTake(currency: Currency, recipient: string, amount: BigintIsh): string { - throw new Error('not implemented') - } - - // @ts-ignore - private static encodeSettle(currency: Currency, amount: BigintIsh, payerIsUser: boolean): string { - throw new Error('not implemented') - } - - // @ts-ignore - private static encodeSettlePair(currency0: Currency, currency1: Currency): string { - throw new Error('not implemented') - } - - // @ts-ignore - private static encodeTakePair(currency0: Currency, currency1: Currency, recipient: string): string { - throw new Error('not implemented') + return V4PositionManager.INTERFACE.encodeFunctionData(PositionFunctions.INITIALIZE_POOL, [ + poolKey, + sqrtPriceX96.toString(), + hookData ?? '0x', + ]) } - // @ts-ignore - private static encodeCollect(options: CollectOptions): string[] { - const calldatas: string[] = [] - - return calldatas + public static encodeModifyLiquidities(unlockData: string, deadline: BigintIsh): string { + return V4PositionManager.INTERFACE.encodeFunctionData(PositionFunctions.MODIFY_LIQUIDITIES, [unlockData, deadline]) } } diff --git a/sdks/v4-sdk/src/actionConstants.ts b/sdks/v4-sdk/src/actionConstants.ts new file mode 100644 index 000000000..bfd8646f1 --- /dev/null +++ b/sdks/v4-sdk/src/actionConstants.ts @@ -0,0 +1,2 @@ +// Shared Action Constants used in the v4 Router and v4 position manager +export const MSG_SENDER = '0x0000000000000000000000000000000000000001' diff --git a/sdks/v4-sdk/src/entities/index.ts b/sdks/v4-sdk/src/entities/index.ts index c317a9c69..39cb17451 100644 --- a/sdks/v4-sdk/src/entities/index.ts +++ b/sdks/v4-sdk/src/entities/index.ts @@ -1,3 +1,4 @@ export * from './pool' export * from './route' export * from './trade' +export * from './position' diff --git a/sdks/v4-sdk/src/entities/pool.ts b/sdks/v4-sdk/src/entities/pool.ts index 43d996c10..b705f8c6e 100644 --- a/sdks/v4-sdk/src/entities/pool.ts +++ b/sdks/v4-sdk/src/entities/pool.ts @@ -93,6 +93,8 @@ export class Pool { * @param currencyA One of the currencys in the pool * @param currencyB The other currency in the pool * @param fee The fee in hundredths of a bips of the input amount of every swap that is collected by the pool + * @param tickSpacing The tickSpacing of the pool + * @param hooks The address of the hook contract * @param sqrtRatioX96 The sqrt of the current ratio of amounts of currency1 to currency0 * @param liquidity The current value of in range liquidity * @param tickCurrent The current tick of the pool diff --git a/sdks/v4-sdk/src/entities/position.ts b/sdks/v4-sdk/src/entities/position.ts index 76d73baff..fdca6490f 100644 --- a/sdks/v4-sdk/src/entities/position.ts +++ b/sdks/v4-sdk/src/entities/position.ts @@ -1,10 +1,10 @@ -// @ts-nocheck - -import { BigintIsh, Percent, Price, CurrencyAmount, Currency } from '@uniswap/sdk-core' +import { BigintIsh, Percent, Price, CurrencyAmount, Currency, MaxUint256 } from '@uniswap/sdk-core' import JSBI from 'jsbi' import invariant from 'tiny-invariant' import { Pool } from './pool' -import { TickMath } from '@uniswap/v3-sdk' +import { encodeSqrtRatioX96, maxLiquidityForAmounts, SqrtPriceMath, TickMath } from '@uniswap/v3-sdk' +import { ZERO } from '../internalConstants' +import { tickToPrice } from '../utils/priceTickConversions' interface PositionConstructorArgs { pool: Pool @@ -52,29 +52,78 @@ export class Position { * Returns the price of token0 at the lower tick */ public get token0PriceLower(): Price { - // TODO: Currency or wrapped token here? - throw new Error('Not implemented') + return tickToPrice(this.pool.currency0, this.pool.currency1, this.tickLower) } /** * Returns the price of token0 at the upper tick */ public get token0PriceUpper(): Price { - throw new Error('Not implemented') + return tickToPrice(this.pool.currency0, this.pool.currency1, this.tickUpper) } /** * Returns the amount of token0 that this position's liquidity could be burned for at the current pool price */ public get amount0(): CurrencyAmount { - throw new Error('Not implemented') + if (!this._token0Amount) { + if (this.pool.tickCurrent < this.tickLower) { + this._token0Amount = CurrencyAmount.fromRawAmount( + this.pool.currency0, + SqrtPriceMath.getAmount0Delta( + TickMath.getSqrtRatioAtTick(this.tickLower), + TickMath.getSqrtRatioAtTick(this.tickUpper), + this.liquidity, + false + ) + ) + } else if (this.pool.tickCurrent < this.tickUpper) { + this._token0Amount = CurrencyAmount.fromRawAmount( + this.pool.currency0, + SqrtPriceMath.getAmount0Delta( + this.pool.sqrtRatioX96, + TickMath.getSqrtRatioAtTick(this.tickUpper), + this.liquidity, + false + ) + ) + } else { + this._token0Amount = CurrencyAmount.fromRawAmount(this.pool.currency0, ZERO) + } + } + return this._token0Amount } /** * Returns the amount of token1 that this position's liquidity could be burned for at the current pool price */ public get amount1(): CurrencyAmount { - throw new Error('Not implemented') + if (!this._token1Amount) { + if (this.pool.tickCurrent < this.tickLower) { + this._token1Amount = CurrencyAmount.fromRawAmount(this.pool.currency1, ZERO) + } else if (this.pool.tickCurrent < this.tickUpper) { + this._token1Amount = CurrencyAmount.fromRawAmount( + this.pool.currency1, + SqrtPriceMath.getAmount1Delta( + TickMath.getSqrtRatioAtTick(this.tickLower), + this.pool.sqrtRatioX96, + this.liquidity, + false + ) + ) + } else { + this._token1Amount = CurrencyAmount.fromRawAmount( + this.pool.currency1, + SqrtPriceMath.getAmount1Delta( + TickMath.getSqrtRatioAtTick(this.tickLower), + TickMath.getSqrtRatioAtTick(this.tickUpper), + this.liquidity, + false + ) + ) + } + } + return this._token1Amount } /** @@ -82,29 +131,183 @@ export class Position { * @param slippageTolerance The amount by which the price can 'slip' before the transaction will revert * @returns The sqrt ratios after slippage */ - // @ts-ignore - private ratiosAfterSlippage(_slippageTolerance: Percent): { sqrtRatioX96Lower: JSBI; sqrtRatioX96Upper: JSBI } { - throw new Error('Not implemented') + private ratiosAfterSlippage(slippageTolerance: Percent): { sqrtRatioX96Lower: JSBI; sqrtRatioX96Upper: JSBI } { + const priceLower = this.pool.token0Price.asFraction.multiply(new Percent(1).subtract(slippageTolerance)) + const priceUpper = this.pool.token0Price.asFraction.multiply(slippageTolerance.add(1)) + let sqrtRatioX96Lower = encodeSqrtRatioX96(priceLower.numerator, priceLower.denominator) + if (JSBI.lessThanOrEqual(sqrtRatioX96Lower, TickMath.MIN_SQRT_RATIO)) { + sqrtRatioX96Lower = JSBI.add(TickMath.MIN_SQRT_RATIO, JSBI.BigInt(1)) + } + let sqrtRatioX96Upper = encodeSqrtRatioX96(priceUpper.numerator, priceUpper.denominator) + if (JSBI.greaterThanOrEqual(sqrtRatioX96Upper, TickMath.MAX_SQRT_RATIO)) { + sqrtRatioX96Upper = JSBI.subtract(TickMath.MAX_SQRT_RATIO, JSBI.BigInt(1)) + } + return { + sqrtRatioX96Lower, + sqrtRatioX96Upper, + } } /** - * Returns the minimum amounts that must be sent in order to safely mint the amount of liquidity held by the position + * Returns the maximum amount of token0 and token1 that must be sent in order to safely mint the amount of liquidity held by the position * with the given slippage tolerance * @param slippageTolerance Tolerance of unfavorable slippage from the current price * @returns The amounts, with slippage + * @dev In v4, minting and increasing is protected by maximum amounts of token0 and token1. */ - // @ts-ignore public mintAmountsWithSlippage(slippageTolerance: Percent): Readonly<{ amount0: JSBI; amount1: JSBI }> { - throw new Error('Not implemented') + // get lower/upper prices + // these represent the lowest and highest prices that the pool is allowed to "slip" to + const { sqrtRatioX96Upper, sqrtRatioX96Lower } = this.ratiosAfterSlippage(slippageTolerance) + + // construct counterfactual pools from the lower bounded price and the upper bounded price + const poolLower = new Pool( + this.pool.token0, + this.pool.token1, + this.pool.fee, + this.pool.tickSpacing, + this.pool.hooks, + sqrtRatioX96Lower, + 0 /* liquidity doesn't matter */, + TickMath.getTickAtSqrtRatio(sqrtRatioX96Lower) + ) + const poolUpper = new Pool( + this.pool.token0, + this.pool.token1, + this.pool.fee, + this.pool.tickSpacing, + this.pool.hooks, + sqrtRatioX96Upper, + 0 /* liquidity doesn't matter */, + TickMath.getTickAtSqrtRatio(sqrtRatioX96Upper) + ) + + // because the router is imprecise, we need to calculate the position (assuming no slippage) to get the estimated actual liquidity + const positionWithoutSlippage = Position.fromAmounts({ + pool: this.pool, + tickLower: this.tickLower, + tickUpper: this.tickUpper, + ...this.mintAmounts, // the mint amounts are what will be passed as calldata + useFullPrecision: false, + }) + + // Note: Slippage derivation in v4 is different from v3. + // When creating a position (minting) or adding to a position (increasing) slippage is bounded by the MAXIMUM amount in in token0 and token1. + // The largest amount of token1 will happen when the price slips up, so we use the poolUpper to get amount1. + // The largest amount of token0 will happen when the price slips down, so we use the poolLower to get amount0. + // Ie...We want the larger amounts, which occurs at the upper price for amount1... + const { amount1 } = new Position({ + pool: poolUpper, + liquidity: positionWithoutSlippage.liquidity, + tickLower: this.tickLower, + tickUpper: this.tickUpper, + }).mintAmounts + // ...and the lower for amount0 + const { amount0 } = new Position({ + pool: poolLower, + liquidity: positionWithoutSlippage.liquidity, + tickLower: this.tickLower, + tickUpper: this.tickUpper, + }).mintAmounts + + return { amount0, amount1 } + } + + /** + * Returns the minimum amounts that should be requested in order to safely burn the amount of liquidity held by the + * position with the given slippage tolerance + * @param slippageTolerance tolerance of unfavorable slippage from the current price + * @returns The amounts, with slippage + */ + public burnAmountsWithSlippage(slippageTolerance: Percent): Readonly<{ amount0: JSBI; amount1: JSBI }> { + // get lower/upper prices + const { sqrtRatioX96Upper, sqrtRatioX96Lower } = this.ratiosAfterSlippage(slippageTolerance) + + // construct counterfactual pools + const poolLower = new Pool( + this.pool.token0, + this.pool.token1, + this.pool.fee, + this.pool.tickSpacing, + this.pool.hooks, + sqrtRatioX96Lower, + 0 /* liquidity doesn't matter */, + TickMath.getTickAtSqrtRatio(sqrtRatioX96Lower) + ) + const poolUpper = new Pool( + this.pool.token0, + this.pool.token1, + this.pool.fee, + this.pool.tickSpacing, + this.pool.hooks, + sqrtRatioX96Upper, + 0 /* liquidity doesn't matter */, + TickMath.getTickAtSqrtRatio(sqrtRatioX96Upper) + ) + + // we want the smaller amounts... + // ...which occurs at the upper price for amount0... + const amount0 = new Position({ + pool: poolUpper, + liquidity: this.liquidity, + tickLower: this.tickLower, + tickUpper: this.tickUpper, + }).amount0 + // ...and the lower for amount1 + const amount1 = new Position({ + pool: poolLower, + liquidity: this.liquidity, + tickLower: this.tickLower, + tickUpper: this.tickUpper, + }).amount1 + + return { amount0: amount0.quotient, amount1: amount1.quotient } } /** * Returns the minimum amounts that must be sent in order to mint the amount of liquidity held by the position at * the current price for the pool */ - // @ts-ignore public get mintAmounts(): Readonly<{ amount0: JSBI; amount1: JSBI }> { - throw new Error('Not implemented') + if (this._mintAmounts === null) { + if (this.pool.tickCurrent < this.tickLower) { + return { + amount0: SqrtPriceMath.getAmount0Delta( + TickMath.getSqrtRatioAtTick(this.tickLower), + TickMath.getSqrtRatioAtTick(this.tickUpper), + this.liquidity, + true + ), + amount1: ZERO, + } + } else if (this.pool.tickCurrent < this.tickUpper) { + return { + amount0: SqrtPriceMath.getAmount0Delta( + this.pool.sqrtRatioX96, + TickMath.getSqrtRatioAtTick(this.tickUpper), + this.liquidity, + true + ), + amount1: SqrtPriceMath.getAmount1Delta( + TickMath.getSqrtRatioAtTick(this.tickLower), + this.pool.sqrtRatioX96, + this.liquidity, + true + ), + } + } else { + return { + amount0: ZERO, + amount1: SqrtPriceMath.getAmount1Delta( + TickMath.getSqrtRatioAtTick(this.tickLower), + TickMath.getSqrtRatioAtTick(this.tickUpper), + this.liquidity, + true + ), + } + } + } + return this._mintAmounts } /** @@ -113,13 +316,12 @@ export class Position { * @param pool The pool for which the position should be created * @param tickLower The lower tick of the position * @param tickUpper The upper tick of the position - * @param amount0 token0 amount + * @param amount0 token0 amountzw * @param amount1 token1 amount * @param useFullPrecision If false, liquidity will be maximized according to what the router can calculate, * not what core can theoretically support * @returns The amount of liquidity for the position */ - // @ts-ignore public static fromAmounts({ pool, tickLower, @@ -134,8 +336,22 @@ export class Position { amount0: BigintIsh amount1: BigintIsh useFullPrecision: boolean - }): Position { - throw new Error('not implemented') + }) { + const sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(tickLower) + const sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(tickUpper) + return new Position({ + pool, + tickLower, + tickUpper, + liquidity: maxLiquidityForAmounts( + pool.sqrtRatioX96, + sqrtRatioAX96, + sqrtRatioBX96, + amount0, + amount1, + useFullPrecision + ), + }) } /** @@ -148,7 +364,6 @@ export class Position { * not what core can theoretically support * @returns The position */ - // @ts-ignore public static fromAmount0({ pool, tickLower, @@ -161,8 +376,8 @@ export class Position { tickUpper: number amount0: BigintIsh useFullPrecision: boolean - }): Position { - throw new Error('not implemented') + }) { + return Position.fromAmounts({ pool, tickLower, tickUpper, amount0, amount1: MaxUint256, useFullPrecision }) } /** @@ -173,7 +388,6 @@ export class Position { * @param amount1 The desired amount of token1 * @returns The position */ - // @ts-ignore public static fromAmount1({ pool, tickLower, @@ -184,7 +398,8 @@ export class Position { tickLower: number tickUpper: number amount1: BigintIsh - }): Position { - throw new Error('not implemented') + }) { + // this function always uses full precision, + return Position.fromAmounts({ pool, tickLower, tickUpper, amount0: MaxUint256, amount1, useFullPrecision: true }) } } diff --git a/sdks/v4-sdk/src/internalConstants.ts b/sdks/v4-sdk/src/internalConstants.ts index 3ab91f83f..1c110f5ff 100644 --- a/sdks/v4-sdk/src/internalConstants.ts +++ b/sdks/v4-sdk/src/internalConstants.ts @@ -1,5 +1,6 @@ import JSBI from 'jsbi' import { constants } from 'ethers' +import { encodeSqrtRatioX96 } from '@uniswap/v3-sdk' // constants used internally but not expected to be used externally export const ADDRESS_ZERO = constants.AddressZero @@ -7,6 +8,7 @@ export const NEGATIVE_ONE = JSBI.BigInt(-1) export const ZERO = JSBI.BigInt(0) export const ONE = JSBI.BigInt(1) export const ONE_ETHER = JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(18)) +export const EMPTY_BYTES = '0x' // used in liquidity amount math export const Q96 = JSBI.exponentiate(JSBI.BigInt(2), JSBI.BigInt(96)) @@ -22,6 +24,23 @@ export const TICK_SPACING_SIXTY = 60 // used in position manager math export const MIN_SLIPPAGE_DECREASE = 0 +// default prices +export const SQRT_PRICE_1_1 = encodeSqrtRatioX96(1, 1) + +// default hook addresses +export const EMPTY_HOOK = '0x0000000000000000000000000000000000000000' + +// error constants +export const NO_NATIVE = 'NO_NATIVE' + +/** + * Function fragments that exist on the PositionManager contract. + */ +export enum PositionFunctions { + INITIALIZE_POOL = 'initializePool', + MODIFY_LIQUIDITIES = 'modifyLiquidities', +} + /** * The default factory enabled fee amounts, denominated in hundredths of bips. */ diff --git a/sdks/v4-sdk/src/multicall.ts b/sdks/v4-sdk/src/multicall.ts index ae8c99c79..0ce4a472b 100644 --- a/sdks/v4-sdk/src/multicall.ts +++ b/sdks/v4-sdk/src/multicall.ts @@ -9,12 +9,14 @@ export abstract class Multicall { */ private constructor() {} - public static encodeMulticall(calldatas: string | string[]): string { - if (!Array.isArray(calldatas)) { - calldatas = [calldatas] + public static encodeMulticall(calldataList: string | string[]): string { + if (!Array.isArray(calldataList)) { + calldataList = [calldataList] } - return calldatas.length === 1 ? calldatas[0] : Multicall.INTERFACE.encodeFunctionData('multicall', [calldatas]) + return calldataList.length === 1 + ? calldataList[0] + : Multicall.INTERFACE.encodeFunctionData('multicall', [calldataList]) } public static decodeMulticall(encodedCalldata: string): string[] { diff --git a/sdks/v4-sdk/src/posm.test.ts b/sdks/v4-sdk/src/posm.test.ts new file mode 100644 index 000000000..b1c3f11e6 --- /dev/null +++ b/sdks/v4-sdk/src/posm.test.ts @@ -0,0 +1,268 @@ +import { Ether, Percent, Token } from '@uniswap/sdk-core' +import { EMPTY_BYTES, EMPTY_HOOK, FeeAmount, NO_NATIVE, SQRT_PRICE_1_1, TICK_SPACINGS } from './internalConstants' +import { Pool } from './entities/pool' +import { Position } from './entities/position' +import { V4PositionManager } from './PositionManager' +import { Multicall } from './multicall' +import { Actions, toHex, V4Planner } from './utils' +import { PoolKey } from './entities/pool' +import { toAddress } from './utils/currencyMap' +import { MSG_SENDER } from './actionConstants' + +describe('POSM', () => { + const currency0 = new Token(1, '0x0000000000000000000000000000000000000001', 18, 't0', 'currency0') + const currency1 = new Token(1, '0x0000000000000000000000000000000000000002', 18, 't1', 'currency1') + const currency_native = Ether.onChain(1) + + const fee = FeeAmount.MEDIUM + const tickSpacing = 60 // for MEDIUM + + const pool_key_0_1 = Pool.getPoolKey(currency0, currency1, fee, tickSpacing, EMPTY_HOOK) + + const pool_0_1 = new Pool(currency0, currency1, fee, tickSpacing, EMPTY_HOOK, SQRT_PRICE_1_1.toString(), 0, 0, []) + + const pool_1_eth = new Pool( + currency_native, + currency1, + fee, + tickSpacing, + EMPTY_HOOK, + SQRT_PRICE_1_1.toString(), + 0, + 0, + [] + ) + + const recipient = '0x0000000000000000000000000000000000000003' + + const tokenId = 1 + const slippageTolerance = new Percent(1, 100) + const deadline = 123 + + let planner: V4Planner + + beforeEach(() => { + planner = new V4Planner() + }) + + describe('#createCallParameters', () => { + it('succeeds', () => { + const { calldata, value } = V4PositionManager.createCallParameters(pool_key_0_1, SQRT_PRICE_1_1) + /** + * 1) "initializePool((address,address,uint24,int24,address),uint160,bytes)" + (0x0000000000000000000000000000000000000001, 0x0000000000000000000000000000000000000002, 3000, 60, 0x0000000000000000000000000000000000000000) + 79228162514264337593543950336 + 0x00 + */ + expect(calldata).toEqual( + '0x3b1fda97000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000000' + ) + expect(value).toEqual('0x00') + }) + + it('succeeds with nonzero hook', () => { + let hook = '0x1100000000000000000000000000000000002401' + let poolKey: PoolKey = Pool.getPoolKey(currency0, currency1, fee, tickSpacing, hook) + + const { calldata, value } = V4PositionManager.createCallParameters(poolKey, SQRT_PRICE_1_1) + /** + * 1) "initializePool((address,address,uint24,int24,address),uint160,bytes)" + (0x0000000000000000000000000000000000000001, 0x0000000000000000000000000000000000000002, 3000, 60, 0x1100000000000000000000000000000000002401) + 79228162514264337593543950336 + 0x00 + */ + expect(calldata).toEqual( + '0x3b1fda97000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000bb8000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000001100000000000000000000000000000000002401000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000000' + ) + expect(value).toEqual('0x00') + }) + }) + + describe('#addCallParameters', () => { + it('throws if liquidity is 0', () => { + expect(() => + V4PositionManager.addCallParameters( + new Position({ + pool: pool_0_1, + tickLower: -TICK_SPACINGS[FeeAmount.MEDIUM], + tickUpper: TICK_SPACINGS[FeeAmount.MEDIUM], + liquidity: 0, + }), + { recipient, slippageTolerance, deadline } + ) + ).toThrow('ZERO_LIQUIDITY') + }) + + it('throws if pool does not involve ether and useNative is true', () => { + expect(() => + V4PositionManager.addCallParameters( + new Position({ + pool: pool_0_1, + tickLower: -TICK_SPACINGS[FeeAmount.MEDIUM], + tickUpper: TICK_SPACINGS[FeeAmount.MEDIUM], + liquidity: 8888888, + }), + { recipient, slippageTolerance, deadline, useNative: Ether.onChain(1) } + ) + ).toThrow(NO_NATIVE) + }) + + it('throws if createPool is true but there is no sqrtPrice defined', () => { + let createPool: boolean = true + expect(() => + V4PositionManager.addCallParameters( + new Position({ + pool: pool_0_1, + tickLower: -TICK_SPACINGS[FeeAmount.MEDIUM], + tickUpper: TICK_SPACINGS[FeeAmount.MEDIUM], + liquidity: 1, + }), + { createPool, recipient, slippageTolerance, deadline } + ) + ).toThrow('NO_SQRT_PRICE') + }) + + it('succeeds for mint', () => { + const position: Position = new Position({ + pool: pool_0_1, + tickLower: -TICK_SPACINGS[FeeAmount.MEDIUM], + tickUpper: TICK_SPACINGS[FeeAmount.MEDIUM], + liquidity: 5000000, + }) + const { calldata, value } = V4PositionManager.addCallParameters(position, { + recipient, + slippageTolerance, + deadline, + }) + + // Rebuild the calldata with the planner for the expected mint. + // Note that this test verifies that the applied logic in addCallParameters is correct but does not necessarily test the validity of the calldata itself. + const { amount0: amount0Max, amount1: amount1Max } = position.mintAmountsWithSlippage(slippageTolerance) + planner.addAction(Actions.MINT_POSITION, [ + pool_0_1.poolKey, + -TICK_SPACINGS[FeeAmount.MEDIUM], + TICK_SPACINGS[FeeAmount.MEDIUM], + 5000000, + toHex(amount0Max), + toHex(amount1Max), + recipient, + EMPTY_BYTES, + ]) + // Expect there to be a settle pair call afterwards + planner.addAction(Actions.SETTLE_PAIR, [toAddress(pool_0_1.currency0), toAddress(pool_0_1.currency1)]) + + expect(calldata).toEqual(V4PositionManager.encodeModifyLiquidities(planner.finalize(), deadline)) + expect(value).toEqual('0x00') + }) + + it('succeeds for increase', () => { + const position: Position = new Position({ + pool: pool_0_1, + tickLower: -TICK_SPACINGS[FeeAmount.MEDIUM], + tickUpper: TICK_SPACINGS[FeeAmount.MEDIUM], + liquidity: 666, + }) + + const { calldata, value } = V4PositionManager.addCallParameters(position, { + tokenId, + slippageTolerance, + deadline, + }) + + // Rebuild the calldata with the planner for increase + const planner = new V4Planner() + const { amount0: amount0Max, amount1: amount1Max } = position.mintAmountsWithSlippage(slippageTolerance) + planner.addAction(Actions.INCREASE_LIQUIDITY, [ + tokenId.toString(), + 666, + toHex(amount0Max), + toHex(amount1Max), + EMPTY_BYTES, + ]) + // Expect there to be a settle pair call afterwards + planner.addAction(Actions.SETTLE_PAIR, [toAddress(pool_0_1.currency0), toAddress(pool_0_1.currency1)]) + expect(calldata).toEqual(V4PositionManager.encodeModifyLiquidities(planner.finalize(), deadline)) + expect(value).toEqual('0x00') + }) + + it('succeeds when createPool is true', () => { + const position: Position = new Position({ + pool: pool_0_1, + tickLower: -TICK_SPACINGS[FeeAmount.MEDIUM], + tickUpper: TICK_SPACINGS[FeeAmount.MEDIUM], + liquidity: 90000000000000, + }) + const { calldata, value } = V4PositionManager.addCallParameters(position, { + recipient, + slippageTolerance, + deadline, + createPool: true, + sqrtPriceX96: SQRT_PRICE_1_1, + }) + + // The resulting calldata should be multicall with two calls: initializePool and modifyLiquidities + const calldataList = Multicall.decodeMulticall(calldata) + // Expect initializePool to be called correctly + expect(calldataList[0]).toEqual( + V4PositionManager.INTERFACE.encodeFunctionData('initializePool', [ + pool_0_1.poolKey, + SQRT_PRICE_1_1.toString(), + EMPTY_BYTES, + ]) + ) + const planner = new V4Planner() + const { amount0: amount0Max, amount1: amount1Max } = position.mintAmountsWithSlippage(slippageTolerance) + // Expect position to be minted correctly + planner.addAction(Actions.MINT_POSITION, [ + pool_0_1.poolKey, + -TICK_SPACINGS[FeeAmount.MEDIUM], + TICK_SPACINGS[FeeAmount.MEDIUM], + 90000000000000, + toHex(amount0Max), + toHex(amount1Max), + recipient, + EMPTY_BYTES, + ]) + planner.addAction(Actions.SETTLE_PAIR, [toAddress(pool_0_1.currency0), toAddress(pool_0_1.currency1)]) + expect(calldataList[1]).toEqual(V4PositionManager.encodeModifyLiquidities(planner.finalize(), deadline)) + expect(value).toEqual('0x00') + }) + + it('succeeds when useNative is true', () => { + const position: Position = new Position({ + pool: pool_1_eth, + tickLower: -TICK_SPACINGS[FeeAmount.MEDIUM], + tickUpper: TICK_SPACINGS[FeeAmount.MEDIUM], + liquidity: 1, + }) + const { calldata, value } = V4PositionManager.addCallParameters(position, { + recipient, + slippageTolerance, + deadline, + useNative: Ether.onChain(1), + }) + + // Rebuild the data with the planner for the expected mint. MUST sweep since we are using the native currency. + + const planner = new V4Planner() + const { amount0: amount0Max, amount1: amount1Max } = position.mintAmountsWithSlippage(slippageTolerance) + // Expect position to be minted correctly + planner.addAction(Actions.MINT_POSITION, [ + pool_1_eth.poolKey, + -TICK_SPACINGS[FeeAmount.MEDIUM], + TICK_SPACINGS[FeeAmount.MEDIUM], + 1, + toHex(amount0Max), + toHex(amount1Max), + recipient, + EMPTY_BYTES, + ]) + + planner.addAction(Actions.SETTLE_PAIR, [toAddress(pool_1_eth.currency0), toAddress(pool_1_eth.currency1)]) + planner.addAction(Actions.SWEEP, [toAddress(pool_1_eth.currency0), MSG_SENDER]) + expect(calldata).toEqual(V4PositionManager.encodeModifyLiquidities(planner.finalize(), deadline)) + + expect(value).toEqual(toHex(amount0Max)) + }) + }) +}) diff --git a/sdks/v4-sdk/src/utils/abi.ts b/sdks/v4-sdk/src/utils/abi.ts new file mode 100644 index 000000000..fd4454559 --- /dev/null +++ b/sdks/v4-sdk/src/utils/abi.ts @@ -0,0 +1,524 @@ +// TODO: import this from npm +export const abi = [ + { + type: 'constructor', + inputs: [ + { name: '_poolManager', type: 'address', internalType: 'contract IPoolManager' }, + { name: '_permit2', type: 'address', internalType: 'contract IAllowanceTransfer' }, + { name: '_unsubscribeGasLimit', type: 'uint256', internalType: 'uint256' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'DOMAIN_SEPARATOR', + inputs: [], + outputs: [{ name: '', type: 'bytes32', internalType: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'approve', + inputs: [ + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'id', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'balanceOf', + inputs: [{ name: 'owner', type: 'address', internalType: 'address' }], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getApproved', + inputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getPoolAndPositionInfo', + inputs: [{ name: 'tokenId', type: 'uint256', internalType: 'uint256' }], + outputs: [ + { + name: 'poolKey', + type: 'tuple', + internalType: 'struct PoolKey', + components: [ + { name: 'currency0', type: 'address', internalType: 'Currency' }, + { name: 'currency1', type: 'address', internalType: 'Currency' }, + { name: 'fee', type: 'uint24', internalType: 'uint24' }, + { name: 'tickSpacing', type: 'int24', internalType: 'int24' }, + { name: 'hooks', type: 'address', internalType: 'contract IHooks' }, + ], + }, + { name: 'info', type: 'uint256', internalType: 'PositionInfo' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getPositionLiquidity', + inputs: [{ name: 'tokenId', type: 'uint256', internalType: 'uint256' }], + outputs: [{ name: 'liquidity', type: 'uint128', internalType: 'uint128' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'initializePool', + inputs: [ + { + name: 'key', + type: 'tuple', + internalType: 'struct PoolKey', + components: [ + { name: 'currency0', type: 'address', internalType: 'Currency' }, + { name: 'currency1', type: 'address', internalType: 'Currency' }, + { name: 'fee', type: 'uint24', internalType: 'uint24' }, + { name: 'tickSpacing', type: 'int24', internalType: 'int24' }, + { name: 'hooks', type: 'address', internalType: 'contract IHooks' }, + ], + }, + { name: 'sqrtPriceX96', type: 'uint160', internalType: 'uint160' }, + { name: 'hookData', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [{ name: '', type: 'int24', internalType: 'int24' }], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'isApprovedForAll', + inputs: [ + { name: '', type: 'address', internalType: 'address' }, + { name: '', type: 'address', internalType: 'address' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'modifyLiquidities', + inputs: [ + { name: 'unlockData', type: 'bytes', internalType: 'bytes' }, + { name: 'deadline', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'modifyLiquiditiesWithoutUnlock', + inputs: [ + { name: 'actions', type: 'bytes', internalType: 'bytes' }, + { name: 'params', type: 'bytes[]', internalType: 'bytes[]' }, + ], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'msgSender', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'multicall', + inputs: [{ name: 'data', type: 'bytes[]', internalType: 'bytes[]' }], + outputs: [{ name: 'results', type: 'bytes[]', internalType: 'bytes[]' }], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'name', + inputs: [], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'nextTokenId', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'nonces', + inputs: [ + { name: 'owner', type: 'address', internalType: 'address' }, + { name: 'word', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: 'bitmap', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'ownerOf', + inputs: [{ name: 'id', type: 'uint256', internalType: 'uint256' }], + outputs: [{ name: 'owner', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'permit', + inputs: [ + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'tokenId', type: 'uint256', internalType: 'uint256' }, + { name: 'deadline', type: 'uint256', internalType: 'uint256' }, + { name: 'nonce', type: 'uint256', internalType: 'uint256' }, + { name: 'signature', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'permit', + inputs: [ + { name: 'owner', type: 'address', internalType: 'address' }, + { + name: 'permitSingle', + type: 'tuple', + internalType: 'struct IAllowanceTransfer.PermitSingle', + components: [ + { + name: 'details', + type: 'tuple', + internalType: 'struct IAllowanceTransfer.PermitDetails', + components: [ + { name: 'token', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint160', internalType: 'uint160' }, + { name: 'expiration', type: 'uint48', internalType: 'uint48' }, + { name: 'nonce', type: 'uint48', internalType: 'uint48' }, + ], + }, + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'sigDeadline', type: 'uint256', internalType: 'uint256' }, + ], + }, + { name: 'signature', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [{ name: 'err', type: 'bytes', internalType: 'bytes' }], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'permit2', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'contract IAllowanceTransfer' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'permitBatch', + inputs: [ + { name: 'owner', type: 'address', internalType: 'address' }, + { + name: '_permitBatch', + type: 'tuple', + internalType: 'struct IAllowanceTransfer.PermitBatch', + components: [ + { + name: 'details', + type: 'tuple[]', + internalType: 'struct IAllowanceTransfer.PermitDetails[]', + components: [ + { name: 'token', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint160', internalType: 'uint160' }, + { name: 'expiration', type: 'uint48', internalType: 'uint48' }, + { name: 'nonce', type: 'uint48', internalType: 'uint48' }, + ], + }, + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'sigDeadline', type: 'uint256', internalType: 'uint256' }, + ], + }, + { name: 'signature', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [{ name: 'err', type: 'bytes', internalType: 'bytes' }], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'permitForAll', + inputs: [ + { name: 'owner', type: 'address', internalType: 'address' }, + { name: 'operator', type: 'address', internalType: 'address' }, + { name: 'approved', type: 'bool', internalType: 'bool' }, + { name: 'deadline', type: 'uint256', internalType: 'uint256' }, + { name: 'nonce', type: 'uint256', internalType: 'uint256' }, + { name: 'signature', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'poolKeys', + inputs: [{ name: 'poolId', type: 'bytes25', internalType: 'bytes25' }], + outputs: [ + { name: 'currency0', type: 'address', internalType: 'Currency' }, + { name: 'currency1', type: 'address', internalType: 'Currency' }, + { name: 'fee', type: 'uint24', internalType: 'uint24' }, + { name: 'tickSpacing', type: 'int24', internalType: 'int24' }, + { name: 'hooks', type: 'address', internalType: 'contract IHooks' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'poolManager', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'contract IPoolManager' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'positionInfo', + inputs: [{ name: 'tokenId', type: 'uint256', internalType: 'uint256' }], + outputs: [{ name: 'info', type: 'uint256', internalType: 'PositionInfo' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'revokeNonce', + inputs: [{ name: 'nonce', type: 'uint256', internalType: 'uint256' }], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'safeTransferFrom', + inputs: [ + { name: 'from', type: 'address', internalType: 'address' }, + { name: 'to', type: 'address', internalType: 'address' }, + { name: 'id', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'safeTransferFrom', + inputs: [ + { name: 'from', type: 'address', internalType: 'address' }, + { name: 'to', type: 'address', internalType: 'address' }, + { name: 'id', type: 'uint256', internalType: 'uint256' }, + { name: 'data', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setApprovalForAll', + inputs: [ + { name: 'operator', type: 'address', internalType: 'address' }, + { name: 'approved', type: 'bool', internalType: 'bool' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'subscribe', + inputs: [ + { name: 'tokenId', type: 'uint256', internalType: 'uint256' }, + { name: 'newSubscriber', type: 'address', internalType: 'address' }, + { name: 'data', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'subscriber', + inputs: [{ name: 'tokenId', type: 'uint256', internalType: 'uint256' }], + outputs: [{ name: 'subscriber', type: 'address', internalType: 'contract ISubscriber' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'supportsInterface', + inputs: [{ name: 'interfaceId', type: 'bytes4', internalType: 'bytes4' }], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'symbol', + inputs: [], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'tokenURI', + inputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'pure', + }, + { + type: 'function', + name: 'transferFrom', + inputs: [ + { name: 'from', type: 'address', internalType: 'address' }, + { name: 'to', type: 'address', internalType: 'address' }, + { name: 'id', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'unlockCallback', + inputs: [{ name: 'data', type: 'bytes', internalType: 'bytes' }], + outputs: [{ name: '', type: 'bytes', internalType: 'bytes' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'unsubscribe', + inputs: [{ name: 'tokenId', type: 'uint256', internalType: 'uint256' }], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'unsubscribeGasLimit', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'event', + name: 'Approval', + inputs: [ + { name: 'owner', type: 'address', indexed: true, internalType: 'address' }, + { name: 'spender', type: 'address', indexed: true, internalType: 'address' }, + { name: 'id', type: 'uint256', indexed: true, internalType: 'uint256' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'ApprovalForAll', + inputs: [ + { name: 'owner', type: 'address', indexed: true, internalType: 'address' }, + { name: 'operator', type: 'address', indexed: true, internalType: 'address' }, + { name: 'approved', type: 'bool', indexed: false, internalType: 'bool' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Subscription', + inputs: [ + { name: 'tokenId', type: 'uint256', indexed: true, internalType: 'uint256' }, + { name: 'subscriber', type: 'address', indexed: true, internalType: 'address' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Transfer', + inputs: [ + { name: 'from', type: 'address', indexed: true, internalType: 'address' }, + { name: 'to', type: 'address', indexed: true, internalType: 'address' }, + { name: 'id', type: 'uint256', indexed: true, internalType: 'uint256' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Unsubscription', + inputs: [ + { name: 'tokenId', type: 'uint256', indexed: true, internalType: 'uint256' }, + { name: 'subscriber', type: 'address', indexed: true, internalType: 'address' }, + ], + anonymous: false, + }, + { + type: 'error', + name: 'AlreadySubscribed', + inputs: [ + { name: 'tokenId', type: 'uint256', internalType: 'uint256' }, + { name: 'subscriber', type: 'address', internalType: 'address' }, + ], + }, + { type: 'error', name: 'ContractLocked', inputs: [] }, + { type: 'error', name: 'DeadlinePassed', inputs: [{ name: 'deadline', type: 'uint256', internalType: 'uint256' }] }, + { + type: 'error', + name: 'DeltaNotNegative', + inputs: [{ name: 'currency', type: 'address', internalType: 'Currency' }], + }, + { + type: 'error', + name: 'DeltaNotPositive', + inputs: [{ name: 'currency', type: 'address', internalType: 'Currency' }], + }, + { type: 'error', name: 'GasLimitTooLow', inputs: [] }, + { type: 'error', name: 'InputLengthMismatch', inputs: [] }, + { type: 'error', name: 'InvalidContractSignature', inputs: [] }, + { type: 'error', name: 'InvalidSignature', inputs: [] }, + { type: 'error', name: 'InvalidSignatureLength', inputs: [] }, + { type: 'error', name: 'InvalidSigner', inputs: [] }, + { + type: 'error', + name: 'MaximumAmountExceeded', + inputs: [ + { name: 'maximumAmount', type: 'uint128', internalType: 'uint128' }, + { name: 'amountRequested', type: 'uint128', internalType: 'uint128' }, + ], + }, + { + type: 'error', + name: 'MinimumAmountInsufficient', + inputs: [ + { name: 'minimumAmount', type: 'uint128', internalType: 'uint128' }, + { name: 'amountReceived', type: 'uint128', internalType: 'uint128' }, + ], + }, + { type: 'error', name: 'NoCodeSubscriber', inputs: [] }, + { type: 'error', name: 'NoSelfPermit', inputs: [] }, + { type: 'error', name: 'NonceAlreadyUsed', inputs: [] }, + { type: 'error', name: 'NotApproved', inputs: [{ name: 'caller', type: 'address', internalType: 'address' }] }, + { type: 'error', name: 'NotPoolManager', inputs: [] }, + { type: 'error', name: 'NotSubscribed', inputs: [] }, + { type: 'error', name: 'SignatureDeadlineExpired', inputs: [] }, + { type: 'error', name: 'SliceOutOfBounds', inputs: [] }, + { type: 'error', name: 'Unauthorized', inputs: [] }, + { type: 'error', name: 'UnsupportedAction', inputs: [{ name: 'action', type: 'uint256', internalType: 'uint256' }] }, + { + type: 'error', + name: 'Wrap__ModifyLiquidityNotificationReverted', + inputs: [ + { name: 'subscriber', type: 'address', internalType: 'address' }, + { name: 'reason', type: 'bytes', internalType: 'bytes' }, + ], + }, + { + type: 'error', + name: 'Wrap__SubscriptionReverted', + inputs: [ + { name: 'subscriber', type: 'address', internalType: 'address' }, + { name: 'reason', type: 'bytes', internalType: 'bytes' }, + ], + }, + { + type: 'error', + name: 'Wrap__TransferNotificationReverted', + inputs: [ + { name: 'subscriber', type: 'address', internalType: 'address' }, + { name: 'reason', type: 'bytes', internalType: 'bytes' }, + ], + }, +] diff --git a/sdks/v4-sdk/src/utils/currencyMap.ts b/sdks/v4-sdk/src/utils/currencyMap.ts new file mode 100644 index 000000000..e0fc2325b --- /dev/null +++ b/sdks/v4-sdk/src/utils/currencyMap.ts @@ -0,0 +1,10 @@ +import { Currency } from '@uniswap/sdk-core' +import { ADDRESS_ZERO } from '../internalConstants' + +// Uniswap v4 supports native pools. Those currencies are represented by the zero address. +// TODO: Figure out if this is how we should be handling weird edge case tokens like CELO/Polygon/etc.. +// Does interface treat those like ERC20 tokens or NATIVE tokens? +export function toAddress(currency: Currency): string { + if (currency.isNative) return ADDRESS_ZERO + else return currency.wrapped.address +} diff --git a/sdks/v4-sdk/src/utils/index.ts b/sdks/v4-sdk/src/utils/index.ts index bf7e0dd40..6134402f6 100644 --- a/sdks/v4-sdk/src/utils/index.ts +++ b/sdks/v4-sdk/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './encodeRouteToPath' export * from './v4Planner' +export * from './v4PositionPlanner' export * from './calldata' diff --git a/sdks/v4-sdk/src/utils/priceTickConversions.ts b/sdks/v4-sdk/src/utils/priceTickConversions.ts new file mode 100644 index 000000000..b30f4605a --- /dev/null +++ b/sdks/v4-sdk/src/utils/priceTickConversions.ts @@ -0,0 +1,54 @@ +import { Price, Currency } from '@uniswap/sdk-core' +import JSBI from 'jsbi' +import { Q192 } from '../internalConstants' +import { TickMath, encodeSqrtRatioX96 } from '@uniswap/v3-sdk' +import { sortsBefore } from '../utils/sortsBefore' + +/** + * This library is the almost the same as v3-sdk priceTickConversion except + * that it accepts a Currency type instead of a Token type, + * and thus uses some helper functions defined for the Currency type over the Token type. + */ + +/** + * Returns a price object corresponding to the input tick and the base/quote token + * Inputs must be tokens because the address order is used to interpret the price represented by the tick + * @param baseToken the base token of the price + * @param quoteToken the quote token of the price + * @param tick the tick for which to return the price + */ +export function tickToPrice(baseCurrency: Currency, quoteCurrency: Currency, tick: number): Price { + const sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick) + + const ratioX192 = JSBI.multiply(sqrtRatioX96, sqrtRatioX96) + + return sortsBefore(baseCurrency, quoteCurrency) + ? new Price(baseCurrency, quoteCurrency, Q192, ratioX192) + : new Price(baseCurrency, quoteCurrency, ratioX192, Q192) +} + +/** + * Returns the first tick for which the given price is greater than or equal to the tick price + * @param price for which to return the closest tick that represents a price less than or equal to the input price, + * i.e. the price of the returned tick is less than or equal to the input price + */ +export function priceToClosestTick(price: Price): number { + const sorted = sortsBefore(price.baseCurrency, price.quoteCurrency) + + const sqrtRatioX96 = sorted + ? encodeSqrtRatioX96(price.numerator, price.denominator) + : encodeSqrtRatioX96(price.denominator, price.numerator) + + let tick = TickMath.getTickAtSqrtRatio(sqrtRatioX96) + const nextTickPrice = tickToPrice(price.baseCurrency, price.quoteCurrency, tick + 1) + if (sorted) { + if (!price.lessThan(nextTickPrice)) { + tick++ + } + } else { + if (!price.greaterThan(nextTickPrice)) { + tick++ + } + } + return tick +} diff --git a/sdks/v4-sdk/src/utils/v4Planner.ts b/sdks/v4-sdk/src/utils/v4Planner.ts index 44d90fc91..952243a99 100644 --- a/sdks/v4-sdk/src/utils/v4Planner.ts +++ b/sdks/v4-sdk/src/utils/v4Planner.ts @@ -3,7 +3,7 @@ import { defaultAbiCoder } from 'ethers/lib/utils' import { BigNumber } from 'ethers' import { Currency, Percent, TradeType } from '@uniswap/sdk-core' import { Trade } from '../entities/trade' -import { ADDRESS_ZERO } from '../internalConstants' +import { ADDRESS_ZERO, EMPTY_BYTES } from '../internalConstants' import { encodeRouteToPath } from './encodeRouteToPath' /** * Actions @@ -27,10 +27,12 @@ export enum Actions { // settling SETTLE = 0x09, SETTLE_ALL = 0x10, + SETTLE_PAIR = 0x11, // taking TAKE = 0x12, TAKE_ALL = 0x13, TAKE_PORTION = 0x14, + TAKE_PAIR = 0x15, SETTLE_TAKE_PAIR = 0x16, @@ -95,9 +97,11 @@ const ABI_DEFINITION: { [key in Actions]: string[] } = { // Payments commands [Actions.SETTLE]: ['address', 'uint256', 'bool'], // currency, amount, payerIsUser [Actions.SETTLE_ALL]: ['address', 'uint256'], + [Actions.SETTLE_PAIR]: ['address', 'address'], [Actions.TAKE]: ['address', 'address', 'uint256'], // currency, receiver, amount [Actions.TAKE_ALL]: ['address', 'uint256'], [Actions.TAKE_PORTION]: ['address', 'address', 'uint256'], + [Actions.TAKE_PAIR]: ['address', 'address', 'address'], [Actions.SETTLE_TAKE_PAIR]: ['address', 'address'], [Actions.CLOSE_CURRENCY]: ['address'], [Actions.SWEEP]: ['address', 'address'], @@ -112,7 +116,7 @@ export class V4Planner { params: string[] constructor() { - this.actions = '0x' + this.actions = EMPTY_BYTES this.params = [] } diff --git a/sdks/v4-sdk/src/utils/v4PositionPlanner.ts b/sdks/v4-sdk/src/utils/v4PositionPlanner.ts new file mode 100644 index 000000000..1c1d470d9 --- /dev/null +++ b/sdks/v4-sdk/src/utils/v4PositionPlanner.ts @@ -0,0 +1,81 @@ +import { V4Planner } from './v4Planner' +import { Pool } from '../entities' +import { Actions } from '../utils' +import { BigintIsh, Currency } from '@uniswap/sdk-core' +import { toAddress } from '../utils/currencyMap' +import { EMPTY_BYTES } from '../internalConstants' + +// A wrapper around V4Planner to help handle PositionManager actions +export class V4PositionPlanner extends V4Planner { + // MINT_POSITION + addMint( + pool: Pool, + tickLower: number, + tickUpper: number, + liquidity: BigintIsh, + amount0Max: BigintIsh, + amount1Max: BigintIsh, + owner: string, + hookData: string = EMPTY_BYTES + ): void { + const inputs = [ + Pool.getPoolKey(pool.currency0, pool.currency1, pool.fee, pool.tickSpacing, pool.hooks), + tickLower, + tickUpper, + liquidity.toString(), + amount0Max.toString(), + amount1Max.toString(), + owner, + hookData, + ] + this.addAction(Actions.MINT_POSITION, inputs) + } + + // INCREASE_LIQUIDITY + addIncrease( + tokenId: BigintIsh, + liquidity: BigintIsh, + amount0Max: BigintIsh, + amount1Max: BigintIsh, + hookData: string = EMPTY_BYTES + ): void { + const inputs = [tokenId.toString(), liquidity.toString(), amount0Max.toString(), amount1Max.toString(), hookData] + this.addAction(Actions.INCREASE_LIQUIDITY, inputs) + } + + // DECREASE_LIQUIDITY + addDecrease( + tokenId: BigintIsh, + liquidity: BigintIsh, + amount0Min: BigintIsh, + amount1Min: BigintIsh, + hookData: string = EMPTY_BYTES + ): void { + const inputs = [tokenId.toString(), liquidity.toString(), amount0Min.toString(), amount1Min.toString(), hookData] + this.addAction(Actions.DECREASE_LIQUIDITY, inputs) + } + + // BURN_POSITION + addBurn(tokenId: BigintIsh, amount0Min: BigintIsh, amount1Min: BigintIsh, hookData: string = EMPTY_BYTES): void { + const inputs = [tokenId.toString(), amount0Min.toString(), amount1Min.toString(), hookData] + this.addAction(Actions.BURN_POSITION, inputs) + } + + // SETTLE_PAIR + addSettlePair(currency0: Currency, currency1: Currency): void { + const inputs = [toAddress(currency0), toAddress(currency1)] + this.addAction(Actions.SETTLE_PAIR, inputs) + } + + // TAKE_PAIR + addTakePair(currency0: Currency, currency1: Currency, recipient: string): void { + const inputs = [toAddress(currency0), toAddress(currency1), recipient] + this.addAction(Actions.TAKE_PAIR, inputs) + } + + // SWEEP + addSweep(currency: Currency, to: string): void { + const inputs = [toAddress(currency), to] + this.addAction(Actions.SWEEP, inputs) + } +}