From 582a43da7e72487c3592f2d246d2efc6b8193b48 Mon Sep 17 00:00:00 2001 From: Eric Zhong Date: Thu, 26 Sep 2024 11:15:16 -0400 Subject: [PATCH] feat(v4-sdk): add permit2 forwarder support and other permit funcs (#104) Co-authored-by: Siyu Jiang (See-You John) <91580504+jsy1218@users.noreply.github.com> Co-authored-by: Sara Reynolds Co-authored-by: Sara Reynolds <30504811+snreynolds@users.noreply.github.com> --- sdks/v4-sdk/src/PositionManager.test.ts | 108 ++++++++++++++++++++- sdks/v4-sdk/src/PositionManager.ts | 91 +++++++++++++++-- sdks/v4-sdk/src/entities/position.ts | 35 +++++++ sdks/v4-sdk/src/internalConstants.ts | 4 + sdks/v4-sdk/src/utils/v4PositionPlanner.ts | 3 +- 5 files changed, 226 insertions(+), 15 deletions(-) diff --git a/sdks/v4-sdk/src/PositionManager.test.ts b/sdks/v4-sdk/src/PositionManager.test.ts index a297f1520..f396343ec 100644 --- a/sdks/v4-sdk/src/PositionManager.test.ts +++ b/sdks/v4-sdk/src/PositionManager.test.ts @@ -8,10 +8,11 @@ import { SQRT_PRICE_1_1, TICK_SPACINGS, ZERO_LIQUIDITY, + PositionFunctions, } from './internalConstants' import { Pool } from './entities/pool' import { Position } from './entities/position' -import { CollectOptions, RemoveLiquidityOptions, V4PositionManager } from './PositionManager' +import { BatchPermitOptions, CollectOptions, RemoveLiquidityOptions, V4PositionManager } from './PositionManager' import { Multicall } from './multicall' import { Actions, toHex, V4Planner } from './utils' import { PoolKey } from './entities/pool' @@ -43,12 +44,15 @@ describe('PositionManager', () => { [] ) - const recipient = '0x0000000000000000000000000000000000000003' - const tokenId = 1 const slippageTolerance = new Percent(1, 100) const deadline = 123 + const mockOwner = '0x000000000000000000000000000000000000000a' + const mockSpender = '0x000000000000000000000000000000000000000b' + const recipient = '0x000000000000000000000000000000000000000c' + const mockBytes32 = '0x0000000000000000000000000000000000000000000000000000000000000000' + let planner: V4Planner beforeEach(() => { @@ -274,6 +278,59 @@ describe('PositionManager', () => { expect(value).toEqual(toHex(amount0Max)) }) + + it('succeeds for batchPermit', () => { + const position: Position = new Position({ + pool: pool_0_1, + tickLower: -TICK_SPACINGS[FeeAmount.MEDIUM], + tickUpper: TICK_SPACINGS[FeeAmount.MEDIUM], + liquidity: 1, + }) + + const batchPermit: BatchPermitOptions = { + owner: mockOwner, + permitBatch: { + details: [], + sigDeadline: deadline, + spender: mockSpender, + }, + signature: mockBytes32, + } + + const { calldata, value } = V4PositionManager.addCallParameters(position, { + recipient, + slippageTolerance, + deadline, + batchPermit, + }) + + const calldataList = Multicall.decodeMulticall(calldata) + // Expect permitBatch to be called correctly + expect(calldataList[0]).toEqual( + V4PositionManager.INTERFACE.encodeFunctionData(PositionFunctions.PERMIT_BATCH, [ + batchPermit.owner, + batchPermit.permitBatch, + batchPermit.signature, + ]) + ) + + const planner = new V4Planner() + 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], + 1, + 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') + }) }) describe('#removeCallParameters', () => { @@ -303,6 +360,17 @@ describe('PositionManager', () => { ...removeLiqOptions, } + const burnLiqWithPermitOptions: RemoveLiquidityOptions = { + ...burnLiqOptions, + permit: { + spender: mockSpender, + tokenId, + deadline, + nonce: 1, + signature: '0x00', + }, + } + it('throws for 0 liquidity', () => { const zeroLiquidityPosition = new Position({ ...position, @@ -374,6 +442,40 @@ describe('PositionManager', () => { ) expect(value).toEqual('0x00') }) + + it('succeeds for burn with permit', () => { + const { calldata, value } = V4PositionManager.removeCallParameters(position, burnLiqWithPermitOptions) + + const { amount0: amount0Min, amount1: amount1Min } = position.burnAmountsWithSlippage(slippageTolerance) + + const planner = new V4PositionPlanner() + + planner.addAction(Actions.BURN_POSITION, [ + tokenId.toString(), + amount0Min.toString(), + amount1Min.toString(), + EMPTY_BYTES, + ]) + planner.addAction(Actions.TAKE_PAIR, [toAddress(currency0), toAddress(currency1), MSG_SENDER]) + + // The resulting calldata should be multicall with two calls: ERC721Permit_Permit and modifyLiquidities + const calldataList = Multicall.decodeMulticall(calldata) + // Expect ERC721Permit_Permit to be called correctly + expect(calldataList[0]).toEqual( + V4PositionManager.INTERFACE.encodeFunctionData(PositionFunctions.ERC721PERMIT_PERMIT, [ + burnLiqWithPermitOptions.permit!.spender, + tokenId.toString(), + burnLiqWithPermitOptions.permit!.deadline, + burnLiqWithPermitOptions.permit!.nonce, + burnLiqWithPermitOptions.permit!.signature, + ]) + ) + // Expect modifyLiquidities to be called correctly + expect(calldataList[1]).toEqual( + V4PositionManager.encodeModifyLiquidities(planner.finalize(), burnLiqOptions.deadline) + ) + expect(value).toEqual('0x00') + }) }) describe('#collectCallParameters', () => { diff --git a/sdks/v4-sdk/src/PositionManager.ts b/sdks/v4-sdk/src/PositionManager.ts index d6b546cfc..ef7210997 100644 --- a/sdks/v4-sdk/src/PositionManager.ts +++ b/sdks/v4-sdk/src/PositionManager.ts @@ -70,14 +70,9 @@ export interface CommonAddLiquidityOptions { useNative?: NativeCurrency /** - * The optional permit parameters for spending token0 + * The optional permit2 batch permit parameters for spending token0 and token1 */ - token0Permit?: any // TODO: add permit2 permit type here - - /** - * The optional permit parameters for spending token1 - */ - token1Permit?: any // TODO: add permit2 permit type here + batchPermit?: BatchPermitOptions } /** @@ -129,11 +124,37 @@ export interface TransferOptions { tokenId: BigintIsh } -export interface NFTPermitOptions { +export interface PermitDetails { + token: string + amount: BigintIsh + expiration: BigintIsh + nonce: BigintIsh +} + +export interface AllowanceTransferPermitSingle { + details: PermitDetails + spender: string + sigDeadline: BigintIsh +} + +export interface AllowanceTransferPermitBatch { + details: PermitDetails[] + spender: string + sigDeadline: BigintIsh +} + +export interface BatchPermitOptions { + owner: string + permitBatch: AllowanceTransferPermitBatch signature: string - deadline: BigintIsh +} + +export interface NFTPermitOptions { spender: string + tokenId: BigintIsh + deadline: BigintIsh nonce: BigintIsh + signature: string } export type MintOptions = CommonOptions & CommonAddLiquidityOptions & MintSpecificOptions @@ -176,7 +197,6 @@ export abstract class V4PositionManager { } } - // TODO: Add Support for permit2 batch forwarding public static addCallParameters(position: Position, options: AddLiquidityOptions): MethodParameters { /** * Cases: @@ -203,6 +223,17 @@ export abstract class V4PositionManager { const amount0Max = toHex(maximumAmounts.amount0) const amount1Max = toHex(maximumAmounts.amount1) + // We use permit2 to approve tokens to the position manager + if (options.batchPermit) { + calldataList.push( + V4PositionManager.encodePermitBatch( + options.batchPermit.owner, + options.batchPermit.permitBatch, + options.batchPermit.signature + ) + ) + } + // mint if (isMint(options)) { const recipient: string = validateAndParseAddress(options.recipient) @@ -264,6 +295,19 @@ export abstract class V4PositionManager { // if burnToken is true, the specified liquidity percentage must be 100% invariant(options.liquidityPercentage.equalTo(ONE), CANNOT_BURN) + // if there is a permit, encode the ERC721Permit permit call + if (options.permit) { + calldataList.push( + V4PositionManager.encodeERC721Permit( + options.permit.spender, + options.permit.tokenId, + options.permit.deadline, + options.permit.nonce, + options.permit.signature + ) + ) + } + // slippage-adjusted amounts derived from current position liquidity const { amount0: amount0Min, amount1: amount1Min } = position.burnAmountsWithSlippage(options.slippageTolerance) planner.addBurn(tokenId, amount0Min, amount1Min, options.hookData) @@ -343,7 +387,34 @@ export abstract class V4PositionManager { ]) } + // Encode a modify liquidities call public static encodeModifyLiquidities(unlockData: string, deadline: BigintIsh): string { return V4PositionManager.INTERFACE.encodeFunctionData(PositionFunctions.MODIFY_LIQUIDITIES, [unlockData, deadline]) } + + // Encode a permit batch call + public static encodePermitBatch(owner: string, permitBatch: AllowanceTransferPermitBatch, signature: string): string { + return V4PositionManager.INTERFACE.encodeFunctionData(PositionFunctions.PERMIT_BATCH, [ + owner, + permitBatch, + signature, + ]) + } + + // Encode a ERC721Permit permit call + public static encodeERC721Permit( + spender: string, + tokenId: BigintIsh, + deadline: BigintIsh, + nonce: BigintIsh, + signature: string + ): string { + return V4PositionManager.INTERFACE.encodeFunctionData(PositionFunctions.ERC721PERMIT_PERMIT, [ + spender, + tokenId, + deadline, + nonce, + signature, + ]) + } } diff --git a/sdks/v4-sdk/src/entities/position.ts b/sdks/v4-sdk/src/entities/position.ts index a7be2eb46..0a2ae22dd 100644 --- a/sdks/v4-sdk/src/entities/position.ts +++ b/sdks/v4-sdk/src/entities/position.ts @@ -5,6 +5,7 @@ import { Pool } from './pool' import { encodeSqrtRatioX96, maxLiquidityForAmounts, SqrtPriceMath, TickMath } from '@uniswap/v3-sdk' import { ZERO } from '../internalConstants' import { tickToPrice } from '../utils/priceTickConversions' +import { AllowanceTransferPermitBatch } from '../PositionManager' interface PositionConstructorArgs { pool: Pool @@ -310,6 +311,40 @@ export class Position { return this._mintAmounts } + /** + * Returns the AllowanceTransferPermitBatch for adding liquidity to a position + * @param slippageTolerance The amount by which the price can 'slip' before the transaction will revert + * @param spender The spender of the permit (should usually be the PositionManager) + * @param nonce A valid permit2 nonce + * @param deadline The deadline for the permit + */ + public permitBatchData( + slippageTolerance: Percent, + spender: string, + nonce: BigintIsh, + deadline: BigintIsh + ): AllowanceTransferPermitBatch { + const { amount0, amount1 } = this.mintAmountsWithSlippage(slippageTolerance) + return { + details: [ + { + token: this.pool.currency0.wrapped.address, + amount: amount0, + expiration: deadline, + nonce: nonce, + }, + { + token: this.pool.currency1.wrapped.address, + amount: amount1, + expiration: deadline, + nonce: nonce, + }, + ], + spender, + sigDeadline: deadline, + } + } + /** * Computes the maximum amount of liquidity received for a given amount of token0, token1, * and the prices at the tick boundaries. diff --git a/sdks/v4-sdk/src/internalConstants.ts b/sdks/v4-sdk/src/internalConstants.ts index fdc649a2c..5df7b3a80 100644 --- a/sdks/v4-sdk/src/internalConstants.ts +++ b/sdks/v4-sdk/src/internalConstants.ts @@ -42,6 +42,10 @@ export const CANNOT_BURN = 'CANNOT_BURN' export enum PositionFunctions { INITIALIZE_POOL = 'initializePool', MODIFY_LIQUIDITIES = 'modifyLiquidities', + // Inherited from PermitForwarder + PERMIT_BATCH = '0x002a3e3a', // "permitBatch(address,((address,uint160,uint48,uint48)[],address,uint256),bytes)" + // Inherited from ERC721Permit + ERC721PERMIT_PERMIT = '0x0f5730f1', // "permit(address,uint256,uint256,uint256,bytes)" } /** diff --git a/sdks/v4-sdk/src/utils/v4PositionPlanner.ts b/sdks/v4-sdk/src/utils/v4PositionPlanner.ts index 1c1d470d9..900b33f8f 100644 --- a/sdks/v4-sdk/src/utils/v4PositionPlanner.ts +++ b/sdks/v4-sdk/src/utils/v4PositionPlanner.ts @@ -1,6 +1,5 @@ -import { V4Planner } from './v4Planner' +import { Actions, 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'