diff --git a/.changeset/late-rings-pay.md b/.changeset/late-rings-pay.md new file mode 100644 index 000000000..795e5de51 --- /dev/null +++ b/.changeset/late-rings-pay.md @@ -0,0 +1,5 @@ +--- +"@balancer/sdk": patch +--- + +Fix Permit2 spender for add liquidity unbalanced via swap to use UnbalancedAddViaSwapRouter, and sign the slippage-adjusted max adjustable amount. diff --git a/src/entities/addLiquidityUnbalancedViaSwap/types.ts b/src/entities/addLiquidityUnbalancedViaSwap/types.ts index 8ebb4aaf2..ec8585281 100644 --- a/src/entities/addLiquidityUnbalancedViaSwap/types.ts +++ b/src/entities/addLiquidityUnbalancedViaSwap/types.ts @@ -22,7 +22,7 @@ export type AddLiquidityUnbalancedViaSwapInput = { * * Use flow-specific fields for `buildCall` and Permit2: * - `pool` — router address argument (distinct from `poolId`) - * - `expectedAdjustableAmountIn` — user budget / permit ceiling + * - `expectedAdjustableAmountIn` — user budget; Permit2 signs budget + slippage * * Generic `signAddLiquidityApproval` does not apply; use * `signAddLiquidityUnbalancedViaSwapApproval` instead. diff --git a/src/entities/permit2Helper/index.ts b/src/entities/permit2Helper/index.ts index 31292a0ab..790db768a 100644 --- a/src/entities/permit2Helper/index.ts +++ b/src/entities/permit2Helper/index.ts @@ -212,7 +212,12 @@ export class Permit2Helper { expiration?: number; }, ): Promise { - const spender = AddressProvider.Router(input.chainId); + const spender = AddressProvider.UnbalancedAddViaSwapRouter( + input.chainId, + ); + const maxAdjustableAmountIn = input.slippage.applyTo( + input.expectedAdjustableAmountIn.amount, + ); const details: PermitDetails[] = []; details.push( await getDetails( @@ -220,7 +225,7 @@ export class Permit2Helper { input.expectedAdjustableAmountIn.token.address, input.owner, spender, - input.expectedAdjustableAmountIn.amount, + maxAdjustableAmountIn, input.expiration, input.nonce, ), diff --git a/test/v3/addLiquidityUnbalancedViaSwap/addLiquidityUnbalancedViaSwap.test.ts b/test/v3/addLiquidityUnbalancedViaSwap/addLiquidityUnbalancedViaSwap.test.ts index 8564c1231..98dc343eb 100644 --- a/test/v3/addLiquidityUnbalancedViaSwap/addLiquidityUnbalancedViaSwap.test.ts +++ b/test/v3/addLiquidityUnbalancedViaSwap/addLiquidityUnbalancedViaSwap.test.ts @@ -10,11 +10,21 @@ import { validateAddLiquidityUnbalancedViaSwapInput } from '@/entities/addLiquid import { AddressProvider } from '@/entities/inputValidator/utils/addressProvider'; import { Token } from '@/entities/token'; import { TokenAmount } from '@/entities/tokenAmount'; -import { Permit2Batch } from '@/entities/permit2Helper'; +import { Permit2Batch, Permit2Helper } from '@/entities/permit2Helper'; +import { PublicWalletClient } from '@/utils'; import { TOKENS } from 'test/lib/utils/addresses'; const chainId = ChainId.MAINNET; +const testOwner = '0x1111111111111111111111111111111111111111' as Address; + +const createMockClient = (clientChainId: ChainId): PublicWalletClient => + ({ + getChainId: async () => clientChainId, + readContract: async () => [0n, 0n, 0] as const, + signTypedData: async () => `0x${'00'.repeat(65)}` as `0x${string}`, + }) as unknown as PublicWalletClient; + const AAVE = TOKENS[chainId].AAVE; const WETH = TOKENS[chainId].WETH; const DAI = TOKENS[chainId].DAI; @@ -433,4 +443,110 @@ describe('AddLiquidityUnbalancedViaSwap', () => { ); }); }); + + describe('signAddLiquidityUnbalancedViaSwapApproval', () => { + test('uses UnbalancedAddViaSwapRouter as permit2 spender', async () => { + const bptToken = new Token(chainId, mockPoolState.address, 18); + const exactToken = new Token(chainId, WETH.address, WETH.decimals); + const adjustableToken = new Token( + chainId, + AAVE.address, + AAVE.decimals, + ); + const exactAmountIn = TokenAmount.fromRawAmount(exactToken, 0n); + const expectedAdjustableAmountIn = TokenAmount.fromRawAmount( + adjustableToken, + parseUnits('50', AAVE.decimals), + ); + + const buildCallInput = { + poolType: mockPoolState.type, + poolId: mockPoolState.id, + addLiquidityKind: AddLiquidityKind.UnbalancedViaSwap, + pool: mockPoolState.address, + bptOut: TokenAmount.fromRawAmount( + bptToken, + parseUnits('100', 18), + ), + exactAmountIn, + expectedAdjustableAmountIn, + amountsIn: [expectedAdjustableAmountIn, exactAmountIn], + tokenInIndex: 0, + chainId, + protocolVersion: 3 as const, + to: AddressProvider.UnbalancedAddViaSwapRouter(chainId), + addLiquidityUserData: '0x' as const, + swapUserData: '0x' as const, + slippage: Slippage.fromPercentage('1'), + deadline: maxUint256, + }; + + const permit2 = + await Permit2Helper.signAddLiquidityUnbalancedViaSwapApproval({ + ...buildCallInput, + client: createMockClient(chainId), + owner: testOwner, + }); + + expect(permit2.batch.spender).toEqual( + AddressProvider.UnbalancedAddViaSwapRouter(chainId), + ); + expect(permit2.batch.spender).not.toEqual( + AddressProvider.Router(chainId), + ); + }); + + test('applies slippage to permit amount', async () => { + const slippage = Slippage.fromPercentage('5'); + const adjustableToken = new Token( + chainId, + AAVE.address, + AAVE.decimals, + ); + const expectedAdjustableAmountIn = TokenAmount.fromRawAmount( + adjustableToken, + parseUnits('50', AAVE.decimals), + ); + const bptToken = new Token(chainId, mockPoolState.address, 18); + const exactToken = new Token(chainId, WETH.address, WETH.decimals); + const exactAmountIn = TokenAmount.fromRawAmount(exactToken, 0n); + + const buildCallInput = { + poolType: mockPoolState.type, + poolId: mockPoolState.id, + addLiquidityKind: AddLiquidityKind.UnbalancedViaSwap, + pool: mockPoolState.address, + bptOut: TokenAmount.fromRawAmount( + bptToken, + parseUnits('100', 18), + ), + exactAmountIn, + expectedAdjustableAmountIn, + amountsIn: [expectedAdjustableAmountIn, exactAmountIn], + tokenInIndex: 0, + chainId, + protocolVersion: 3 as const, + to: AddressProvider.UnbalancedAddViaSwapRouter(chainId), + addLiquidityUserData: '0x' as const, + swapUserData: '0x' as const, + slippage, + deadline: maxUint256, + }; + + const permit2 = + await Permit2Helper.signAddLiquidityUnbalancedViaSwapApproval({ + ...buildCallInput, + client: createMockClient(chainId), + owner: testOwner, + }); + + const maxAdjustableAmount = slippage.applyTo( + expectedAdjustableAmountIn.amount, + ); + expect(permit2.batch.details[0].amount).toBe(maxAdjustableAmount); + expect(permit2.batch.details[0].amount).not.toBe( + expectedAdjustableAmountIn.amount, + ); + }); + }); });