From 235dd395f01a2e334060c843aed90dae73df2a35 Mon Sep 17 00:00:00 2001 From: Alberto Gualis Date: Thu, 19 Dec 2024 16:40:31 +0100 Subject: [PATCH] refactor: pool token helpers (#358) * refactor: add tests for different pool examples * extract 3 describes --- .../lib/modules/pool/__mocks__/getPoolMock.ts | 5 + .../pool/__mocks__/pool-examples/boosted.ts | 24 +++ .../pool/__mocks__/pool-examples/flat.ts | 44 ++++ .../pool/__mocks__/pool-examples/nested.ts | 16 ++ .../pool-examples/pool-example-helpers.ts | 10 + packages/lib/modules/pool/pool.types.ts | 5 +- .../pool/pool.utils.integration.spec.ts | 196 ++++++++++++++++++ packages/lib/modules/tokens/token.helpers.ts | 4 +- 8 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 packages/lib/modules/pool/__mocks__/pool-examples/boosted.ts create mode 100644 packages/lib/modules/pool/__mocks__/pool-examples/flat.ts create mode 100644 packages/lib/modules/pool/__mocks__/pool-examples/nested.ts create mode 100644 packages/lib/modules/pool/__mocks__/pool-examples/pool-example-helpers.ts create mode 100644 packages/lib/modules/pool/pool.utils.integration.spec.ts diff --git a/packages/lib/modules/pool/__mocks__/getPoolMock.ts b/packages/lib/modules/pool/__mocks__/getPoolMock.ts index b866cf359..2a6a1cbe2 100644 --- a/packages/lib/modules/pool/__mocks__/getPoolMock.ts +++ b/packages/lib/modules/pool/__mocks__/getPoolMock.ts @@ -12,6 +12,7 @@ import { } from '@repo/lib/shared/services/api/generated/graphql' import { nested50WETH_50_3poolId } from '@repo/lib/debug-helpers' import { Address } from 'viem' +import { PoolExample } from './pool-examples/flat' function astToQueryString(ast: any): string { return print(ast) @@ -47,3 +48,7 @@ export async function getPoolMock( return getPoolQuery.pool as GqlPoolElement } + +export function getPoolForTest(poolExample: PoolExample): Promise { + return getPoolMock(poolExample.poolId, poolExample.poolChain) +} diff --git a/packages/lib/modules/pool/__mocks__/pool-examples/boosted.ts b/packages/lib/modules/pool/__mocks__/pool-examples/boosted.ts new file mode 100644 index 000000000..a5752667b --- /dev/null +++ b/packages/lib/modules/pool/__mocks__/pool-examples/boosted.ts @@ -0,0 +1,24 @@ +import { GqlChain } from '@repo/lib/shared/services/api/generated/graphql' +import { PoolExample } from './flat' + +export const v3SepoliaNestedBoosted: PoolExample = { + description: 'Edge case: V3 Nested Boosted', + poolId: '0x693cc6a39bbf35464f53d6a5dbf7d6c2fa93741c', + poolChain: GqlChain.Sepolia, + version: 2, +} + +export const morphoStakeHouse: PoolExample = { + description: 'Edge case: boosted with custom morpho stuff', + poolId: '0x5dd88b3aa3143173eb26552923922bdf33f50949', + poolChain: GqlChain.Mainnet, + version: 3, +} + +export const sDAIBoosted: PoolExample = { + description: + 'Edge case: BOOSTED with 2 ERC4626 tokens but one of them (sDAI) has isBufferAllowed == false', + poolId: '0xd1d7fa8871d84d0e77020fc28b7cd5718c446522', + poolChain: GqlChain.Gnosis, + version: 3, +} diff --git a/packages/lib/modules/pool/__mocks__/pool-examples/flat.ts b/packages/lib/modules/pool/__mocks__/pool-examples/flat.ts new file mode 100644 index 000000000..ff0b355a5 --- /dev/null +++ b/packages/lib/modules/pool/__mocks__/pool-examples/flat.ts @@ -0,0 +1,44 @@ +import { GqlChain } from '@repo/lib/shared/services/api/generated/graphql' +import { Address } from 'viem' +import { ProtocolVersion } from '../../pool.types' + +export type PoolExample = { + description?: string + // Explicit pool prefix to make tests more readable + poolId: Address + poolAddress?: Address + poolChain: GqlChain + version: ProtocolVersion +} + +export const balWeth8020: PoolExample = { + description: 'Weighted OG', + poolId: '0x5c6ee304399dbdb9c8ef030ab642b10820db8f56000200000000000000000014', + poolAddress: '0x5c6ee304399dbdb9c8ef030ab642b10820db8f56', + poolChain: GqlChain.Mainnet, + version: 2, +} + +export const osETHPhantom: PoolExample = { + description: + 'Edge case: Phantom composable stable where the pool itself appears in one of the tokens', + poolId: '0xdacf5fa19b1f720111609043ac67a9818262850c000000000000000000000635', + poolChain: GqlChain.Mainnet, + version: 2, +} + +// TODO: use it in actionable tests +// THIS is A WRONG TEST CAUSE IT IS V2, we need V3 +export const sDAIWeighted: PoolExample = { + description: 'Edge case: sDAI is ERC4626 but has isBufferAllowed == false', + poolId: '0xbc2acf5e821c5c9f8667a36bb1131dad26ed64f9000200000000000000000063', + poolChain: GqlChain.Gnosis, + version: 2, +} + +export const v2SepoliaStableWithERC4626: PoolExample = { + description: 'It has ERC4626 (usdc-aave and dai-aave) tokens but it is V2 so it is not boosted', + poolId: '0x6c3966874f49a2f6a8f2f791f82f65b214e90ccb0000000000000000000001a6', + poolChain: GqlChain.Sepolia, + version: 3, +} diff --git a/packages/lib/modules/pool/__mocks__/pool-examples/nested.ts b/packages/lib/modules/pool/__mocks__/pool-examples/nested.ts new file mode 100644 index 000000000..b34d70afd --- /dev/null +++ b/packages/lib/modules/pool/__mocks__/pool-examples/nested.ts @@ -0,0 +1,16 @@ +import { GqlChain } from '@repo/lib/shared/services/api/generated/graphql' +import { PoolExample } from './flat' + +export const staBALv2Nested: PoolExample = { + description: 'V2 Nested supporting nested actions (default)', + poolId: '0x66888e4f35063ad8bb11506a6fde5024fb4f1db0000100000000000000000053', + poolChain: GqlChain.Gnosis, + version: 2, +} + +export const auraBal: PoolExample = { + description: 'Edge case: Must use nested 8020 BPT to add (does not support nested actions)', + poolId: '0x3dd0843a028c86e0b760b1a76929d1c5ef93a2dd000200000000000000000249', + poolChain: GqlChain.Mainnet, + version: 2, +} diff --git a/packages/lib/modules/pool/__mocks__/pool-examples/pool-example-helpers.ts b/packages/lib/modules/pool/__mocks__/pool-examples/pool-example-helpers.ts new file mode 100644 index 000000000..590c9100c --- /dev/null +++ b/packages/lib/modules/pool/__mocks__/pool-examples/pool-example-helpers.ts @@ -0,0 +1,10 @@ +import { ApiToken } from '../../pool.types' + +// TODO: move to testing utils +export function tokenSymbols(apiTokens: ApiToken[]): string[] { + return apiTokens.map(token => token.symbol).sort() +} + +export function underlyingTokenSymbols(apiTokens: ApiToken[]): string[] { + return apiTokens.map(token => token.underlyingToken?.symbol || '-').sort() +} diff --git a/packages/lib/modules/pool/pool.types.ts b/packages/lib/modules/pool/pool.types.ts index 00730481f..309a33c10 100644 --- a/packages/lib/modules/pool/pool.types.ts +++ b/packages/lib/modules/pool/pool.types.ts @@ -146,7 +146,10 @@ export type TokenCore = { index: number } -export type ApiToken = Omit +export type ApiToken = Omit & { + nestedTokens?: ApiToken[] + underlyingToken?: ApiToken +} export enum PoolListDisplayType { Name = 'name', diff --git a/packages/lib/modules/pool/pool.utils.integration.spec.ts b/packages/lib/modules/pool/pool.utils.integration.spec.ts new file mode 100644 index 000000000..2663d79e2 --- /dev/null +++ b/packages/lib/modules/pool/pool.utils.integration.spec.ts @@ -0,0 +1,196 @@ +import { GqlNestedPool } from '@repo/lib/shared/services/api/generated/graphql' +import { isSameAddress } from '@repo/lib/shared/utils/addresses' +import { sortBy } from 'lodash' +import { Address } from 'viem' +import { PoolToken } from '../tokens/token.helpers' +import { getPoolForTest } from './__mocks__/getPoolMock' +import { + morphoStakeHouse, + sDAIBoosted, + v3SepoliaNestedBoosted, +} from './__mocks__/pool-examples/boosted' +import { + balWeth8020, + osETHPhantom, + PoolExample, + sDAIWeighted, + v2SepoliaStableWithERC4626, +} from './__mocks__/pool-examples/flat' +import { auraBal, staBALv2Nested } from './__mocks__/pool-examples/nested' +import { + tokenSymbols, + underlyingTokenSymbols, +} from './__mocks__/pool-examples/pool-example-helpers' +import { isV3Pool } from './pool.helpers' +import { ApiToken } from './pool.types' +import { getPoolDisplayTokensWithPossibleNestedPools } from './pool.utils' +import { Pool } from './PoolProvider' + +function isPool(pool: any): pool is Pool { + return (pool as Pool).poolTokens !== undefined +} + +function isGqlNestedPool(pool: any): pool is GqlNestedPool { + return (pool as GqlNestedPool).tokens !== undefined +} + +function getPoolTokens(pool: Pool | GqlNestedPool) { + if (isPool(pool)) { + return pool.poolTokens + } + if (isGqlNestedPool(pool)) { + return pool.tokens + } + throw new Error('Invalid pool type: poolTokens or tokens but be defined') +} + +// TODO: extract this pool helpers or utils +function getDisplayTokens(pool: Pool | GqlNestedPool): ApiToken[] { + const tokens = getPoolTokens(pool).map(token => { + if (token.hasNestedPool && token.nestedPool) { + return { + ...token, + nestedTokens: getDisplayTokens(token.nestedPool as GqlNestedPool), + } as ApiToken + } + return token as ApiToken + }) + + return sortBy(excludeNestedBptTokens(tokens as ApiToken[], pool.address), 'symbol') +} + +function getHeaderDisplayTokens(pool: Pool): ApiToken[] { + // excludeNestedBptTokens(pool.poolTokens, pool.address) //TODO: do we need this case?? How is Panthom displayed after API fix? + if (isV3Pool(pool) && pool.hasErc4626 && pool.hasAnyAllowedBuffer) { + return pool.poolTokens.map(token => + token.isErc4626 && token.isBufferAllowed + ? ({ ...token, ...token.underlyingToken } as ApiToken) + : (token as ApiToken) + ) + } + // Is this correct? + return getDisplayTokens(pool) +} + +function excludeNestedBptTokens(tokens: PoolToken[] | ApiToken[], poolAddress: string): ApiToken[] { + return tokens + .filter(token => !isSameAddress(token.address, poolAddress as Address)) // Exclude the BPT pool token itself + .filter(token => token !== undefined) as ApiToken[] +} + +// Testing utils that can be kept in the test: +async function getDisplaySymbols(poolExample: PoolExample): Promise { + const pool = await getPoolForTest(poolExample) + + return tokenSymbols(getDisplayTokens(pool)) +} + +async function getDisplayTokensFromPoolExample(poolExample: PoolExample): Promise { + const pool = await getPoolForTest(poolExample) + return getDisplayTokens(pool) +} + +async function getHeaderDisplaySymbols(poolExample: PoolExample): Promise { + const pool = await getPoolForTest(poolExample) + + return tokenSymbols(getHeaderDisplayTokens(pool)) +} + +async function getBoostedUnderlyingTokenSymbols(poolExample: PoolExample): Promise { + const displayTokens = await getDisplayTokensFromPoolExample(poolExample) + + return underlyingTokenSymbols(displayTokens) +} + +async function getNestedTokenSymbols(poolExample: PoolExample): Promise { + const displayTokens = await getDisplayTokensFromPoolExample(poolExample) + + return displayTokens + .filter(token => token.nestedTokens) + .flatMap(token => token.nestedTokens?.map(t => t.symbol) || []) +} + +async function getOldCompositionDisplaySymbols(poolExample: PoolExample): Promise { + const pool = await getPoolForTest(poolExample) + + return tokenSymbols(getPoolDisplayTokensWithPossibleNestedPools(pool) as ApiToken[]) +} + +describe('getDisplayTokens for flat pools', () => { + it('BAL WETH 80 20', async () => { + expect(await getDisplaySymbols(balWeth8020)).toEqual(['BAL', 'WETH']) + }) + + it('osETH Phantom Composable Stable', async () => { + expect(await getDisplaySymbols(osETHPhantom)).toEqual(['WETH', 'osETH']) + }) + + it('sDAI weighted', async () => { + expect(await getDisplaySymbols(sDAIWeighted)).toEqual(['sDAI', 'wstETH']) + }) + + it.skip('v2 stable with ERC4626 tokens (V2 so no boosted)', async () => { + expect(await getDisplaySymbols(v2SepoliaStableWithERC4626)).toEqual(['dai-aave', 'usdc-aave']) + }) +}) + +describe('getDisplayTokens for NESTED pools', () => { + it('v2 nested', async () => { + expect(await getDisplaySymbols(staBALv2Nested)).toEqual(['WBTC', 'WETH', 'staBAL3']) + + expect(await getHeaderDisplaySymbols(staBALv2Nested)).toEqual(['WBTC', 'WETH', 'staBAL3']) + + // TODO: merge this function logic into getDisplayTokens above and getPoolDisplayTokens(pool: Pool | PoolListItem) in pool.utils + expect(await getOldCompositionDisplaySymbols(staBALv2Nested)).toEqual([ + 'USDC', + 'USDT', + 'WBTC', + 'WETH', + 'WXDAI', + 'staBAL3', + ]) + + expect(await getNestedTokenSymbols(staBALv2Nested)).toEqual(['USDC', 'USDT', 'WXDAI']) + }) + + it('aura bal (Nested with supportsNestedActions false)', async () => { + expect(await getDisplaySymbols(auraBal)).toEqual(['B-80BAL-20WETH', 'auraBAL']) + }) +}) + +describe('getDisplayTokens for BOOSTED pools', () => { + it('Morpho boosted', async () => { + expect(await getDisplaySymbols(morphoStakeHouse)).toEqual(['csUSDL', 'steakUSDC']) + + // Only case where pool composition and header do not match + expect(await getHeaderDisplaySymbols(morphoStakeHouse)).toEqual(['USDC', 'wUSDL']) + + expect(await getBoostedUnderlyingTokenSymbols(morphoStakeHouse)).toEqual(['USDC', 'wUSDL']) + }) + + it('sDAI boosted', async () => { + expect(await getDisplaySymbols(sDAIBoosted)).toEqual(['sDAI', 'waGnoGNO']) + // Only case where pool composition and header do not match + expect(await getHeaderDisplaySymbols(sDAIBoosted)).toEqual(['GNO', 'sDAI']) + }) + + // unskip when we have a non-sepolia nested v3 + it.skip('v3 nested boosted', async () => { + expect(await getDisplaySymbols(v3SepoliaNestedBoosted)).toEqual(['WETH', 'bb-a-USD']) + + expect(await getHeaderDisplaySymbols(v3SepoliaNestedBoosted)).toEqual(['WETH', 'bb-a-USD']) // DO WE WANT THIS in the header? + + const lpToken = getDisplayTokens(await getPoolForTest(v3SepoliaNestedBoosted))[1] + expect(tokenSymbols(lpToken.nestedTokens as ApiToken[])).toEqual([ + 'stataEthUSDC', + 'stataEthUSDT', + ]) + + // TODO: merge this function logic into getDisplayTokens above and getPoolDisplayTokens(pool: Pool | PoolListItem) in pool.utils + expect(await getOldCompositionDisplaySymbols(v3SepoliaNestedBoosted)).toEqual([ + 'WETH', + 'stataEthUSDC', + 'stataEthUSDT', + ]) + }) +}) diff --git a/packages/lib/modules/tokens/token.helpers.ts b/packages/lib/modules/tokens/token.helpers.ts index 916e0c86f..1f470339f 100644 --- a/packages/lib/modules/tokens/token.helpers.ts +++ b/packages/lib/modules/tokens/token.helpers.ts @@ -154,7 +154,7 @@ export function getSpenderForAddLiquidity(pool: Pool): Address { return vaultAddress } -function buildApiToken(poolToken: PoolToken) { +function buildApiToken(poolToken: PoolToken): ApiToken { return { ...poolToken, // The following fields are (wrongly) optional in the generated PoolToken schema so we have to cast them to satisfy TS @@ -163,5 +163,5 @@ function buildApiToken(poolToken: PoolToken) { chain: poolToken.chain as GqlChain, priority: poolToken.priority as number, tradable: poolToken.tradable as boolean, - } + } as ApiToken }