diff --git a/apps/frontend-v3/app/(app)/debug/pools/page.tsx b/apps/frontend-v3/app/(app)/debug/pools/page.tsx index 888f798b6..4338e6233 100644 --- a/apps/frontend-v3/app/(app)/debug/pools/page.tsx +++ b/apps/frontend-v3/app/(app)/debug/pools/page.tsx @@ -16,8 +16,11 @@ export default function DebugPools() { Sepolia WEIGHTED with Proportional joins (Balancer 50 BAL 50 WETH -ExitFee Hook) - - Sepolia reference BOOSTED pool (Balancer DAI/USDC/USDT) + + Sepolia reference BOOSTED pool (Balancer USDC/USDT) + + + Sepolia reference NESTED pool (Balancer 50 WETH 50 USD) diff --git a/packages/lib/config/config.types.ts b/packages/lib/config/config.types.ts index 3d9487b72..fddfe723e 100644 --- a/packages/lib/config/config.types.ts +++ b/packages/lib/config/config.types.ts @@ -43,6 +43,7 @@ export interface ContractsConfig { */ router?: Address batchRouter?: Address + compositeLiquidityRouter?: Address relayerV6: Address minter: Address } diff --git a/packages/lib/config/networks/sepolia.ts b/packages/lib/config/networks/sepolia.ts index 2fbd9df31..8fbf70ccc 100644 --- a/packages/lib/config/networks/sepolia.ts +++ b/packages/lib/config/networks/sepolia.ts @@ -1,7 +1,13 @@ import { GqlChain } from '@repo/lib/shared/services/api/generated/graphql' import { NetworkConfig } from '../config.types' import { convertHexToLowerCase } from '@repo/lib/shared/utils/objects' -import { BALANCER_BATCH_ROUTER, BALANCER_ROUTER, PERMIT2, VAULT_V3 } from '@balancer/sdk' +import { + BALANCER_BATCH_ROUTER, + BALANCER_COMPOSITE_LIQUIDITY_ROUTER, + BALANCER_ROUTER, + PERMIT2, + VAULT_V3, +} from '@balancer/sdk' import { sepolia } from 'viem/chains' const networkConfig: NetworkConfig = { @@ -36,6 +42,7 @@ const networkConfig: NetworkConfig = { vaultV3: VAULT_V3[sepolia.id], router: BALANCER_ROUTER[sepolia.id], batchRouter: BALANCER_BATCH_ROUTER[sepolia.id], + compositeLiquidityRouter: BALANCER_COMPOSITE_LIQUIDITY_ROUTER[sepolia.id], relayerV6: '0x7852fB9d0895e6e8b3EedA553c03F6e2F9124dF9', minter: '0x1783Cd84b3d01854A96B4eD5843753C2CcbD574A', }, diff --git a/packages/lib/debug-helpers.ts b/packages/lib/debug-helpers.ts index 723100d54..27c443d0f 100644 --- a/packages/lib/debug-helpers.ts +++ b/packages/lib/debug-helpers.ts @@ -40,6 +40,8 @@ export const vaultV3Address = sepoliaNetworkConfig.contracts.balancer.vaultV3 as export const poolId = '0x68e3266c9c8bbd44ad9dca5afbfe629022aee9fe000200000000000000000512' as const // Balancer Weighted wjAura and WETH export const sepoliaRouter = sepoliaNetworkConfig.contracts.balancer.router +export const sepoliaCompositeRouter = + sepoliaNetworkConfig.contracts.balancer.compositeLiquidityRouter /* Used to pretty print objects when debugging diff --git a/packages/lib/modules/pool/actions/LiquidityActionHelpers.integration.spec.ts b/packages/lib/modules/pool/actions/LiquidityActionHelpers.integration.spec.ts index 187cd4ac8..7637a7804 100644 --- a/packages/lib/modules/pool/actions/LiquidityActionHelpers.integration.spec.ts +++ b/packages/lib/modules/pool/actions/LiquidityActionHelpers.integration.spec.ts @@ -10,7 +10,7 @@ import { wETHAddress, } from '@repo/lib/debug-helpers' import { HumanTokenAmountWithAddress } from '@repo/lib/modules/tokens/token.types' -import { GqlChain } from '@repo/lib/shared/services/api/generated/graphql' +import { GqlChain, GqlPoolElement } from '@repo/lib/shared/services/api/generated/graphql' import { getPoolMock } from '../__mocks__/getPoolMock' import { allPoolTokens } from '../pool.helpers' import { LiquidityActionHelpers } from './LiquidityActionHelpers' @@ -46,12 +46,16 @@ describe('Calculates toInputAmounts from allPoolTokens', () => { { humanAmount: '100', tokenAddress: daiAddress }, ] - expect(allPoolTokens(nestedPool).map(t => t.address)).toEqual([ - usdcDaiUsdtBptAddress, // Phantom BPT + expect( + allPoolTokens(nestedPool) + .map(t => t.address) + .sort() + ).toEqual([ daiAddress, + usdcDaiUsdtBptAddress, // Phantom BPT usdcAddress, - usdtAddress, wETHAddress, + usdtAddress, ]) const helpers = new LiquidityActionHelpers(nestedPool) @@ -91,26 +95,160 @@ describe('Calculates toInputAmounts from allPoolTokens', () => { }, ]) }) +}) + +// Unskip when sepolia V3 pools are available in production api +describe.skip('Liquidity helpers for V3 Boosted pools', async () => { + const poolId = '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8' // Sepolia stataEthUSDC stataEthUSDT + + const usdcSepoliaAddress = '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8' + const usdtSepoliaAddress = '0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0' + // const v3Pool = await getPoolMock(poolId, GqlChain.Sepolia) + const v3Pool = {} as GqlPoolElement + const helpers = new LiquidityActionHelpers(v3Pool) - // Unskip when sepolia V3 pools are available in production api - it.skip('for v3 BOOSTED pool', async () => { - const poolId = '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8' // Sepolia stataEthUSDC stataEthUSDT + const humanAmountsIn: HumanTokenAmountWithAddress[] = [ + { humanAmount: '0.1', tokenAddress: usdcSepoliaAddress }, + ] - const usdcSepoliaAddress = '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8' - const usdtSepoliaAddress = '0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0' - const v3Pool = await getPoolMock(poolId, GqlChain.Sepolia) + it('allPoolTokens', async () => { + expect(allPoolTokens(v3Pool).map(t => t.address)).toEqual([ + usdcSepoliaAddress, + usdtSepoliaAddress, + ]) + }) + it('allPoolTokens snapshot', async () => { + expect(allPoolTokens(v3Pool)).toMatchInlineSnapshot(` + [ + { + "address": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", + "decimals": 6, + "index": 0, + "name": "USDC (AAVE Faucet)", + "symbol": "usdc-aave", + }, + { + "address": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", + "decimals": 6, + "index": 1, + "name": "USDT (AAVE Faucet)", + "symbol": "usdt-aave", + }, + ] + `) + }) + + it('toInputAmounts', async () => { + expect(helpers.toInputAmounts(humanAmountsIn)).toEqual([ + { + address: usdcSepoliaAddress, + decimals: 6, + rawAmount: 100000n, + }, + ]) + }) + + it('boostedPoolState', async () => { + const helpers = new LiquidityActionHelpers(v3Pool) + expect(helpers.boostedPoolState).toMatchObject({ + address: '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8', + id: '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8', + protocolVersion: 3, + tokens: [ + { + address: '0x8a88124522dbbf1e56352ba3de1d9f78c143751e', + balance: expect.any(String), + balanceUSD: expect.any(String), + decimals: 6, + hasNestedPool: false, + id: '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8-0x8a88124522dbbf1e56352ba3de1d9f78c143751e', + index: 0, + isAllowed: true, + isErc4626: true, + name: 'Static Aave Ethereum USDC', + nestedPool: null, + priceRate: expect.any(String), + priceRateProvider: '0x34101091673238545de8a846621823d9993c3085', + priceRateProviderData: null, + symbol: 'stataEthUSDC', + underlyingToken: { + address: '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8', + decimals: 6, + index: 0, + name: 'USDC (AAVE Faucet)', + symbol: 'usdc-aave', + }, + weight: null, + }, + { + address: '0x978206fae13faf5a8d293fb614326b237684b750', + balance: expect.any(String), + balanceUSD: expect.any(String), + decimals: 6, + hasNestedPool: false, + id: '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8-0x978206fae13faf5a8d293fb614326b237684b750', + index: 1, + isAllowed: true, + isErc4626: true, + name: 'Static Aave Ethereum USDT', + nestedPool: null, + priceRate: expect.any(String), + priceRateProvider: '0xb1b171a07463654cc1fe3df4ec05f754e41f0a65', + priceRateProviderData: null, + symbol: 'stataEthUSDT', + underlyingToken: { + address: '0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0', + decimals: 6, + index: 1, + name: 'USDT (AAVE Faucet)', + symbol: 'usdt-aave', + }, + weight: null, + }, + ], + type: 'Stable', + }) + }) +}) + +// Unskip when sepolia V3 pools are available in production api +describe.skip('Liquidity helpers for V3 NESTED pool', async () => { + const poolId = '0x0270daf4ee12ccb1abc8aa365054eecb1b7f4f6b' // Sepolia Balancer 50 WETH 50 USD + + const usdcSepoliaAddress = '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8' + const usdtSepoliaAddress = '0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0' + // const v3Pool = await getPoolMock(poolId, GqlChain.Sepolia) + const v3Pool = {} as GqlPoolElement + + const helpers = new LiquidityActionHelpers(v3Pool) + const wethAddress = '0x7b79995e5f793a07bc00c21412e50ecae098e7f9' + + const usdcUsdtSepoliaBptAddress = '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8' + + const aaveUSDCAddress = '0x8a88124522dbbf1e56352ba3de1d9f78c143751e' + const aaveUSDTAddress = '0x978206fae13faf5a8d293fb614326b237684b750' + + const humanAmountsIn: HumanTokenAmountWithAddress[] = [ + { humanAmount: '0.1', tokenAddress: usdcSepoliaAddress }, + ] + + it('allPoolTokens', async () => { expect( allPoolTokens(v3Pool) .map(t => t.address) .sort() - ).toMatchInlineSnapshot([usdcSepoliaAddress, usdtSepoliaAddress]) - - const humanAmountsIn: HumanTokenAmountWithAddress[] = [ - { humanAmount: '0.1', tokenAddress: usdcSepoliaAddress }, - ] - const helpers = new LiquidityActionHelpers(v3Pool) + ).toEqual([ + usdcUsdtSepoliaBptAddress, + wethAddress, + aaveUSDCAddress, + usdcSepoliaAddress, + aaveUSDTAddress, + usdtSepoliaAddress, + ]) + }) + it('toInputAmounts', async () => { expect(helpers.toInputAmounts(humanAmountsIn)).toEqual([ { address: usdcSepoliaAddress, diff --git a/packages/lib/modules/pool/actions/LiquidityActionHelpers.spec.ts b/packages/lib/modules/pool/actions/LiquidityActionHelpers.spec.ts index 4b5ab194d..8da48d979 100644 --- a/packages/lib/modules/pool/actions/LiquidityActionHelpers.spec.ts +++ b/packages/lib/modules/pool/actions/LiquidityActionHelpers.spec.ts @@ -64,7 +64,7 @@ it('returns poolState for non nested pools', () => { it('returns NestedPoolState for nested pools', () => { const helpers = new LiquidityActionHelpers(nestedPoolMock) - const nestedPoolState = helpers.nestedPoolState + const nestedPoolState = helpers.nestedPoolStateV2 expect(nestedPoolState.pools).toHaveLength(2) const firstPool = nestedPoolState.pools[0] diff --git a/packages/lib/modules/pool/actions/LiquidityActionHelpers.ts b/packages/lib/modules/pool/actions/LiquidityActionHelpers.ts index 6eb93b658..23f6ea395 100644 --- a/packages/lib/modules/pool/actions/LiquidityActionHelpers.ts +++ b/packages/lib/modules/pool/actions/LiquidityActionHelpers.ts @@ -14,9 +14,12 @@ import { PoolGetPool, PoolState, PoolStateWithBalances, + PoolStateWithUnderlyings, + PoolTokenWithUnderlying, Token, TokenAmount, mapPoolToNestedPoolStateV2, + mapPoolToNestedPoolStateV3, mapPoolType, } from '@balancer/sdk' import BigNumber from 'bignumber.js' @@ -61,12 +64,44 @@ export class LiquidityActionHelpers { } /* Used by default nested SDK handlers */ - public get nestedPoolState(): NestedPoolState { + public get nestedPoolStateV2(): NestedPoolState { const result = mapPoolToNestedPoolStateV2(this.pool as PoolGetPool) result.protocolVersion = 2 return result } + /* Used by default nested SDK handlers */ + public get nestedPoolStateV3(): NestedPoolState { + const result = mapPoolToNestedPoolStateV3(this.pool as PoolGetPool) + result.protocolVersion = 3 + return result + } + + /* Used by V3 boosted SDK handlers */ + public get boostedPoolState(): PoolStateWithUnderlyings & { totalShares: HumanAmount } { + const poolTokensWithUnderlyings: PoolTokenWithUnderlying[] = this.pool.poolTokens.map( + (token, index) => ({ + ...token, + address: token.address as Address, + underlyingToken: { + ...token.underlyingToken, + address: token.underlyingToken?.address as Address, + decimals: token.underlyingToken?.decimals as number, + index, //TODO: review that this index is always the expected one + }, + }) + ) + const state: PoolStateWithUnderlyings & { totalShares: HumanAmount } = { + id: this.pool.id as Hex, + address: this.pool.address as Address, + protocolVersion: 3, + type: mapPoolType(this.pool.type), + tokens: poolTokensWithUnderlyings, + totalShares: this.pool.dynamicData.totalShares as HumanAmount, + } + return state + } + public get poolStateWithBalances(): PoolStateWithBalances { return toPoolStateWithBalances(this.pool) } diff --git a/packages/lib/modules/pool/actions/add-liquidity/form/AddLiquidityForm.tsx b/packages/lib/modules/pool/actions/add-liquidity/form/AddLiquidityForm.tsx index ef2b6ba5e..75b5746ce 100644 --- a/packages/lib/modules/pool/actions/add-liquidity/form/AddLiquidityForm.tsx +++ b/packages/lib/modules/pool/actions/add-liquidity/form/AddLiquidityForm.tsx @@ -55,6 +55,7 @@ import { ConnectWallet } from '@repo/lib/modules/web3/ConnectWallet' import { BalAlert } from '@repo/lib/shared/components/alerts/BalAlert' import { SafeAppAlert } from '@repo/lib/shared/components/alerts/SafeAppAlert' import { useTokens } from '@repo/lib/modules/tokens/TokensProvider' +import { isBoosted } from '../../../pool.helpers' // small wrapper to prevent out of context error export function AddLiquidityForm() { @@ -188,7 +189,11 @@ function AddLiquidityMainForm() { )} - {!nestedAddLiquidityEnabled ? ( + {/* //TODO: + Avoid proportional inputs to avoid error above until SDK calculateProportionalAmounts for boosted is implemented + https://github.com/balancer/b-sdk/issues/468 + */} + {!nestedAddLiquidityEnabled && !isBoosted(pool) ? ( tokenSelectDisclosure.onOpen()} diff --git a/packages/lib/modules/pool/actions/add-liquidity/handlers/BoostedUnbalancedAddLiquidityV3.handler.integration.spec.ts b/packages/lib/modules/pool/actions/add-liquidity/handlers/BoostedUnbalancedAddLiquidityV3.handler.integration.spec.ts new file mode 100644 index 000000000..7770e1948 --- /dev/null +++ b/packages/lib/modules/pool/actions/add-liquidity/handlers/BoostedUnbalancedAddLiquidityV3.handler.integration.spec.ts @@ -0,0 +1,52 @@ +/* eslint-disable max-len */ +import { getNetworkConfig } from '@repo/lib/config/app.config' +import { HumanTokenAmountWithAddress } from '@repo/lib/modules/tokens/token.types' +import { GqlChain, GqlPoolElement } from '@repo/lib/shared/services/api/generated/graphql' +import { defaultTestUserAccount } from '@repo/lib/test/anvil/anvil-setup' +import { getPoolMock } from '../../../__mocks__/getPoolMock' +import { BoostedUnbalancedAddLiquidityV3Handler } from './BoostedUnbalancedAddLiquidityV3.handler' +import { selectAddLiquidityHandler } from './selectAddLiquidityHandler' + +// TODO: unskip this test when sepolia V3 pools are available in production api +describe.skip('When adding unbalanced liquidity for a V3 BOOSTED pool', async () => { + // Sepolia + const poolId = '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8' // Sepolia stataEthUSDC stataEthUSDT + + const usdcAaveAddress = '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8' // Sepolia underlying usdcAave faucet address (temporary until we have the real one) + const usdtAaveAddress = '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0' // Sepolia underlying usdcAave faucet address (temporary until we have the real one) + // const v3Pool = await getPoolMock(poolId, GqlChain.Sepolia) + const v3Pool = {} as GqlPoolElement + + const handler = selectAddLiquidityHandler(v3Pool) as BoostedUnbalancedAddLiquidityV3Handler + + const humanAmountsIn: HumanTokenAmountWithAddress[] = [ + { humanAmount: '0.1', tokenAddress: usdcAaveAddress }, + { humanAmount: '0.1', tokenAddress: usdtAaveAddress }, + ] + + it('calculates price impact', async () => { + const priceImpact = await handler.getPriceImpact(humanAmountsIn) + expect(priceImpact).toBe(0) + }) + + it('queries bptOut', async () => { + const result = await handler.simulate(humanAmountsIn) + + expect(result.bptOut.amount).toBeGreaterThan(100000000000000n) + expect(result.bptOut.token.address).toBe(poolId) + }) + + it('builds Tx Config', async () => { + const queryOutput = await handler.simulate(humanAmountsIn) + + const result = await handler.buildCallData({ + humanAmountsIn, + account: defaultTestUserAccount, + slippagePercent: '0.2', + queryOutput, + }) + const router = getNetworkConfig(GqlChain.Sepolia).contracts.balancer.compositeLiquidityRouter + expect(result.to).toBe(router) + expect(result.data).toBeDefined() + }) +}) diff --git a/packages/lib/modules/pool/actions/add-liquidity/handlers/BoostedUnbalancedAddLiquidityV3.handler.ts b/packages/lib/modules/pool/actions/add-liquidity/handlers/BoostedUnbalancedAddLiquidityV3.handler.ts new file mode 100644 index 000000000..314bcb7f8 --- /dev/null +++ b/packages/lib/modules/pool/actions/add-liquidity/handlers/BoostedUnbalancedAddLiquidityV3.handler.ts @@ -0,0 +1,79 @@ +import { + AddLiquidityBoostedBuildCallInput, + AddLiquidityBoostedInput, + AddLiquidityBoostedV3, + Hex, + PriceImpact, + PriceImpactAmount, +} from '@balancer/sdk' +import { HumanTokenAmountWithAddress } from '@repo/lib/modules/tokens/token.types' +import { TransactionConfig } from '@repo/lib/modules/web3/contracts/contract.types' +import { SdkBuildAddLiquidityInput, SdkQueryAddLiquidityOutput } from '../add-liquidity.types' +import { BaseUnbalancedAddLiquidityHandler } from './BaseUnbalancedAddLiquidity.handler' +import { constructBaseBuildCallInput } from './add-liquidity.utils' +import { areEmptyAmounts } from '../../LiquidityActionHelpers' + +export class BoostedUnbalancedAddLiquidityV3Handler extends BaseUnbalancedAddLiquidityHandler { + public async getPriceImpact(humanAmountsIn: HumanTokenAmountWithAddress[]): Promise { + if (areEmptyAmounts(humanAmountsIn)) { + // Avoid price impact calculation when there are no amounts in + return 0 + } + + const addLiquidityInput = this.constructSdkInput(humanAmountsIn) + + const priceImpactABA: PriceImpactAmount = await PriceImpact.addLiquidityUnbalancedBoosted( + addLiquidityInput, + this.helpers.boostedPoolState + ) + + return priceImpactABA.decimal + } + + public async simulate( + humanAmountsIn: HumanTokenAmountWithAddress[] + ): Promise { + const addLiquidity = new AddLiquidityBoostedV3() + const addLiquidityInput: AddLiquidityBoostedInput = this.constructSdkInput(humanAmountsIn) + + const sdkQueryOutput = await addLiquidity.query( + addLiquidityInput, + this.helpers.boostedPoolState + ) + + return { bptOut: sdkQueryOutput.bptOut, to: sdkQueryOutput.to, sdkQueryOutput } + } + + public async buildCallData({ + humanAmountsIn, + slippagePercent, + queryOutput, + account, + permit2, + }: SdkBuildAddLiquidityInput): Promise { + const addLiquidity = new AddLiquidityBoostedV3() + + const buildCallParams: AddLiquidityBoostedBuildCallInput = { + ...constructBaseBuildCallInput({ + humanAmountsIn, + sdkQueryOutput: queryOutput.sdkQueryOutput, + slippagePercent: slippagePercent, + pool: this.helpers.pool, + }), + protocolVersion: 3, + userData: '0x' as Hex, + } + + const { callData, to, value } = permit2 + ? addLiquidity.buildCallWithPermit2(buildCallParams, permit2) + : addLiquidity.buildCall(buildCallParams) + + return { + account, + chainId: this.helpers.chainId, + data: callData, + to, + value, + } + } +} diff --git a/packages/lib/modules/pool/actions/add-liquidity/handlers/NestedAddLiquidity.handler.ts b/packages/lib/modules/pool/actions/add-liquidity/handlers/NestedAddLiquidity.handler.ts index 66dfabee2..28f81cc1a 100644 --- a/packages/lib/modules/pool/actions/add-liquidity/handlers/NestedAddLiquidity.handler.ts +++ b/packages/lib/modules/pool/actions/add-liquidity/handlers/NestedAddLiquidity.handler.ts @@ -35,7 +35,10 @@ export class NestedAddLiquidityHandler implements AddLiquidityHandler { return 0 } const input = this.constructSdkInput(humanAmountsIn) - const priceImpactABA = await PriceImpact.addLiquidityNested(input, this.helpers.nestedPoolState) + const priceImpactABA = await PriceImpact.addLiquidityNested( + input, + this.helpers.nestedPoolStateV2 + ) return priceImpactABA.decimal } @@ -47,7 +50,10 @@ export class NestedAddLiquidityHandler implements AddLiquidityHandler { const addLiquidityInput = this.constructSdkInput(humanAmountsIn, userAddress) - const sdkQueryOutput = await addLiquidity.query(addLiquidityInput, this.helpers.nestedPoolState) + const sdkQueryOutput = await addLiquidity.query( + addLiquidityInput, + this.helpers.nestedPoolStateV2 + ) return { bptOut: sdkQueryOutput.bptOut, to: sdkQueryOutput.to, sdkQueryOutput } } diff --git a/packages/lib/modules/pool/actions/add-liquidity/handlers/selectAddLiquidityHandler.ts b/packages/lib/modules/pool/actions/add-liquidity/handlers/selectAddLiquidityHandler.ts index 4143cba98..801a3c6a8 100644 --- a/packages/lib/modules/pool/actions/add-liquidity/handlers/selectAddLiquidityHandler.ts +++ b/packages/lib/modules/pool/actions/add-liquidity/handlers/selectAddLiquidityHandler.ts @@ -6,14 +6,17 @@ import { AddLiquidityHandler } from './AddLiquidity.handler' import { NestedAddLiquidityHandler } from './NestedAddLiquidity.handler' import { requiresProportionalInput, supportsNestedActions } from '../../LiquidityActionHelpers' import { ProportionalAddLiquidityHandler } from './ProportionalAddLiquidity.handler' -import { isV3Pool } from '../../../pool.helpers' +import { isBoosted, isV3Pool } from '../../../pool.helpers' import { ProportionalAddLiquidityHandlerV3 } from './ProportionalAddLiquidityV3.handler' import { UnbalancedAddLiquidityV3Handler } from './UnbalancedAddLiquidityV3.handler' +import { BoostedUnbalancedAddLiquidityV3Handler } from './BoostedUnbalancedAddLiquidityV3.handler' export function selectAddLiquidityHandler(pool: Pool): AddLiquidityHandler { // This is just an example to illustrate how edge-case handlers would receive different inputs but return a common contract if (pool.id === 'TWAMM-example') return new TwammAddLiquidityHandler(getChainId(pool.chain)) + if (isBoosted(pool)) return new BoostedUnbalancedAddLiquidityV3Handler(pool) + if (requiresProportionalInput(pool)) { if (isV3Pool(pool)) { return new ProportionalAddLiquidityHandlerV3(pool) diff --git a/packages/lib/modules/pool/actions/remove-liquidity/RemoveLiquidityProvider.tsx b/packages/lib/modules/pool/actions/remove-liquidity/RemoveLiquidityProvider.tsx index 9fabd0f66..3abbb229e 100644 --- a/packages/lib/modules/pool/actions/remove-liquidity/RemoveLiquidityProvider.tsx +++ b/packages/lib/modules/pool/actions/remove-liquidity/RemoveLiquidityProvider.tsx @@ -23,6 +23,7 @@ import { useTransactionSteps } from '@repo/lib/modules/transactions/transaction- import { HumanTokenAmountWithAddress } from '@repo/lib/modules/tokens/token.types' import { getUserWalletBalance } from '../../user-balance.helpers' import { useModalWithPoolRedirect } from '../../useModalWithPoolRedirect' +import { GqlToken } from '@repo/lib/shared/services/api/generated/graphql' export type UseRemoveLiquidityResponse = ReturnType export const RemoveLiquidityContext = createContext(null) @@ -73,7 +74,7 @@ export function _useRemoveLiquidity(urlTxHash?: Hash) { const isSingleToken = removalType === RemoveLiquidityType.SingleToken const isProportional = removalType === RemoveLiquidityType.Proportional - function tokensToShow() { + function tokensToShow(): GqlToken[] { // Cow AMM pools don't support wethIsEth if (isCowAmmPool(pool.type)) return tokens @@ -83,13 +84,15 @@ export function _useRemoveLiquidity(urlTxHash?: Hash) { // if wethIsEth we only show the native asset if (includesWrappedNativeAsset && wethIsEth) { // replace the wrapped native asset with the native asset - return tokens.map(token => { - if (token && isWrappedNativeAsset(token.address as Address, chain)) { - return nativeAsset - } else { - return token - } - }) + return tokens + .map(token => { + if (token && isWrappedNativeAsset(token.address as Address, chain)) { + return nativeAsset + } else { + return token + } + }) + .filter((token): token is GqlToken => token !== undefined) } return tokens diff --git a/packages/lib/modules/pool/actions/remove-liquidity/form/RemoveLiquidityForm.tsx b/packages/lib/modules/pool/actions/remove-liquidity/form/RemoveLiquidityForm.tsx index e23cb885e..69955fa36 100644 --- a/packages/lib/modules/pool/actions/remove-liquidity/form/RemoveLiquidityForm.tsx +++ b/packages/lib/modules/pool/actions/remove-liquidity/form/RemoveLiquidityForm.tsx @@ -39,19 +39,27 @@ import { SafeAppAlert } from '@repo/lib/shared/components/alerts/SafeAppAlert' import { useTokens } from '@repo/lib/modules/tokens/TokensProvider' import { TooltipWithTouch } from '@repo/lib/shared/components/tooltips/TooltipWithTouch' import { useUserSettings } from '@repo/lib/modules/user/settings/UserSettingsProvider' - -const TABS: ButtonGroupOption[] = [ - { - value: 'proportional', - label: 'Proportional', - }, - { - value: 'single', - label: 'Single token', - }, -] as const +import { isBoosted } from '../../../pool.helpers' export function RemoveLiquidityForm() { + const { pool } = usePool() + + const TABS: ButtonGroupOption[] = [ + { + value: 'proportional', + label: 'Proportional', + }, + { + value: 'single', + label: 'Single token', + //Boosted pools do not support single token removes + disabled: isBoosted(pool), + }, + ] as const + const [activeTab, setActiveTab] = useState(TABS[0]) + const isProportionalTabSelected = activeTab.value === 'proportional' + const isSingleTabSelected = activeTab.value === 'single' + const { transactionSteps, tokens, @@ -72,11 +80,9 @@ export function RemoveLiquidityForm() { setHumanBptInPercent, setNeedsToAcceptHighPI, } = useRemoveLiquidity() - const { pool } = usePool() const { priceImpactColor, priceImpact, setPriceImpact } = usePriceImpact() const { redirectToPoolPage } = usePoolRedirect(pool) const nextBtn = useRef(null) - const [activeTab, setActiveTab] = useState(TABS[0]) const { startTokenPricePolling } = useTokens() const { slippage } = useUserSettings() @@ -180,10 +186,10 @@ export function RemoveLiquidityForm() { You can only remove up to 25% of a single asset from the pool in one transaction )} - {activeTab === TABS[0] && ( + {isProportionalTabSelected && ( )} - {activeTab === TABS[1] && ( + {isSingleTabSelected && ( )} diff --git a/packages/lib/modules/pool/actions/remove-liquidity/form/RemoveLiquidityProportional.tsx b/packages/lib/modules/pool/actions/remove-liquidity/form/RemoveLiquidityProportional.tsx index 170729bee..7a964fcd2 100644 --- a/packages/lib/modules/pool/actions/remove-liquidity/form/RemoveLiquidityProportional.tsx +++ b/packages/lib/modules/pool/actions/remove-liquidity/form/RemoveLiquidityProportional.tsx @@ -10,7 +10,7 @@ import { isNativeAsset, isNativeOrWrappedNative } from '@repo/lib/modules/tokens import { NativeAssetSelectModal } from '@repo/lib/modules/tokens/NativeAssetSelectModal' import { shouldShowNativeWrappedSelector } from '../../LiquidityActionHelpers' -type Props = { tokens: (GqlToken | undefined)[]; poolType: GqlPoolType } +type Props = { tokens: GqlToken[]; poolType: GqlPoolType } export function RemoveLiquidityProportional({ tokens, poolType }: Props) { const { amountOutForToken, validTokens, setWethIsEth, simulationQuery, priceImpactQuery } = useRemoveLiquidity() diff --git a/packages/lib/modules/pool/actions/remove-liquidity/handlers/BaseProportionalRemoveLiquidity.handler.ts b/packages/lib/modules/pool/actions/remove-liquidity/handlers/BaseProportionalRemoveLiquidity.handler.ts index b35fc8f46..046211963 100644 --- a/packages/lib/modules/pool/actions/remove-liquidity/handlers/BaseProportionalRemoveLiquidity.handler.ts +++ b/packages/lib/modules/pool/actions/remove-liquidity/handlers/BaseProportionalRemoveLiquidity.handler.ts @@ -25,6 +25,11 @@ export abstract class BaseProportionalRemoveLiquidityHandler implements RemoveLi this.helpers = new LiquidityActionHelpers(pool) } + public async getPriceImpact(): Promise { + // proportional remove liquidity does not have price impact + return 0 + } + public async simulate({ humanBptIn: bptIn, userAddress, @@ -37,11 +42,6 @@ export abstract class BaseProportionalRemoveLiquidityHandler implements RemoveLi return { amountsOut: sdkQueryOutput.amountsOut.filter(a => a.amount > 0n), sdkQueryOutput } } - public async getPriceImpact(): Promise { - // proportional remove liquidity does not have price impact - return 0 - } - public abstract buildCallData(inputs: BuildRemoveLiquidityInput): Promise /** diff --git a/packages/lib/modules/pool/actions/remove-liquidity/handlers/BoostedProportionalRemoveLiquidityV3.handler.integration.spec.ts b/packages/lib/modules/pool/actions/remove-liquidity/handlers/BoostedProportionalRemoveLiquidityV3.handler.integration.spec.ts new file mode 100644 index 000000000..bff104db7 --- /dev/null +++ b/packages/lib/modules/pool/actions/remove-liquidity/handlers/BoostedProportionalRemoveLiquidityV3.handler.integration.spec.ts @@ -0,0 +1,67 @@ +import { sepoliaCompositeRouter } from '@repo/lib/debug-helpers' +import { emptyAddress } from '@repo/lib/modules/web3/contracts/wagmi-helpers' +import { defaultTestUserAccount } from '@repo/lib/test/anvil/anvil-setup' +import { connectWithDefaultUser } from '@repo/lib/test/utils/wagmi/wagmi-connections' +import { Pool } from '../../../PoolProvider' +import { QueryRemoveLiquidityInput, RemoveLiquidityType } from '../remove-liquidity.types' +import { BoostedProportionalRemoveLiquidityV3Handler } from './BoostedProportionalRemoveLiquidityV3.handler' +import { selectRemoveLiquidityHandler } from './selectRemoveLiquidityHandler' + +function selectProportionalHandler(pool: Pool): BoostedProportionalRemoveLiquidityV3Handler { + return selectRemoveLiquidityHandler( + pool, + RemoveLiquidityType.Proportional + ) as BoostedProportionalRemoveLiquidityV3Handler +} + +const defaultBuildInput = { account: defaultTestUserAccount, slippagePercent: '0.2' } + +await connectWithDefaultUser() + +// TODO: unskip this test when sepolia V3 pools are available in production api +describe.skip('When proportionally removing liquidity for a BOOSTED v3 pool', async () => { + // Sepolia + // const poolId = '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8' // Sepolia stataEthUSDC stataEthUSDT + + const usdcAaveAddress = '0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8' // Sepolia underlying usdcAave faucet address (temporary until we have the real one) + const usdtAaveAddress = '0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0' // Sepolia underlying usdcAave faucet address (temporary until we have the real one) + + // const v3Pool = await getPoolMock(poolId, GqlChain.Sepolia) + const v3Pool = {} as unknown as Pool + + const defaultQueryInput: QueryRemoveLiquidityInput = { + humanBptIn: '0.01', + tokenOut: emptyAddress, // We don't use in this scenario it but it is required to simplify TS interfaces + userAddress: defaultTestUserAccount, + } + + test('queries amounts out', async () => { + const handler = selectProportionalHandler(v3Pool) + + const result = await handler.simulate(defaultQueryInput) + + expect(result.sdkQueryOutput.to).toBe(sepoliaCompositeRouter) + + const [aUsdcTokenAmountOut, aUsdtTokenAmountOut] = result.amountsOut.sort() + + expect(aUsdcTokenAmountOut.token.address).toBe(usdcAaveAddress) + expect(aUsdcTokenAmountOut.amount).toBeGreaterThan(0n) + + expect(aUsdtTokenAmountOut.token.address).toBe(usdtAaveAddress) + expect(aUsdtTokenAmountOut.amount).toBeGreaterThan(0n) + }) + + test('builds Tx Config', async () => { + const handler = selectProportionalHandler(v3Pool) + + const queryOutput = await handler.simulate(defaultQueryInput) + + const result = await handler.buildCallData({ + ...defaultBuildInput, + queryOutput, + }) + + expect(result.to).toBe(sepoliaCompositeRouter) + expect(result.data).toBeDefined() + }) +}) diff --git a/packages/lib/modules/pool/actions/remove-liquidity/handlers/BoostedProportionalRemoveLiquidityV3.handler.ts b/packages/lib/modules/pool/actions/remove-liquidity/handlers/BoostedProportionalRemoveLiquidityV3.handler.ts new file mode 100644 index 000000000..9497db970 --- /dev/null +++ b/packages/lib/modules/pool/actions/remove-liquidity/handlers/BoostedProportionalRemoveLiquidityV3.handler.ts @@ -0,0 +1,59 @@ +import { + RemoveLiquidityBoostedBuildCallInput, + RemoveLiquidityBoostedV3, + RemoveLiquidityKind, + Slippage, +} from '@balancer/sdk' +import { TransactionConfig } from '@repo/lib/modules/web3/contracts/contract.types' +import { + QueryRemoveLiquidityInput, + SdkBuildRemoveLiquidityInput, + SdkQueryRemoveLiquidityOutput, +} from '../remove-liquidity.types' +import { BaseProportionalRemoveLiquidityHandler } from './BaseProportionalRemoveLiquidity.handler' + +export class BoostedProportionalRemoveLiquidityV3Handler extends BaseProportionalRemoveLiquidityHandler { + public async simulate({ + humanBptIn: bptIn, + userAddress, + }: QueryRemoveLiquidityInput): Promise { + const removeLiquidity = new RemoveLiquidityBoostedV3() + const removeLiquidityInput = this.constructSdkInput(bptIn, userAddress) + + const sdkQueryOutput = await removeLiquidity.query( + removeLiquidityInput, + this.helpers.boostedPoolState + ) + + return { amountsOut: sdkQueryOutput.amountsOut.filter(a => a.amount > 0n), sdkQueryOutput } + } + + public async buildCallData({ + account, + slippagePercent, + queryOutput, + permit, + }: SdkBuildRemoveLiquidityInput): Promise { + const removeLiquidity = new RemoveLiquidityBoostedV3() + + const v3BuildCallParams: RemoveLiquidityBoostedBuildCallInput = { + ...queryOutput.sdkQueryOutput, + slippage: Slippage.fromPercentage(`${Number(slippagePercent)}`), + protocolVersion: 3, + userData: '0x', + removeLiquidityKind: RemoveLiquidityKind.Proportional, + } + + const { callData, to, value } = permit + ? removeLiquidity.buildCallWithPermit(v3BuildCallParams, permit) + : removeLiquidity.buildCall(v3BuildCallParams) + + return { + account, + chainId: this.helpers.chainId, + data: callData, + to, + value, + } + } +} diff --git a/packages/lib/modules/pool/actions/remove-liquidity/handlers/NestedProportionalRemoveLiquidity.handler.ts b/packages/lib/modules/pool/actions/remove-liquidity/handlers/NestedProportionalRemoveLiquidity.handler.ts index 116df6938..35a5433a5 100644 --- a/packages/lib/modules/pool/actions/remove-liquidity/handlers/NestedProportionalRemoveLiquidity.handler.ts +++ b/packages/lib/modules/pool/actions/remove-liquidity/handlers/NestedProportionalRemoveLiquidity.handler.ts @@ -42,7 +42,7 @@ export class NestedProportionalRemoveLiquidityHandler implements RemoveLiquidity const sdkQueryOutput = await removeLiquidity.query( removeLiquidityInput, - this.helpers.nestedPoolState + this.helpers.nestedPoolStateV2 ) return { amountsOut: sdkQueryOutput.amountsOut, sdkQueryOutput } diff --git a/packages/lib/modules/pool/actions/remove-liquidity/handlers/NestedSingleTokenRemoveLiquidity.handler.ts b/packages/lib/modules/pool/actions/remove-liquidity/handlers/NestedSingleTokenRemoveLiquidity.handler.ts index 33062e008..1085223b2 100644 --- a/packages/lib/modules/pool/actions/remove-liquidity/handlers/NestedSingleTokenRemoveLiquidity.handler.ts +++ b/packages/lib/modules/pool/actions/remove-liquidity/handlers/NestedSingleTokenRemoveLiquidity.handler.ts @@ -45,7 +45,7 @@ export class NestedSingleTokenRemoveLiquidityHandler implements RemoveLiquidityH const sdkQueryOutput = await removeLiquidity.query( removeLiquidityInput, - this.helpers.nestedPoolState + this.helpers.nestedPoolStateV2 ) return { amountsOut: sdkQueryOutput.amountsOut, sdkQueryOutput } @@ -59,7 +59,7 @@ export class NestedSingleTokenRemoveLiquidityHandler implements RemoveLiquidityH const priceImpactABA: PriceImpactAmount = await PriceImpact.removeLiquidityNested( removeLiquidityInput, - this.helpers.nestedPoolState + this.helpers.nestedPoolStateV2 ) return priceImpactABA.decimal diff --git a/packages/lib/modules/pool/actions/remove-liquidity/handlers/ProportionalRemoveLiquidity.handler.integration.spec.ts b/packages/lib/modules/pool/actions/remove-liquidity/handlers/ProportionalRemoveLiquidity.handler.integration.spec.ts index 91b4f235c..895341210 100644 --- a/packages/lib/modules/pool/actions/remove-liquidity/handlers/ProportionalRemoveLiquidity.handler.integration.spec.ts +++ b/packages/lib/modules/pool/actions/remove-liquidity/handlers/ProportionalRemoveLiquidity.handler.integration.spec.ts @@ -1,16 +1,16 @@ import networkConfig from '@repo/lib/config/networks/mainnet' -import { balAddress, sepoliaRouter, wETHAddress } from '@repo/lib/debug-helpers' +import { balAddress, wETHAddress } from '@repo/lib/debug-helpers' +import { emptyAddress } from '@repo/lib/modules/web3/contracts/wagmi-helpers' +import { defaultTestUserAccount } from '@repo/lib/test/anvil/anvil-setup' import { aBalWethPoolElementMock, aPhantomStablePoolMock, } from '@repo/lib/test/msw/builders/gqlPoolElement.builders' -import { defaultTestUserAccount } from '@repo/lib/test/anvil/anvil-setup' +import { connectWithDefaultUser } from '@repo/lib/test/utils/wagmi/wagmi-connections' import { Pool } from '../../../PoolProvider' import { QueryRemoveLiquidityInput, RemoveLiquidityType } from '../remove-liquidity.types' -import { selectRemoveLiquidityHandler } from './selectRemoveLiquidityHandler' import { ProportionalRemoveLiquidityHandler } from './ProportionalRemoveLiquidity.handler' -import { emptyAddress } from '@repo/lib/modules/web3/contracts/wagmi-helpers' -import { connectWithDefaultUser } from '@repo/lib/test/utils/wagmi/wagmi-connections' +import { selectRemoveLiquidityHandler } from './selectRemoveLiquidityHandler' // import { GqlChain } from '@repo/lib/shared/services/api/generated/graphql' // import { getPoolMock } from '../../../__mocks__/getPoolMock' @@ -82,55 +82,3 @@ describe('When removing liquidity from a V2 stable pool', () => { expect(result.account).toBe(defaultTestUserAccount) }) }) - -// TODO: unskip this test when sepolia V3 pools are available in production api -describe.skip('When proportionally removing liquidity for a weighted v3 pool', async () => { - // Sepolia - const balAddress = '0xb19382073c7a0addbb56ac6af1808fa49e377b75' - const wethAddress = '0x7b79995e5f793a07bc00c21412e50ecae098e7f9' - // const poolId = '0xec1b5ca86c83c7a85392063399e7d2170d502e00' // Sepolia B-50BAL-50WETH - // const v3Pool = await getPoolMock(poolId, GqlChain.Sepolia) - - const v3Pool = {} as unknown as Pool - - const defaultQueryInput: QueryRemoveLiquidityInput = { - humanBptIn: '0.01', - tokenOut: emptyAddress, // We don't use in this scenario it but it is required to simplify TS interfaces - userAddress: defaultTestUserAccount, - } - - test('returns ZERO price impact', async () => { - const handler = selectProportionalHandler(v3Pool) - - const priceImpact = await handler.getPriceImpact() - - expect(priceImpact).toBe(0) - }) - test('queries amounts out', async () => { - const handler = selectProportionalHandler(v3Pool) - - const result = await handler.simulate(defaultQueryInput) - - const [wEthTokenAmountOut, balTokenAmountOut] = result.amountsOut - - expect(balTokenAmountOut.token.address).toBe(balAddress) - expect(balTokenAmountOut.amount).toBeGreaterThan(200000000000000n) - - expect(wEthTokenAmountOut.token.address).toBe(wethAddress) - expect(wEthTokenAmountOut.amount).toBeGreaterThan(100000000000000n) - }) - - test('builds Tx Config', async () => { - const handler = selectProportionalHandler(v3Pool) - - const queryOutput = await handler.simulate(defaultQueryInput) - - const result = await handler.buildCallData({ - ...defaultBuildInput, - queryOutput, - }) - - expect(result.to).toBe(sepoliaRouter) - expect(result.data).toBeDefined() - }) -}) diff --git a/packages/lib/modules/pool/actions/remove-liquidity/handlers/ProportionalRemoveLiquidityV3.handler.integration.spec.ts b/packages/lib/modules/pool/actions/remove-liquidity/handlers/ProportionalRemoveLiquidityV3.handler.integration.spec.ts new file mode 100644 index 000000000..1ae117a27 --- /dev/null +++ b/packages/lib/modules/pool/actions/remove-liquidity/handlers/ProportionalRemoveLiquidityV3.handler.integration.spec.ts @@ -0,0 +1,117 @@ +import { sepoliaRouter } from '@repo/lib/debug-helpers' +import { emptyAddress } from '@repo/lib/modules/web3/contracts/wagmi-helpers' +import { defaultTestUserAccount } from '@repo/lib/test/anvil/anvil-setup' +import { connectWithDefaultUser } from '@repo/lib/test/utils/wagmi/wagmi-connections' +import { Pool } from '../../../PoolProvider' +import { QueryRemoveLiquidityInput, RemoveLiquidityType } from '../remove-liquidity.types' +import { ProportionalRemoveLiquidityV3Handler } from './ProportionalRemoveLiquidityV3.handler' +import { selectRemoveLiquidityHandler } from './selectRemoveLiquidityHandler' + +function selectProportionalHandler(pool: Pool): ProportionalRemoveLiquidityV3Handler { + return selectRemoveLiquidityHandler( + pool, + RemoveLiquidityType.Proportional + ) as ProportionalRemoveLiquidityV3Handler +} + +const defaultBuildInput = { account: defaultTestUserAccount, slippagePercent: '0.2' } + +await connectWithDefaultUser() + +// TODO: unskip this test when sepolia V3 pools are available in production api +describe.skip('When proportionally removing liquidity for a weighted v3 pool', async () => { + // Sepolia + const balAddress = '0xb19382073c7a0addbb56ac6af1808fa49e377b75' + const wethAddress = '0x7b79995e5f793a07bc00c21412e50ecae098e7f9' + // const poolId = '0xec1b5ca86c83c7a85392063399e7d2170d502e00' // Sepolia B-50BAL-50WETH + // const v3Pool = await getPoolMock(poolId, GqlChain.Sepolia) + + const v3Pool = {} as unknown as Pool + + const defaultQueryInput: QueryRemoveLiquidityInput = { + humanBptIn: '0.01', + tokenOut: emptyAddress, // We don't use in this scenario it but it is required to simplify TS interfaces + userAddress: defaultTestUserAccount, + } + + test('returns ZERO price impact', async () => { + const handler = selectProportionalHandler(v3Pool) + + const priceImpact = await handler.getPriceImpact() + + expect(priceImpact).toBe(0) + }) + test('queries amounts out', async () => { + const handler = selectProportionalHandler(v3Pool) + + const result = await handler.simulate(defaultQueryInput) + + const [wEthTokenAmountOut, balTokenAmountOut] = result.amountsOut + + expect(balTokenAmountOut.token.address).toBe(balAddress) + expect(balTokenAmountOut.amount).toBeGreaterThan(200000000000000n) + + expect(wEthTokenAmountOut.token.address).toBe(wethAddress) + expect(wEthTokenAmountOut.amount).toBeGreaterThan(100000000000000n) + }) + + test('builds Tx Config', async () => { + const handler = selectProportionalHandler(v3Pool) + + const queryOutput = await handler.simulate(defaultQueryInput) + + const result = await handler.buildCallData({ + ...defaultBuildInput, + queryOutput, + }) + + expect(result.to).toBe(sepoliaRouter) + expect(result.data).toBeDefined() + }) +}) + +// TODO: unskip this test when sepolia V3 pools are available in production api +describe.skip(`When proportionally removing liquidity using NON BOOSTED remove for a BOOSTED v3 pool + (if the user wants to get wrapping tokens instead of underlying ones)`, async () => { + // Sepolia + // const poolId = '0x6dbdd7a36d900083a5b86a55583d90021e9f33e8' // Sepolia stataEthUSDC stataEthUSDT + + const stataEthUSDC = '0x8a88124522dbbf1e56352ba3de1d9f78c143751e' + const stataEthUSDT = '0x978206fae13faf5a8d293fb614326b237684b750' + // const v3Pool = await getPoolMock(poolId, GqlChain.Sepolia) + const v3Pool = {} as unknown as Pool + + const defaultQueryInput: QueryRemoveLiquidityInput = { + humanBptIn: '0.01', + tokenOut: emptyAddress, // We don't use in this scenario it but it is required to simplify TS interfaces + userAddress: defaultTestUserAccount, + } + + test('queries amounts out', async () => { + const handler = selectProportionalHandler(v3Pool) + + const result = await handler.simulate(defaultQueryInput) + + const [aUsdcTokenAmountOut, aUsdtTokenAmountOut] = result.amountsOut + + expect(aUsdtTokenAmountOut.token.address).toBe(stataEthUSDT) + expect(aUsdtTokenAmountOut.amount).toBeGreaterThan(0n) + + expect(aUsdcTokenAmountOut.token.address).toBe(stataEthUSDC) + expect(aUsdcTokenAmountOut.amount).toBeGreaterThan(0n) + }) + + test('builds Tx Config', async () => { + const handler = selectProportionalHandler(v3Pool) + + const queryOutput = await handler.simulate(defaultQueryInput) + + const result = await handler.buildCallData({ + ...defaultBuildInput, + queryOutput, + }) + + expect(result.to).toBe(sepoliaRouter) + expect(result.data).toBeDefined() + }) +}) diff --git a/packages/lib/modules/pool/actions/remove-liquidity/handlers/selectRemoveLiquidityHandler.ts b/packages/lib/modules/pool/actions/remove-liquidity/handlers/selectRemoveLiquidityHandler.ts index 37ef7e95b..a6e6d652d 100644 --- a/packages/lib/modules/pool/actions/remove-liquidity/handlers/selectRemoveLiquidityHandler.ts +++ b/packages/lib/modules/pool/actions/remove-liquidity/handlers/selectRemoveLiquidityHandler.ts @@ -1,10 +1,11 @@ import { Pool } from '../../../PoolProvider' -import { isV3Pool } from '../../../pool.helpers' +import { isBoosted, isV3Pool } from '../../../pool.helpers' import { shouldUseRecoveryRemoveLiquidity, supportsNestedActions, } from '../../LiquidityActionHelpers' import { RemoveLiquidityType } from '../remove-liquidity.types' +import { BoostedProportionalRemoveLiquidityV3Handler } from './BoostedProportionalRemoveLiquidityV3.handler' import { NestedProportionalRemoveLiquidityHandler } from './NestedProportionalRemoveLiquidity.handler' import { NestedSingleTokenRemoveLiquidityHandler } from './NestedSingleTokenRemoveLiquidity.handler' import { ProportionalRemoveLiquidityHandler } from './ProportionalRemoveLiquidity.handler' @@ -29,6 +30,10 @@ export function selectRemoveLiquidityHandler( return new RecoveryRemoveLiquidityHandler(pool) } + if (isV3Pool(pool) && isBoosted(pool)) { + return new BoostedProportionalRemoveLiquidityV3Handler(pool) + } + if (supportsNestedActions(pool) && kind === RemoveLiquidityType.Proportional) { return new NestedProportionalRemoveLiquidityHandler(pool) } diff --git a/packages/lib/modules/pool/pool.helpers.ts b/packages/lib/modules/pool/pool.helpers.ts index aa1fe222f..43e12f8fa 100644 --- a/packages/lib/modules/pool/pool.helpers.ts +++ b/packages/lib/modules/pool/pool.helpers.ts @@ -4,6 +4,7 @@ import { getBlockExplorerAddressUrl } from '@repo/lib/shared/hooks/useBlockExplo import { GetPoolQuery, GqlChain, + GqlNestedPool, GqlPoolBase, GqlPoolNestingType, GqlPoolStakingGauge, @@ -354,6 +355,10 @@ export function getPoolActionableTokens(pool: Pool, getToken: GetTokenFn): GqlTo .filter((token): token is GqlToken => token !== undefined) } + if (isBoosted(pool)) { + return getBoostedGqlTokens(pool, getToken) + } + // TODO add exception for composable pools where we can allow adding // liquidity with nested tokens if (supportsNestedActions(pool)) { @@ -435,21 +440,35 @@ export function isPoolSwapAllowed(pool: Pool, token1: Address, token2: Address): } /* - Returns all the top level tokens + children nested tokens + ERC4626 underlying tokens. - That is, the tokens that we can use in the pool's actions (add/remove/swap) + Returns all the tokens in the structure of the given pool: + top level tokens + children nested tokens + ERC4626 underlying tokens. */ export function allPoolTokens(pool: Pool | GqlPoolBase): TokenCore[] { - const underlyingTokens: TokenCore[] = pool.poolTokens.flatMap((token, index) => - token.isErc4626 ? ({ ...token.underlyingToken, index } as TokenCore) : [] - ) + const extractUnderlyingTokens = (token: PoolToken): TokenCore[] => { + if (token.isErc4626 && token.underlyingToken) { + return [{ ...token.underlyingToken, index: token.index } as TokenCore] + } + return [] + } + + const extractNestedUnderlyingTokens = (nestedPool?: GqlNestedPool): TokenCore[] => { + if (!nestedPool) return [] + return nestedPool.tokens.flatMap((nestedToken, index) => + nestedToken.isErc4626 && nestedToken.underlyingToken + ? ([nestedToken, { ...nestedToken.underlyingToken, index }] as TokenCore[]) // Is index is not relevant in this case? + : [nestedToken as TokenCore] + ) + } + + const underlyingTokens: TokenCore[] = pool.poolTokens.flatMap(extractUnderlyingTokens) const nestedParentTokens: PoolToken[] = pool.poolTokens.flatMap(token => token.nestedPool ? token : [] ) - const nestedChildrenTokens: PoolToken[] = pool.poolTokens - .flatMap(token => (token.nestedPool ? token.nestedPool.tokens : [])) - .filter((token): token is PoolToken => token !== undefined) + const nestedChildrenTokens: TokenCore[] = pool.poolTokens.flatMap(token => + token.nestedPool ? extractNestedUnderlyingTokens(token.nestedPool as GqlNestedPool) : [] + ) const standardTopLevelTokens: PoolToken[] = pool.poolTokens.flatMap(token => !token.hasNestedPool && !token.isErc4626 ? token : [] @@ -457,7 +476,7 @@ export function allPoolTokens(pool: Pool | GqlPoolBase): TokenCore[] { const allTokens = underlyingTokens.concat( toTokenCores(nestedParentTokens), - toTokenCores(nestedChildrenTokens), + nestedChildrenTokens, toTokenCores(standardTopLevelTokens) ) @@ -465,6 +484,18 @@ export function allPoolTokens(pool: Pool | GqlPoolBase): TokenCore[] { return uniqBy(allTokens, 'address') } +// Returns top level standard tokens + Erc4626 underlying tokens +export function getBoostedGqlTokens(pool: Pool, getToken: GetTokenFn): GqlToken[] { + const underlyingTokens = pool.poolTokens + .flatMap(token => + token.isErc4626 + ? [getToken(token?.underlyingToken?.address as Address, pool.chain)] + : toGqlTokens([token], getToken, pool.chain) + ) + .filter((token): token is GqlToken => token !== undefined) + return underlyingTokens +} + function toTokenCores(poolTokens: PoolToken[]): TokenCore[] { return poolTokens.map( t => diff --git a/packages/lib/modules/tokens/approvals/permit/signRemoveLiquidityPermit.tsx b/packages/lib/modules/tokens/approvals/permit/signRemoveLiquidityPermit.tsx index fd8497712..37f59f1e2 100644 --- a/packages/lib/modules/tokens/approvals/permit/signRemoveLiquidityPermit.tsx +++ b/packages/lib/modules/tokens/approvals/permit/signRemoveLiquidityPermit.tsx @@ -8,6 +8,8 @@ import { PublicWalletClient, RemoveLiquidityQueryOutput, } from '@balancer/sdk' +import { isBoosted } from '@repo/lib/modules/pool/pool.helpers' +import { Pool } from '@repo/lib/modules/pool/PoolProvider' export interface PermitRemoveLiquidityInput { account: Address @@ -19,16 +21,18 @@ type Params = { sdkClient?: PublicWalletClient permitInput: PermitRemoveLiquidityInput wethIsEth: boolean + pool: Pool } export async function signRemoveLiquidityPermit({ wethIsEth, sdkClient, permitInput, + pool, }: Params): Promise { if (!sdkClient) return undefined try { - const signature = await signPermit({ permitInput, wethIsEth, sdkClient }) + const signature = await signPermit({ permitInput, wethIsEth, sdkClient, pool }) return signature } catch (e: unknown) { const error = ensureError(e) @@ -39,13 +43,18 @@ export async function signRemoveLiquidityPermit({ } } -async function signPermit({ permitInput, wethIsEth, sdkClient }: Params): Promise { +async function signPermit({ permitInput, wethIsEth, sdkClient, pool }: Params): Promise { const baseInput = constructRemoveBaseBuildCallInput({ wethIsEth, slippagePercent: permitInput.slippagePercent, sdkQueryOutput: permitInput.sdkQueryOutput as RemoveLiquidityQueryOutput, }) - const signature = await PermitHelper.signRemoveLiquidityApproval({ + + const signPermitFn = isBoosted(pool) + ? PermitHelper.signRemoveLiquidityBoostedApproval + : PermitHelper.signRemoveLiquidityApproval + + const signature = await signPermitFn({ ...baseInput, /* eslint-disable @typescript-eslint/no-non-null-assertion */ client: sdkClient!, diff --git a/packages/lib/modules/tokens/approvals/permit/useSignPermit.tsx b/packages/lib/modules/tokens/approvals/permit/useSignPermit.tsx index 33bac55ce..c0302928a 100644 --- a/packages/lib/modules/tokens/approvals/permit/useSignPermit.tsx +++ b/packages/lib/modules/tokens/approvals/permit/useSignPermit.tsx @@ -57,6 +57,7 @@ export function useSignPermit({ sdkQueryOutput: queryOutput.sdkQueryOutput, }, wethIsEth, + pool, }) if (signature) { diff --git a/packages/lib/modules/tokens/approvals/permit2/signPermit2Add.tsx b/packages/lib/modules/tokens/approvals/permit2/signPermit2Add.tsx index 65fd229d9..4f1918c35 100644 --- a/packages/lib/modules/tokens/approvals/permit2/signPermit2Add.tsx +++ b/packages/lib/modules/tokens/approvals/permit2/signPermit2Add.tsx @@ -14,6 +14,7 @@ import { NoncesByTokenAddress } from './usePermit2Allowance' import { constructBaseBuildCallInput } from '@repo/lib/modules/pool/actions/add-liquidity/handlers/add-liquidity.utils' import { GqlChain } from '@repo/lib/shared/services/api/generated/graphql' import { isWrappedNativeAsset } from '../../token.helpers' +import { isBoosted } from '@repo/lib/modules/pool/pool.helpers' type SignPermit2AddParams = { sdkClient?: PublicWalletClient @@ -27,6 +28,7 @@ type SignPermit2AddParams = { } export async function signPermit2Add(params: SignPermit2AddParams): Promise { if (!params.nonces) throw new Error('Missing nonces in signPermitAdd') + try { const signature = await sign(params) return signature @@ -66,7 +68,11 @@ async function sign({ amountsIn: sdkQueryOutput.amountsIn, }) - const signature = await Permit2Helper.signAddLiquidityApproval({ + const signFn = isBoosted(pool) + ? Permit2Helper.signAddLiquidityBoostedApproval + : Permit2Helper.signAddLiquidityApproval + + const signature = await signFn({ ...baseInput, client: sdkClient, owner: account, diff --git a/packages/lib/modules/tokens/approvals/permit2/signPermit2Swap.tsx b/packages/lib/modules/tokens/approvals/permit2/signPermit2Swap.tsx index 2427e80c7..88acb911b 100644 --- a/packages/lib/modules/tokens/approvals/permit2/signPermit2Swap.tsx +++ b/packages/lib/modules/tokens/approvals/permit2/signPermit2Swap.tsx @@ -54,10 +54,12 @@ async function sign({ const MaxAllowance = MaxUint159 const maximizedQueryOutput = { ...queryOutput } - if (maximizedQueryOutput.swapKind === SwapKind.GivenIn) - {maximizedQueryOutput.amountIn.amount = MaxAllowance} - if (maximizedQueryOutput.swapKind === SwapKind.GivenOut) - {maximizedQueryOutput.amountOut.amount = MaxAllowance} + if (maximizedQueryOutput.swapKind === SwapKind.GivenIn) { + maximizedQueryOutput.amountIn.amount = MaxAllowance + } + if (maximizedQueryOutput.swapKind === SwapKind.GivenOut) { + maximizedQueryOutput.amountOut.amount = MaxAllowance + } const signature = await Permit2Helper.signSwapApproval({ client: sdkClient, diff --git a/packages/lib/shared/services/api/pool.graphql b/packages/lib/shared/services/api/pool.graphql index c175cc189..6b1ea97f0 100644 --- a/packages/lib/shared/services/api/pool.graphql +++ b/packages/lib/shared/services/api/pool.graphql @@ -228,6 +228,13 @@ fragment PoolTokens on GqlPoolTokenDetail { index address decimals + isErc4626 + underlyingToken { + address + decimals + name + symbol + } } } isErc4626