diff --git a/.changeset/tame-books-smoke.md b/.changeset/tame-books-smoke.md new file mode 100644 index 00000000..3b649b9c --- /dev/null +++ b/.changeset/tame-books-smoke.md @@ -0,0 +1,5 @@ +--- +"@balancer/sdk": minor +--- + +added add and remove liquidity for boosted pools diff --git a/src/data/types.ts b/src/data/types.ts index 6633d10c..ac82229d 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -11,3 +11,7 @@ export interface MinimalToken { export interface PoolTokenWithBalance extends MinimalToken { balance: HumanAmount; } + +export interface PoolTokenWithUnderlying extends MinimalToken { + underlyingToken: MinimalToken; +} diff --git a/src/entities/addLiquidityBoosted/doAddLiquidityPropotionalQuery.ts b/src/entities/addLiquidityBoosted/doAddLiquidityPropotionalQuery.ts new file mode 100644 index 00000000..7b1778f3 --- /dev/null +++ b/src/entities/addLiquidityBoosted/doAddLiquidityPropotionalQuery.ts @@ -0,0 +1,29 @@ +import { createPublicClient, Hex, http } from 'viem'; + +import { BALANCER_COMPOSITE_LIQUIDITY_ROUTER, ChainId, CHAINS } from '@/utils'; + +import { Address } from '@/types'; + +import { balancerCompositeLiquidityRouterAbi } from '@/abi'; + +export const doAddLiquidityProportionalQuery = async ( + rpcUrl: string, + chainId: ChainId, + sender: Address, + userData: Hex, + poolAddress: Address, + exactBptAmountOut: bigint, +): Promise => { + const client = createPublicClient({ + transport: http(rpcUrl), + chain: CHAINS[chainId], + }); + + const { result: exactAmountsIn } = await client.simulateContract({ + address: BALANCER_COMPOSITE_LIQUIDITY_ROUTER[chainId], + abi: balancerCompositeLiquidityRouterAbi, + functionName: 'queryAddLiquidityProportionalToERC4626Pool', + args: [poolAddress, exactBptAmountOut, sender, userData], + }); + return [...exactAmountsIn]; +}; diff --git a/src/entities/addLiquidityBoosted/doAddLiquidityUnbalancedQuery.ts b/src/entities/addLiquidityBoosted/doAddLiquidityUnbalancedQuery.ts new file mode 100644 index 00000000..8a72a9b2 --- /dev/null +++ b/src/entities/addLiquidityBoosted/doAddLiquidityUnbalancedQuery.ts @@ -0,0 +1,29 @@ +import { createPublicClient, Hex, http } from 'viem'; + +import { BALANCER_COMPOSITE_LIQUIDITY_ROUTER, ChainId, CHAINS } from '@/utils'; + +import { Address } from '@/types'; + +import { balancerCompositeLiquidityRouterAbi } from '@/abi'; + +export const doAddLiquidityUnbalancedQuery = async ( + rpcUrl: string, + chainId: ChainId, + sender: Address, + userData: Hex, + poolAddress: Address, + exactUnderlyingAmountsIn: bigint[], +): Promise => { + const client = createPublicClient({ + transport: http(rpcUrl), + chain: CHAINS[chainId], + }); + + const { result: bptAmountOut } = await client.simulateContract({ + address: BALANCER_COMPOSITE_LIQUIDITY_ROUTER[chainId], + abi: balancerCompositeLiquidityRouterAbi, + functionName: 'queryAddLiquidityUnbalancedToERC4626Pool', + args: [poolAddress, exactUnderlyingAmountsIn, sender, userData], + }); + return bptAmountOut; +}; diff --git a/src/entities/addLiquidityBoosted/index.ts b/src/entities/addLiquidityBoosted/index.ts new file mode 100644 index 00000000..3b61883b --- /dev/null +++ b/src/entities/addLiquidityBoosted/index.ts @@ -0,0 +1,211 @@ +// A user can add liquidity to a boosted pool in various forms. The following ways are +// available: +// 1. Unbalanced - addLiquidityUnbalancedToERC4626Pool +// 2. Proportional - addLiquidityProportionalToERC4626Pool +import { encodeFunctionData, zeroAddress } from 'viem'; +import { TokenAmount } from '@/entities/tokenAmount'; + +import { Permit2 } from '@/entities/permit2Helper'; + +import { getAmountsCall } from '../addLiquidity/helpers'; + +import { PoolStateWithUnderlyings } from '@/entities/types'; + +import { getAmounts, getSortedTokens } from '@/entities/utils'; + +import { + AddLiquidityBuildCallOutput, + AddLiquidityKind, +} from '../addLiquidity/types'; + +import { doAddLiquidityUnbalancedQuery } from './doAddLiquidityUnbalancedQuery'; +import { doAddLiquidityProportionalQuery } from './doAddLiquidityPropotionalQuery'; +import { Token } from '../token'; +import { BALANCER_COMPOSITE_LIQUIDITY_ROUTER } from '@/utils'; +import { balancerCompositeLiquidityRouterAbi, balancerRouterAbi } from '@/abi'; + +import { InputValidator } from '../inputValidator/inputValidator'; + +import { Hex } from '@/types'; +import { + AddLiquidityBoostedBuildCallInput, + AddLiquidityBoostedInput, + AddLiquidityBoostedQueryOutput, +} from './types'; + +export class AddLiquidityBoostedV3 { + private readonly inputValidator: InputValidator = new InputValidator(); + + async query( + input: AddLiquidityBoostedInput, + poolState: PoolStateWithUnderlyings, + ): Promise { + this.inputValidator.validateAddLiquidityBoosted(input, { + ...poolState, + type: 'Boosted', + }); + + const bptToken = new Token(input.chainId, poolState.address, 18); + + let bptOut: TokenAmount; + let amountsIn: TokenAmount[]; + + switch (input.kind) { + case AddLiquidityKind.Unbalanced: { + // It is allowed not not provide the same amount of TokenAmounts as inputs + // as the pool has tokens, in this case, the input tokens are filled with + // a default value ( 0 in this case ) to assure correct amounts in as the pool has tokens. + const underlyingTokens = poolState.tokens.map((t) => { + return t.underlyingToken; + }); + const sortedTokens = getSortedTokens( + underlyingTokens, + input.chainId, + ); + const maxAmountsIn = getAmounts(sortedTokens, input.amountsIn); + + const bptAmountOut = await doAddLiquidityUnbalancedQuery( + input.rpcUrl, + input.chainId, + input.userAddress ?? zeroAddress, + input.userData ?? '0x', + poolState.address, + maxAmountsIn, + ); + bptOut = TokenAmount.fromRawAmount(bptToken, bptAmountOut); + + amountsIn = input.amountsIn.map((t) => { + return TokenAmount.fromRawAmount( + new Token(input.chainId, t.address, t.decimals), + t.rawAmount, + ); + }); + + break; + } + case AddLiquidityKind.Proportional: { + if (input.referenceAmount.address !== poolState.address) { + // TODO: add getBptAmountFromReferenceAmount + throw new Error('Reference token must be the pool token'); + } + + const exactAmountsInNumbers = + await doAddLiquidityProportionalQuery( + input.rpcUrl, + input.chainId, + input.userAddress ?? zeroAddress, + input.userData ?? '0x', + poolState.address, + input.referenceAmount.rawAmount, + ); + + // Since the user adds tokens which are technically not pool tokens, the TokenAmount to return + // uses the pool's tokens underlyingTokens to indicate which tokens are being added from the user + // perspective + amountsIn = poolState.tokens.map((t, i) => + TokenAmount.fromRawAmount( + new Token( + input.chainId, + t.underlyingToken.address, + t.underlyingToken.decimals, + ), + exactAmountsInNumbers[i], + ), + ); + + bptOut = TokenAmount.fromRawAmount( + bptToken, + input.referenceAmount.rawAmount, + ); + break; + } + } + + const output: AddLiquidityBoostedQueryOutput = { + poolId: poolState.id, + poolType: poolState.type, + addLiquidityKind: input.kind, + bptOut, + amountsIn, + chainId: input.chainId, + protocolVersion: 3, + userData: input.userData ?? '0x', + }; + + return output; + } + + buildCall( + input: AddLiquidityBoostedBuildCallInput, + ): AddLiquidityBuildCallOutput { + const amounts = getAmountsCall(input); + const args = [ + input.poolId, + amounts.maxAmountsIn, + amounts.minimumBpt, + false, + input.userData, + ] as const; + let callData: Hex; + switch (input.addLiquidityKind) { + case AddLiquidityKind.Unbalanced: { + callData = encodeFunctionData({ + abi: balancerCompositeLiquidityRouterAbi, + functionName: 'addLiquidityUnbalancedToERC4626Pool', + args, + }); + break; + } + case AddLiquidityKind.Proportional: { + callData = encodeFunctionData({ + abi: balancerCompositeLiquidityRouterAbi, + functionName: 'addLiquidityProportionalToERC4626Pool', + args, + }); + break; + } + case AddLiquidityKind.SingleToken: { + throw new Error('SingleToken not supported'); + } + } + return { + callData, + to: BALANCER_COMPOSITE_LIQUIDITY_ROUTER[input.chainId], + value: 0n, // Default to 0 as native not supported + minBptOut: TokenAmount.fromRawAmount( + input.bptOut.token, + amounts.minimumBpt, + ), + maxAmountsIn: input.amountsIn.map((a, i) => + TokenAmount.fromRawAmount(a.token, amounts.maxAmountsIn[i]), + ), + }; + } + + public buildCallWithPermit2( + input: AddLiquidityBoostedBuildCallInput, + permit2: Permit2, + ): AddLiquidityBuildCallOutput { + // generate same calldata as buildCall + const buildCallOutput = this.buildCall(input); + + const args = [ + [], + [], + permit2.batch, + permit2.signature, + [buildCallOutput.callData], + ] as const; + + const callData = encodeFunctionData({ + abi: balancerRouterAbi, + functionName: 'permitBatchAndCall', + args, + }); + + return { + ...buildCallOutput, + callData, + }; + } +} diff --git a/src/entities/addLiquidityBoosted/types.ts b/src/entities/addLiquidityBoosted/types.ts new file mode 100644 index 00000000..820b4e3a --- /dev/null +++ b/src/entities/addLiquidityBoosted/types.ts @@ -0,0 +1,42 @@ +import { InputAmount } from '@/types'; +import { AddLiquidityKind } from '../addLiquidity/types'; +import { Address, Hex } from 'viem'; +import { TokenAmount } from '../tokenAmount'; +import { Slippage } from '../slippage'; + +export type AddLiquidityBoostedProportionalInput = { + chainId: number; + rpcUrl: string; + referenceAmount: InputAmount; + kind: AddLiquidityKind.Proportional; + userAddress?: Address; + userData?: Hex; +}; + +export type AddLiquidityBoostedUnbalancedInput = { + chainId: number; + rpcUrl: string; + amountsIn: InputAmount[]; + kind: AddLiquidityKind.Unbalanced; + userAddress?: Address; + userData?: Hex; +}; + +export type AddLiquidityBoostedInput = + | AddLiquidityBoostedUnbalancedInput + | AddLiquidityBoostedProportionalInput; + +export type AddLiquidityBoostedQueryOutput = { + poolId: Hex; + poolType: string; + addLiquidityKind: AddLiquidityKind; + bptOut: TokenAmount; + amountsIn: TokenAmount[]; + chainId: number; + protocolVersion: 3; + userData: Hex; +}; + +export type AddLiquidityBoostedBuildCallInput = { + slippage: Slippage; +} & AddLiquidityBoostedQueryOutput; diff --git a/src/entities/index.ts b/src/entities/index.ts index 6ab9cc88..0f4e5aac 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -1,5 +1,7 @@ export * from './addLiquidity'; export * from './addLiquidity/types'; +export * from './addLiquidityBoosted'; +export * from './addLiquidityBoosted/types'; export * from './addLiquidityNested'; export * from './addLiquidityNested/types'; export * from './addLiquidityNested/addLiquidityNestedV2/types'; @@ -14,6 +16,8 @@ export * from './priceImpactAmount'; export * from './relayer'; export * from './removeLiquidity'; export * from './removeLiquidity/types'; +export * from './removeLiquidityBoosted'; +export * from './removeLiquidityBoosted/types'; export * from './removeLiquidityNested/index'; export * from './removeLiquidityNested/types'; export * from './removeLiquidityNested/removeLiquidityNestedV2'; diff --git a/src/entities/inputValidator/boosted/inputValidatorBoosted.ts b/src/entities/inputValidator/boosted/inputValidatorBoosted.ts new file mode 100644 index 00000000..d603e5c6 --- /dev/null +++ b/src/entities/inputValidator/boosted/inputValidatorBoosted.ts @@ -0,0 +1,34 @@ +import { PoolStateWithUnderlyings } from '@/entities/types'; +import { InputValidatorBase } from '../inputValidatorBase'; +import { AddLiquidityKind } from '@/entities/addLiquidity/types'; +import { AddLiquidityBoostedInput } from '@/entities/addLiquidityBoosted/types'; + +export class InputValidatorBoosted extends InputValidatorBase { + validateAddLiquidityBoosted( + addLiquidityInput: AddLiquidityBoostedInput, + poolState: PoolStateWithUnderlyings, + ): void { + //check if poolState.protocolVersion is 3 + if (poolState.protocolVersion !== 3) { + throw new Error('protocol version must be 3'); + } + + if (addLiquidityInput.kind === AddLiquidityKind.Unbalanced) { + // check if addLiquidityInput.amountsIn.address is contained in poolState.tokens.underlyingToken.address + const underlyingTokens = poolState.tokens.map((t) => + t.underlyingToken.address.toLowerCase(), + ); + addLiquidityInput.amountsIn.forEach((a) => { + if ( + !underlyingTokens.includes( + a.address.toLowerCase() as `0x${string}`, + ) + ) { + throw new Error( + `Address ${a.address} is not contained in the pool's underlying tokens.`, + ); + } + }); + } + } +} diff --git a/src/entities/inputValidator/inputValidator.ts b/src/entities/inputValidator/inputValidator.ts index 8e996289..eb14e176 100644 --- a/src/entities/inputValidator/inputValidator.ts +++ b/src/entities/inputValidator/inputValidator.ts @@ -6,14 +6,16 @@ import { RemoveLiquidityInput, RemoveLiquidityRecoveryInput, } from '../removeLiquidity/types'; -import { PoolState } from '../types'; +import { PoolState, PoolStateWithUnderlyings } from '../types'; import { InputValidatorComposableStable } from './composableStable/inputValidatorComposableStable'; import { InputValidatorCowAmm } from './cowAmm/inputValidatorCowAmm'; import { InputValidatorGyro } from './gyro/inputValidatorGyro'; import { InputValidatorStable } from './stable/inputValidatorStable'; import { InputValidatorBase } from './inputValidatorBase'; import { InputValidatorWeighted } from './weighted/inputValidatorWeighted'; +import { InputValidatorBoosted } from './boosted/inputValidatorBoosted'; import { ChainId, buildCallWithPermit2ProtocolVersionError } from '@/utils'; +import { AddLiquidityBoostedInput } from '../addLiquidityBoosted/types'; export class InputValidator { validators: Record = {}; @@ -28,6 +30,7 @@ export class InputValidator { [PoolType.MetaStable]: new InputValidatorStable(), [PoolType.Stable]: new InputValidatorStable(), [PoolType.Weighted]: new InputValidatorWeighted(), + [PoolType.Boosted]: new InputValidatorBoosted(), }; } @@ -87,6 +90,20 @@ export class InputValidator { this.getValidator(input.poolType).validateCreatePool(input); } + validateAddLiquidityBoosted( + addLiquidityInput: AddLiquidityBoostedInput, + poolState: PoolStateWithUnderlyings, + ): void { + if (poolState.type !== PoolType.Boosted) + throw new Error( + `validateAddLiquidityBoosted on non boosted pool: ${poolState.address}:${poolState.type}`, + ); + this.validateChain(addLiquidityInput.chainId); + ( + this.validators[PoolType.Boosted] as InputValidatorBoosted + ).validateAddLiquidityBoosted(addLiquidityInput, poolState); + } + private validateChain(chainId: number): void { if (chainId in ChainId) return; throw new Error(`Unsupported ChainId: ${chainId}`); diff --git a/src/entities/permit2Helper/index.ts b/src/entities/permit2Helper/index.ts index a39af5e6..9927e817 100644 --- a/src/entities/permit2Helper/index.ts +++ b/src/entities/permit2Helper/index.ts @@ -114,6 +114,45 @@ export class Permit2Helper { return signPermit2(input.client, input.owner, spender, details); } + static async signAddLiquidityBoostedApproval( + input: AddLiquidityBaseBuildCallInput & { + client: PublicWalletClient; + owner: Address; + nonces?: number[]; + expirations?: number[]; + }, + ): Promise { + if (input.nonces && input.nonces.length !== input.amountsIn.length) { + throw new Error("Nonces length doesn't match amountsIn length"); + } + if ( + input.expirations && + input.expirations.length !== input.amountsIn.length + ) { + throw new Error( + "Expirations length doesn't match amountsIn length", + ); + } + const amounts = getAmountsCall(input); + const spender = BALANCER_COMPOSITE_LIQUIDITY_ROUTER[input.chainId]; + const details: PermitDetails[] = []; + + for (let i = 0; i < input.amountsIn.length; i++) { + details.push( + await getDetails( + input.client, + input.amountsIn[i].token.address, + input.owner, + spender, + amounts.maxAmountsIn[i], + input.expirations ? input.expirations[i] : undefined, + input.nonces ? input.nonces[i] : undefined, + ), + ); + } + return signPermit2(input.client, input.owner, spender, details); + } + static async signSwapApproval( input: SwapBuildCallInputBase & { client: PublicWalletClient; diff --git a/src/entities/permitHelper/index.ts b/src/entities/permitHelper/index.ts index d042588e..2ade8915 100644 --- a/src/entities/permitHelper/index.ts +++ b/src/entities/permitHelper/index.ts @@ -87,6 +87,34 @@ export class PermitHelper { ); return { batch: [permitApproval], signatures: [permitSignature] }; }; + + static signRemoveLiquidityBoostedApproval = async ( + input: RemoveLiquidityBaseBuildCallInput & { + client: PublicWalletClient; + owner: Hex; + nonce?: bigint; + deadline?: bigint; + }, + ): Promise => { + const amounts = getAmountsCall(input); + const nonce = + input.nonce ?? + (await getNonce( + input.client, + input.bptIn.token.address, + input.owner, + )); + const { permitApproval, permitSignature } = await signPermit( + input.client, + input.bptIn.token.address, + input.owner, + BALANCER_COMPOSITE_LIQUIDITY_ROUTER[input.chainId], + nonce, + amounts.maxBptAmountIn, + input.deadline, + ); + return { batch: [permitApproval], signatures: [permitSignature] }; + }; } /** diff --git a/src/entities/removeLiquidityBoosted/doRemoveLiquidityProportionalQuery.ts b/src/entities/removeLiquidityBoosted/doRemoveLiquidityProportionalQuery.ts new file mode 100644 index 00000000..0ef356db --- /dev/null +++ b/src/entities/removeLiquidityBoosted/doRemoveLiquidityProportionalQuery.ts @@ -0,0 +1,27 @@ +import { createPublicClient, Hex, http } from 'viem'; +import { BALANCER_COMPOSITE_LIQUIDITY_ROUTER, ChainId, CHAINS } from '@/utils'; +import { Address } from '@/types'; +import { balancerCompositeLiquidityRouterAbi } from '@/abi'; + +export const doRemoveLiquidityProportionalQuery = async ( + rpcUrl: string, + chainId: ChainId, + exactBptAmountIn: bigint, + sender: Address, + userData: Hex, + poolAddress: Address, +): Promise => { + const client = createPublicClient({ + transport: http(rpcUrl), + chain: CHAINS[chainId], + }); + + const { result: underlyingAmountsOut } = await client.simulateContract({ + address: BALANCER_COMPOSITE_LIQUIDITY_ROUTER[chainId], + abi: balancerCompositeLiquidityRouterAbi, + functionName: 'queryRemoveLiquidityProportionalFromERC4626Pool', + args: [poolAddress, exactBptAmountIn, sender, userData], + }); + // underlying amounts (not pool token amounts) + return [...underlyingAmountsOut]; +}; diff --git a/src/entities/removeLiquidityBoosted/index.ts b/src/entities/removeLiquidityBoosted/index.ts new file mode 100644 index 00000000..3c975366 --- /dev/null +++ b/src/entities/removeLiquidityBoosted/index.ts @@ -0,0 +1,142 @@ +import { encodeFunctionData, zeroAddress } from 'viem'; + +import { + RemoveLiquidityBase, + RemoveLiquidityKind, + RemoveLiquidityBuildCallOutput, + RemoveLiquidityQueryOutput, + RemoveLiquidityRecoveryInput, +} from '../removeLiquidity/types'; + +import { Permit } from '@/entities/permitHelper'; + +import { balancerCompositeLiquidityRouterAbi } from '@/abi'; + +import { PoolState, PoolStateWithUnderlyings } from '@/entities/types'; + +import { TokenAmount } from '@/entities/tokenAmount'; +import { Token } from '@/entities/token'; + +import { getAmountsCall } from '../removeLiquidity/helper'; + +import { doRemoveLiquidityProportionalQuery } from './doRemoveLiquidityProportionalQuery'; +import { BALANCER_COMPOSITE_LIQUIDITY_ROUTER } from '@/utils'; +import { + RemoveLiquidityBoostedBuildCallInput, + RemoveLiquidityBoostedProportionalInput, + RemoveLiquidityBoostedQueryOutput, +} from './types'; +import { InputValidator } from '../inputValidator/inputValidator'; + +export class RemoveLiquidityBoostedV3 implements RemoveLiquidityBase { + private readonly inputValidator: InputValidator = new InputValidator(); + + public async queryRemoveLiquidityRecovery( + _input: RemoveLiquidityRecoveryInput, + _poolState: PoolState, + ): Promise { + throw new Error('Not implemented'); + } + + public async query( + input: RemoveLiquidityBoostedProportionalInput, + poolState: PoolStateWithUnderlyings, + ): Promise { + this.inputValidator.validateRemoveLiquidity(input, { + ...poolState, + type: 'Boosted', + }); + const underlyingAmountsOut = await doRemoveLiquidityProportionalQuery( + input.rpcUrl, + input.chainId, + input.bptIn.rawAmount, + input.userAddress ?? zeroAddress, + input.userData ?? '0x', + poolState.address, + ); + + // amountsOut are in underlying Tokens sorted in token registration order of wrapped tokens in the pool + const amountsOut = underlyingAmountsOut.map((amount, i) => { + const token = new Token( + input.chainId, + poolState.tokens[i].underlyingToken.address, + poolState.tokens[i].underlyingToken.decimals, + ); + return TokenAmount.fromRawAmount(token, amount); + }); + + const bptToken = new Token(input.chainId, poolState.address, 18); + + const output: RemoveLiquidityBoostedQueryOutput = { + poolType: poolState.type, + poolId: poolState.address, + removeLiquidityKind: RemoveLiquidityKind.Proportional, + bptIn: TokenAmount.fromRawAmount(bptToken, input.bptIn.rawAmount), + amountsOut: amountsOut, + protocolVersion: 3, + chainId: input.chainId, + userData: input.userData ?? '0x', + }; + return output; + } + + public buildCall( + input: RemoveLiquidityBoostedBuildCallInput, + ): RemoveLiquidityBuildCallOutput { + // Apply slippage to amounts shared in put depending on the kind + // In this case, the user is willing to accept a slightly lower + // amount of tokens out, depending on slippage + const amounts = getAmountsCall(input); + + const callData = encodeFunctionData({ + abi: balancerCompositeLiquidityRouterAbi, + functionName: 'removeLiquidityProportionalFromERC4626Pool', + args: [ + input.poolId, + input.bptIn.amount, + amounts.minAmountsOut, + false, + input.userData, + ], + }); + + return { + callData: callData, + to: BALANCER_COMPOSITE_LIQUIDITY_ROUTER[input.chainId], + value: 0n, // always has 0 value + maxBptIn: input.bptIn, //TokenAmount + minAmountsOut: amounts.minAmountsOut.map((amount, i) => { + return TokenAmount.fromRawAmount( + input.amountsOut[i].token, + amount, + ); + }), + }; + } + + public buildCallWithPermit( + input: RemoveLiquidityBoostedBuildCallInput, + permit: Permit, + ): RemoveLiquidityBuildCallOutput { + const buildCallOutput = this.buildCall(input); + + const args = [ + permit.batch, + permit.signatures, + { details: [], spender: zeroAddress, sigDeadline: 0n }, + '0x', + [buildCallOutput.callData], + ] as const; + + const callData = encodeFunctionData({ + abi: balancerCompositeLiquidityRouterAbi, + functionName: 'permitBatchAndCall', + args, + }); + + return { + ...buildCallOutput, + callData, + }; + } +} diff --git a/src/entities/removeLiquidityBoosted/types.ts b/src/entities/removeLiquidityBoosted/types.ts new file mode 100644 index 00000000..1310ddf7 --- /dev/null +++ b/src/entities/removeLiquidityBoosted/types.ts @@ -0,0 +1,37 @@ +import { Address, Hex } from 'viem'; +import { InputAmount } from '@/types'; +import { RemoveLiquidityKind } from '../removeLiquidity/types'; +import { TokenAmount } from '../tokenAmount'; +import { Slippage } from '../slippage'; + +export type RemoveLiquidityBoostedProportionalInput = { + chainId: number; + rpcUrl: string; + bptIn: InputAmount; + kind: RemoveLiquidityKind.Proportional; + userAddress?: Address; + userData?: Hex; +}; + +export type RemoveLiquidityBoostedQueryOutput = { + poolType: string; + poolId: Address; + removeLiquidityKind: RemoveLiquidityKind.Proportional; + bptIn: TokenAmount; + amountsOut: TokenAmount[]; + protocolVersion: 3; + chainId: number; + userData: Hex; +}; + +export type RemoveLiquidityBoostedBuildCallInput = { + userData: Hex; + poolType: string; + poolId: Address; + removeLiquidityKind: RemoveLiquidityKind.Proportional; + bptIn: TokenAmount; + amountsOut: TokenAmount[]; + protocolVersion: 3; + chainId: number; + slippage: Slippage; +}; diff --git a/src/entities/types.ts b/src/entities/types.ts index 79e5deb0..74dd96c6 100644 --- a/src/entities/types.ts +++ b/src/entities/types.ts @@ -1,4 +1,9 @@ -import { HumanAmount, MinimalToken, PoolTokenWithBalance } from '../data'; +import { + HumanAmount, + MinimalToken, + PoolTokenWithBalance, + PoolTokenWithUnderlying, +} from '../data'; import { Address, Hex, PoolType } from '../types'; // Returned from API and used as input @@ -19,6 +24,15 @@ export type PoolStateWithBalances = { protocolVersion: 1 | 2 | 3; }; +export type PoolStateWithUnderlyings = { + id: Hex; + address: Address; + type: string; + tokens: PoolTokenWithUnderlying[]; + totalShares: HumanAmount; + protocolVersion: 1 | 2 | 3; +}; + export type AddLiquidityAmounts = { maxAmountsIn: bigint[]; maxAmountsInWithoutBpt: bigint[]; diff --git a/src/types.ts b/src/types.ts index edacda8a..fe06ebad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,6 +12,7 @@ export enum PoolType { MetaStable = 'MetaStable', Stable = 'Stable', Weighted = 'Weighted', + Boosted = 'Boosted', } export enum SwapKind { diff --git a/test/anvil/anvil-global-setup.ts b/test/anvil/anvil-global-setup.ts index b1ef0701..03daefd3 100644 --- a/test/anvil/anvil-global-setup.ts +++ b/test/anvil/anvil-global-setup.ts @@ -66,7 +66,7 @@ export const ANVIL_NETWORKS: Record = { rpcEnv: 'SEPOLIA_RPC_URL', fallBackRpc: 'https://sepolia.gateway.tenderly.co', port: ANVIL_PORTS.SEPOLIA, - forkBlockNumber: 6940180n, + forkBlockNumber: 6947990n, }, OPTIMISM: { rpcEnv: 'OPTIMISM_RPC_URL', diff --git a/test/lib/utils/addLiquidityBoostedHelper.ts b/test/lib/utils/addLiquidityBoostedHelper.ts new file mode 100644 index 00000000..04199588 --- /dev/null +++ b/test/lib/utils/addLiquidityBoostedHelper.ts @@ -0,0 +1,17 @@ +import { Token } from '@/entities'; + +export const assertTokenMatch = ( + tokenDefined: Token[], + tokenReturned: Token[], +) => { + tokenDefined.map((tokenAmount) => { + expect( + tokenReturned.some( + (token) => token.address === tokenAmount.address, + ), + ).to.be.true; + }); + tokenDefined.map((a, i) => { + expect(a.decimals).to.eq(tokenReturned[i].decimals); + }); +}; diff --git a/test/lib/utils/index.ts b/test/lib/utils/index.ts index 6c4b4ecf..5820b2b0 100644 --- a/test/lib/utils/index.ts +++ b/test/lib/utils/index.ts @@ -12,3 +12,4 @@ export * from './removeLiquidityHelper'; export * from './removeLiquidityRecoveryHelper'; export * from './swapHelpers'; export * from './types'; +export * from './addLiquidityBoostedHelper'; diff --git a/test/v3/addLiquidityBoosted.integration.test.ts b/test/v3/addLiquidityBoosted.integration.test.ts new file mode 100644 index 00000000..b7689779 --- /dev/null +++ b/test/v3/addLiquidityBoosted.integration.test.ts @@ -0,0 +1,495 @@ +// pnpm test -- v3/addLiquidityBoosted.integration.test.ts + +import { config } from 'dotenv'; +config(); + +import { + Address, + createTestClient, + http, + parseUnits, + publicActions, + TestActions, + walletActions, +} from 'viem'; + +import { + AddLiquidityKind, + Slippage, + Hex, + PoolStateWithUnderlyings, + CHAINS, + ChainId, + AddLiquidityBoostedV3, + Permit2Helper, + PERMIT2, + Token, + PublicWalletClient, + AddLiquidityBoostedBuildCallInput, + AddLiquidityBoostedInput, +} from '../../src'; +import { + setTokenBalances, + approveSpenderOnTokens, + approveTokens, + areBigIntsWithinPercent, +} from '../lib/utils'; + +import { assertTokenMatch } from 'test/lib/utils'; + +import { sendTransactionGetBalances } from 'test/lib/utils'; + +import { ANVIL_NETWORKS, startFork } from '../anvil/anvil-global-setup'; + +const protocolVersion = 3; + +const chainId = ChainId.SEPOLIA; +// deploy 10 +const poolid = '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8'; +// const stataUSDC = 0x8a88124522dbbf1e56352ba3de1d9f78c143751e; +const USDC = { + address: '0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8', + decimals: 6, + slot: 0, +}; +//const statAUSDT = 0x978206fae13faf5a8d293fb614326b237684b750; +const USDT = { + address: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', + decimals: 6, + slot: 0, +}; + +describe('Boosted AddLiquidity', () => { + let client: PublicWalletClient & TestActions; + let rpcUrl: string; + let snapshot: Hex; + let testAddress: Address; + const addLiquidityBoosted = new AddLiquidityBoostedV3(); + + beforeAll(async () => { + ({ rpcUrl } = await startFork(ANVIL_NETWORKS[ChainId[chainId]])); + + client = createTestClient({ + mode: 'anvil', + chain: CHAINS[chainId], + transport: http(rpcUrl), + }) + .extend(publicActions) + .extend(walletActions); + + testAddress = (await client.getAddresses())[0]; + + await setTokenBalances( + client, + testAddress, + [USDT.address, USDC.address] as Address[], + [USDT.slot, USDC.slot] as number[], + [ + parseUnits('100', USDT.decimals), + parseUnits('100', USDC.decimals), + ], + ); + + // approve Permit2 to spend users DAI/USDC + // does not include the sub approvals + await approveSpenderOnTokens( + client, + testAddress, + [USDT.address, USDC.address] as Address[], + PERMIT2[chainId], + ); + + snapshot = await client.snapshot(); + }); + + beforeEach(async () => { + await client.revert({ + id: snapshot, + }); + snapshot = await client.snapshot(); + }); + + describe('permit 2 direct approval', () => { + beforeEach(async () => { + // Here We approve the Vault to spend Tokens on the users behalf via Permit2 + await approveTokens( + client, + testAddress as Address, + [USDT.address, USDC.address] as Address[], + protocolVersion, + ); + }); + describe('add liquidity unbalanced', () => { + test('with tokens', async () => { + const input: AddLiquidityBoostedInput = { + chainId, + rpcUrl, + amountsIn: [ + { + rawAmount: 1000000n, + decimals: 6, + address: USDC.address as Address, + }, + { + rawAmount: 1000000n, + decimals: 6, + address: USDT.address as Address, + }, + ], + kind: AddLiquidityKind.Unbalanced, + userData: '0x', + }; + + const addLiquidityQueryOutput = await addLiquidityBoosted.query( + input, + poolStateWithUnderlyings, + ); + + const addLiquidityBuildInput: AddLiquidityBoostedBuildCallInput = + { + ...addLiquidityQueryOutput, + slippage: Slippage.fromPercentage('1'), + }; + + const addLiquidityBuildCallOutput = + await addLiquidityBoosted.buildCall(addLiquidityBuildInput); + + const { transactionReceipt, balanceDeltas } = + await sendTransactionGetBalances( + [ + addLiquidityQueryOutput.bptOut.token.address, + USDC.address as `0x${string}`, + USDT.address as `0x${string}`, + ], + client, + testAddress, + addLiquidityBuildCallOutput.to, + addLiquidityBuildCallOutput.callData, + ); + + expect(transactionReceipt.status).to.eq('success'); + + expect(addLiquidityQueryOutput.bptOut.amount > 0n).to.be.true; + + areBigIntsWithinPercent( + addLiquidityQueryOutput.bptOut.amount, + balanceDeltas[0], + 0.001, + ); + + const slippageAdjustedQueryOutput = Slippage.fromPercentage( + '1', + ).applyTo(addLiquidityQueryOutput.bptOut.amount, -1); + + expect( + slippageAdjustedQueryOutput === + addLiquidityBuildCallOutput.minBptOut.amount, + ).to.be.true; + }); + test('with native', async () => { + // TODO + }); + }); + describe('add liquidity proportional', () => { + test('with tokens', async () => { + const addLiquidityProportionalInput: AddLiquidityBoostedInput = + { + chainId, + rpcUrl, + referenceAmount: { + rawAmount: 1000000000000000000n, + decimals: 18, + address: poolid as Address, + }, + kind: AddLiquidityKind.Proportional, + }; + const addLiquidityQueryOutput = await addLiquidityBoosted.query( + addLiquidityProportionalInput, + poolStateWithUnderlyings, + ); + const addLiquidityBuildInput: AddLiquidityBoostedBuildCallInput = + { + ...addLiquidityQueryOutput, + slippage: Slippage.fromPercentage('1'), + }; + + const addLiquidityBuildCallOutput = + await addLiquidityBoosted.buildCall(addLiquidityBuildInput); + + const { transactionReceipt, balanceDeltas } = + await sendTransactionGetBalances( + [ + addLiquidityQueryOutput.bptOut.token.address, + USDC.address as `0x${string}`, + USDT.address as `0x${string}`, + ], + client, + testAddress, + addLiquidityBuildCallOutput.to, // + addLiquidityBuildCallOutput.callData, + ); + + expect(transactionReceipt.status).to.eq('success'); + + addLiquidityQueryOutput.amountsIn.map((a) => { + expect(a.amount > 0n).to.be.true; + }); + + const expectedDeltas = [ + addLiquidityProportionalInput.referenceAmount.rawAmount, + ...addLiquidityQueryOutput.amountsIn.map( + (tokenAmount) => tokenAmount.amount, + ), + ]; + expect(balanceDeltas).to.deep.eq(expectedDeltas); + + const slippageAdjustedQueryInput = + addLiquidityQueryOutput.amountsIn.map((amountsIn) => { + return Slippage.fromPercentage('1').applyTo( + amountsIn.amount, + 1, + ); + }); + expect( + addLiquidityBuildCallOutput.maxAmountsIn.map( + (a) => a.amount, + ), + ).to.deep.eq(slippageAdjustedQueryInput); + + // make sure to pass Tokens in correct order. Same as poolTokens but as underlyings instead + assertTokenMatch( + [ + new Token( + 111555111, + USDC.address as Address, + USDC.decimals, + ), + new Token( + 111555111, + USDT.address as Address, + USDT.decimals, + ), + ], + addLiquidityBuildCallOutput.maxAmountsIn.map( + (a) => a.token, + ), + ); + }); + test('with native', async () => { + // TODO + }); + }); + }); + + describe('permit 2 signatures', () => { + describe('add liquidity unbalanced', () => { + test('token inputs', async () => { + const input: AddLiquidityBoostedInput = { + chainId, + rpcUrl, + amountsIn: [ + { + rawAmount: 1000000n, + decimals: 6, + address: USDC.address as Address, + }, + { + rawAmount: 1000000n, + decimals: 6, + address: USDT.address as Address, + }, + ], + kind: AddLiquidityKind.Unbalanced, + }; + + const addLiquidityQueryOutput = await addLiquidityBoosted.query( + input, + poolStateWithUnderlyings, + ); + + const addLiquidityBuildInput = { + ...addLiquidityQueryOutput, + slippage: Slippage.fromPercentage('1'), + } as AddLiquidityBoostedBuildCallInput; + + const permit2 = + await Permit2Helper.signAddLiquidityBoostedApproval({ + ...addLiquidityBuildInput, + client, + owner: testAddress, + }); + + const addLiquidityBuildCallOutput = + await addLiquidityBoosted.buildCallWithPermit2( + addLiquidityBuildInput, + permit2, + ); + + const { transactionReceipt, balanceDeltas } = + await sendTransactionGetBalances( + [ + addLiquidityQueryOutput.bptOut.token.address, + USDC.address as `0x${string}`, + USDT.address as `0x${string}`, + ], + client, + testAddress, + addLiquidityBuildCallOutput.to, + addLiquidityBuildCallOutput.callData, + ); + + expect(transactionReceipt.status).to.eq('success'); + + expect(addLiquidityQueryOutput.bptOut.amount > 0n).to.be.true; + + areBigIntsWithinPercent( + addLiquidityQueryOutput.bptOut.amount, + balanceDeltas[0], + 0.001, + ); + + const slippageAdjustedQueryOutput = Slippage.fromPercentage( + '1', + ).applyTo(addLiquidityQueryOutput.bptOut.amount, -1); + + expect( + slippageAdjustedQueryOutput === + addLiquidityBuildCallOutput.minBptOut.amount, + ).to.be.true; + }); + test('with native', async () => { + // TODO + }); + }); + describe('add liquidity proportional', () => { + test('token inputs', async () => { + const addLiquidityProportionalInput: AddLiquidityBoostedInput = + { + chainId, + rpcUrl, + referenceAmount: { + rawAmount: 1000000000000000000n, + decimals: 18, + address: poolid as Address, + }, + kind: AddLiquidityKind.Proportional, + }; + + const addLiquidityQueryOutput = await addLiquidityBoosted.query( + addLiquidityProportionalInput, + poolStateWithUnderlyings, + ); + const addLiquidityBuildInput: AddLiquidityBoostedBuildCallInput = + { + ...addLiquidityQueryOutput, + slippage: Slippage.fromPercentage('1'), + }; + + const permit2 = + await Permit2Helper.signAddLiquidityBoostedApproval({ + ...addLiquidityBuildInput, + client, + owner: testAddress, + }); + + const addLiquidityBuildCallOutput = + await addLiquidityBoosted.buildCallWithPermit2( + addLiquidityBuildInput, + permit2, + ); + + const { transactionReceipt, balanceDeltas } = + await sendTransactionGetBalances( + [ + addLiquidityQueryOutput.bptOut.token.address, + USDC.address as `0x${string}`, + USDT.address as `0x${string}`, + ], + client, + testAddress, + addLiquidityBuildCallOutput.to, // + addLiquidityBuildCallOutput.callData, + ); + + expect(transactionReceipt.status).to.eq('success'); + + addLiquidityQueryOutput.amountsIn.map((a) => { + expect(a.amount > 0n).to.be.true; + }); + + const expectedDeltas = [ + addLiquidityProportionalInput.referenceAmount.rawAmount, + ...addLiquidityQueryOutput.amountsIn.map( + (tokenAmount) => tokenAmount.amount, + ), + ]; + expect(balanceDeltas).to.deep.eq(expectedDeltas); + + const slippageAdjustedQueryInput = + addLiquidityQueryOutput.amountsIn.map((amountsIn) => { + return Slippage.fromPercentage('1').applyTo( + amountsIn.amount, + 1, + ); + }); + expect( + addLiquidityBuildCallOutput.maxAmountsIn.map( + (a) => a.amount, + ), + ).to.deep.eq(slippageAdjustedQueryInput); + + // make sure to pass Tokens in correct order. Same as poolTokens but as underlyings instead + assertTokenMatch( + [ + new Token( + 111555111, + USDC.address as Address, + USDC.decimals, + ), + new Token( + 111555111, + USDT.address as Address, + USDT.decimals, + ), + ], + addLiquidityBuildCallOutput.maxAmountsIn.map( + (a) => a.token, + ), + ); + }); + test('with native', async () => { + // TODO + }); + }); + }); + + const poolStateWithUnderlyings: PoolStateWithUnderlyings = { + id: '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8', + address: '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8', + type: 'Stable', + protocolVersion: 3, + tokens: [ + { + index: 0, + address: '0x8a88124522dbbf1e56352ba3de1d9f78c143751e', + decimals: 6, + underlyingToken: { + address: '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8', + decimals: 6, + index: 0, + }, + }, + { + index: 1, + address: '0x978206fae13faf5a8d293fb614326b237684b750', + decimals: 6, + underlyingToken: { + address: '0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0', + decimals: 6, + index: 1, + }, + }, + ], + totalShares: '119755.048508537457614083', + }; +}); diff --git a/test/v3/removeLiquidityBoosted.integration.test.ts b/test/v3/removeLiquidityBoosted.integration.test.ts new file mode 100644 index 00000000..9c1a42ed --- /dev/null +++ b/test/v3/removeLiquidityBoosted.integration.test.ts @@ -0,0 +1,385 @@ +// pnpm test -- v3/removeLiquidityBoosted.integration.test.ts + +import { config } from 'dotenv'; +config(); + +import { + Address, + createTestClient, + http, + parseUnits, + publicActions, + TestActions, + walletActions, +} from 'viem'; + +import { + AddLiquidityProportionalInput, + AddLiquidityKind, + RemoveLiquidityKind, + Slippage, + Hex, + PoolStateWithUnderlyings, + CHAINS, + ChainId, + AddLiquidityInput, + PERMIT2, + Token, + PublicWalletClient, + AddLiquidityBoostedV3, + RemoveLiquidityBoostedV3, + BALANCER_COMPOSITE_LIQUIDITY_ROUTER, + RemoveLiquidityBoostedProportionalInput, +} from '../../src'; +import { + AddLiquidityTxInput, + doAddLiquidity, + setTokenBalances, + approveSpenderOnTokens, + approveTokens, +} from '../lib/utils'; + +import { PermitHelper } from 'src'; + +import { sendTransactionGetBalances } from 'test/lib/utils'; +import { assertTokenMatch } from 'test/lib/utils'; + +import { ANVIL_NETWORKS, startFork } from '../anvil/anvil-global-setup'; + +const protocolVersion = 3; + +const chainId = ChainId.SEPOLIA; +// deploy 10 +const poolid = '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8'; +// const stataUSDC = 0x8a88124522dbbf1e56352ba3de1d9f78c143751e; +const USDC = { + address: '0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8', + decimals: 6, + slot: 0, +}; +//const statAUSDT = 0x978206fae13faf5a8d293fb614326b237684b750; +const USDT = { + address: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', + decimals: 6, + slot: 0, +}; + +describe('remove liquidity test', () => { + let client: PublicWalletClient & TestActions; + let txInput: AddLiquidityTxInput; + let rpcUrl: string; + let snapshot: Hex; + let testAddress: Address; + + beforeAll(async () => { + ({ rpcUrl } = await startFork(ANVIL_NETWORKS[ChainId[chainId]])); + + client = createTestClient({ + mode: 'anvil', + chain: CHAINS[chainId], + transport: http(rpcUrl), + }) + .extend(publicActions) + .extend(walletActions); + + testAddress = (await client.getAddresses())[0]; + + await setTokenBalances( + client, + testAddress, + [USDT.address, USDC.address] as Address[], + [USDT.slot, USDC.slot] as number[], + [ + parseUnits('100', USDT.decimals), + parseUnits('100', USDC.decimals), + ], + ); + + // approve Permit2 to spend users DAI/USDC + // does not include the sub approvals + await approveSpenderOnTokens( + client, + testAddress, + [USDT.address, USDC.address] as Address[], + PERMIT2[chainId], + ); + + snapshot = await client.snapshot(); + }); + + beforeEach(async () => { + await client.revert({ + id: snapshot, + }); + snapshot = await client.snapshot(); + + // subapprovals for permit2 to the vault + // fine to do before each because it does not impact the + // requirement for BPT permits. (which are permits, not permit2) + // Here We approve the Vault to spend Tokens on the users behalf via Permit2 + await approveTokens( + client, + testAddress as Address, + [USDT.address, USDC.address] as Address[], + protocolVersion, + ); + + // join the pool - via direct approval + const slippage: Slippage = Slippage.fromPercentage('1'); + + txInput = { + client, + addLiquidity: new AddLiquidityBoostedV3(), + slippage: slippage, + poolState: poolStateWithUnderlyings, + testAddress, + addLiquidityInput: {} as AddLiquidityInput, + }; + + const input: AddLiquidityProportionalInput = { + chainId: chainId, + rpcUrl: rpcUrl, + referenceAmount: { + rawAmount: 1000000000000000000n, + decimals: 18, + address: poolid as Address, + }, + kind: AddLiquidityKind.Proportional, + }; + + const _addLiquidityOutput = await doAddLiquidity({ + ...txInput, + addLiquidityInput: input, + }); + }); + describe('direct approval', () => { + beforeEach(async () => { + // Approve the Composite liquidity router. + await approveSpenderOnTokens( + client, + testAddress, + [poolid] as Address[], + BALANCER_COMPOSITE_LIQUIDITY_ROUTER[chainId], + ); + }); + test('remove liquidity proportional', async () => { + const removeLiquidityBoostedV3 = new RemoveLiquidityBoostedV3(); + + const removeLiquidityInput: RemoveLiquidityBoostedProportionalInput = + { + chainId: chainId, + rpcUrl: rpcUrl, + bptIn: { + rawAmount: 1000000000000000000n, + decimals: 18, + address: poolStateWithUnderlyings.address, + }, + kind: RemoveLiquidityKind.Proportional, + }; + + const removeLiquidityQueryOutput = + await removeLiquidityBoostedV3.query( + removeLiquidityInput, + poolStateWithUnderlyings, + ); + + const removeLiquidityBuildInput = { + ...removeLiquidityQueryOutput, + slippage: Slippage.fromPercentage('1'), + }; + + const removeLiquidityBuildCallOutput = + removeLiquidityBoostedV3.buildCall(removeLiquidityBuildInput); + + const { transactionReceipt, balanceDeltas } = + await sendTransactionGetBalances( + [ + poolStateWithUnderlyings.address, + USDC.address as `0x${string}`, + USDT.address as `0x${string}`, + ], + client, + testAddress, + removeLiquidityBuildCallOutput.to, + removeLiquidityBuildCallOutput.callData, + ); + expect(transactionReceipt.status).to.eq('success'); + expect( + removeLiquidityQueryOutput.amountsOut.map((amount) => { + expect(amount.amount > 0).to.be.true; + }), + ); + + const expectedDeltas = [ + removeLiquidityQueryOutput.bptIn.amount, + ...removeLiquidityQueryOutput.amountsOut.map( + (amountOut) => amountOut.amount, + ), + ]; + expect(expectedDeltas).to.deep.eq(balanceDeltas); + const expectedMinAmountsOut = + removeLiquidityQueryOutput.amountsOut.map((amountOut) => + removeLiquidityBuildInput.slippage.applyTo( + amountOut.amount, + -1, + ), + ); + expect(expectedMinAmountsOut).to.deep.eq( + removeLiquidityBuildCallOutput.minAmountsOut.map( + (a) => a.amount, + ), + ); + + // make sure to pass Tokens in correct order. Same as poolTokens but as underlyings instead + assertTokenMatch( + [ + new Token( + 111555111, + USDC.address as Address, + USDC.decimals, + ), + new Token( + 111555111, + USDT.address as Address, + USDT.decimals, + ), + ], + removeLiquidityBuildCallOutput.minAmountsOut.map( + (a) => a.token, + ), + ); + }); + }); + describe('permit approval', () => { + test('remove liquidity proportional', async () => { + const removeLiquidityBoostedV3 = new RemoveLiquidityBoostedV3(); + + const removeLiquidityInput: RemoveLiquidityBoostedProportionalInput = + { + chainId: chainId, + rpcUrl: rpcUrl, + bptIn: { + rawAmount: 1000000000000000000n, + decimals: 18, + address: poolStateWithUnderlyings.address, + }, + kind: RemoveLiquidityKind.Proportional, + userAddress: testAddress, + userData: '0x123', + }; + + const removeLiquidityQueryOutput = + await removeLiquidityBoostedV3.query( + removeLiquidityInput, + poolStateWithUnderlyings, + ); + + const removeLiquidityBuildInput = { + ...removeLiquidityQueryOutput, + slippage: Slippage.fromPercentage('1'), + }; + + // + const permit = + await PermitHelper.signRemoveLiquidityBoostedApproval({ + ...removeLiquidityBuildInput, + client, + owner: testAddress, + }); + + const removeLiquidityBuildCallOutput = + removeLiquidityBoostedV3.buildCallWithPermit( + removeLiquidityBuildInput, + permit, + ); + + const { transactionReceipt, balanceDeltas } = + await sendTransactionGetBalances( + [ + poolStateWithUnderlyings.address, + USDC.address as `0x${string}`, + USDT.address as `0x${string}`, + ], + client, + testAddress, + removeLiquidityBuildCallOutput.to, + removeLiquidityBuildCallOutput.callData, + ); + + expect(transactionReceipt.status).to.eq('success'); + expect( + removeLiquidityQueryOutput.amountsOut.map((amount) => { + expect(amount.amount > 0).to.be.true; + }), + ); + + const expectedDeltas = [ + removeLiquidityQueryOutput.bptIn.amount, + ...removeLiquidityQueryOutput.amountsOut.map( + (amountOut) => amountOut.amount, + ), + ]; + expect(expectedDeltas).to.deep.eq(balanceDeltas); + const expectedMinAmountsOut = + removeLiquidityQueryOutput.amountsOut.map((amountOut) => + removeLiquidityBuildInput.slippage.applyTo( + amountOut.amount, + -1, + ), + ); + expect(expectedMinAmountsOut).to.deep.eq( + removeLiquidityBuildCallOutput.minAmountsOut.map( + (a) => a.amount, + ), + ); + // make sure to pass Tokens in correct order. Same as poolTokens but as underlyings instead + assertTokenMatch( + [ + new Token( + 111555111, + USDC.address as Address, + USDC.decimals, + ), + new Token( + 111555111, + USDT.address as Address, + USDT.decimals, + ), + ], + removeLiquidityBuildCallOutput.minAmountsOut.map( + (a) => a.token, + ), + ); + }); + }); + + const poolStateWithUnderlyings: PoolStateWithUnderlyings = { + id: '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8', + address: '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8', + type: 'Stable', + protocolVersion: 3, + tokens: [ + { + index: 0, + address: '0x8a88124522dbbf1e56352ba3de1d9f78c143751e', + decimals: 6, + underlyingToken: { + address: '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8', + decimals: 6, + index: 0, + }, + }, + { + index: 1, + address: '0x978206fae13faf5a8d293fb614326b237684b750', + decimals: 6, + underlyingToken: { + address: '0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0', + decimals: 6, + index: 1, + }, + }, + ], + totalShares: '119755.048508537457614083', + }; +});