diff --git a/src/entities/index.ts b/src/entities/index.ts index 7d03ba84..9e978937 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -2,6 +2,7 @@ export * from './encoders'; export * from './join'; export * from './exit'; export * from './nestedJoin'; +export * from './nestedExit'; export * from './path'; export * from './swap'; export * from './slippage'; diff --git a/src/entities/nestedExit/doQueryNestedExit.ts b/src/entities/nestedExit/doQueryNestedExit.ts new file mode 100644 index 00000000..0705d7e3 --- /dev/null +++ b/src/entities/nestedExit/doQueryNestedExit.ts @@ -0,0 +1,42 @@ +import { + createPublicClient, + decodeAbiParameters, + decodeFunctionResult, + http, +} from 'viem'; +import { Address, Hex } from '../../types'; +import { BALANCER_RELAYER, CHAINS } from '../../utils'; +import { balancerRelayerAbi } from '../../abi'; + +export const doQueryNestedExit = async ( + chainId: number, + rpcUrl: string, + accountAddress: Address, + encodedMulticall: Hex, + tokensOutLength: number, +): Promise => { + const client = createPublicClient({ + transport: http(rpcUrl), + chain: CHAINS[chainId], + }); + + const { data } = await client.call({ + account: accountAddress, + to: BALANCER_RELAYER[chainId], + data: encodedMulticall, + }); + + const result = decodeFunctionResult({ + abi: balancerRelayerAbi, + functionName: 'vaultActionsQueryMulticall', + data: data as Hex, + }); + + const resultsToPeek = result.slice(result.length - tokensOutLength); + + const peekedValues = resultsToPeek.map( + (r) => decodeAbiParameters([{ type: 'uint256' }], r)[0], + ); + + return peekedValues; +}; diff --git a/src/entities/nestedExit/getNestedExitCalls.ts b/src/entities/nestedExit/getNestedExitCalls.ts new file mode 100644 index 00000000..c0517843 --- /dev/null +++ b/src/entities/nestedExit/getNestedExitCalls.ts @@ -0,0 +1,196 @@ +import { Token } from '../token'; +import { NestedExitInput, NestedExitCall } from './types'; +import { NestedPool, NestedPoolState } from '../types'; +import { TokenAmount } from '../tokenAmount'; +import { Address } from '../../types'; + +export const getNestedExitCalls = ( + { + bptAmountIn, + chainId, + accountAddress, + tokenOut, + useNativeAssetAsWrappedAmountOut = false, + toInternalBalance = false, + }: NestedExitInput, + { pools }: NestedPoolState, +): { bptAmountIn: TokenAmount; calls: NestedExitCall[] } => { + const isProportional = tokenOut === undefined; + let poolsTopDown: NestedPool[]; + let calls: NestedExitCall[]; + + if (isProportional) { + /** + * Overall logic to build sequence of proportional exit calls: + * 1. Go from top pool to bottom filling out input amounts and output refs + * 2. Inputs will be bptAmountIn provided or output of the previous level + * 3. Output at bottom level is the amountsOut + */ + + // sort pools by descending level + poolsTopDown = pools.sort((a, b) => b.level - a.level); + + calls = getProportionalExitCalls( + poolsTopDown, + chainId, + useNativeAssetAsWrappedAmountOut, + accountAddress, + bptAmountIn, + toInternalBalance, + ); + } else { + /** + * Overall logic to build sequence of single token exit calls: + * 1. Go BOTTOM-UP building exit path to tokenOut + * 2. Go through exit path filling out input amounts and output refs + * 3. Inputs will be bptAmountIn provided or output of the previous level + * 4. Output at bottom level is the amountOut + */ + + // sort pools by descending level + poolsTopDown = pools.sort((a, b) => b.level - a.level); + const topPool = poolsTopDown[0]; + + // Go BOTTOM-UP building exit path to tokenOut + const exitPath: NestedPool[] = []; + let tokenOutByLevel = tokenOut; + while (tokenOutByLevel !== topPool.address) { + const currentPool = poolsTopDown.find( + (p) => + p.address !== tokenOutByLevel && // prevents pools with BPT as token to be picked up incorrectly + p.tokens.some((t) => t.address === tokenOutByLevel), + ) as NestedPool; + exitPath.unshift(currentPool); + tokenOutByLevel = currentPool.address; + } + + calls = getSingleTokenExitCalls( + exitPath, + chainId, + useNativeAssetAsWrappedAmountOut, + accountAddress, + bptAmountIn, + toInternalBalance, + tokenOut, + ); + } + + const bptIn = new Token(chainId, poolsTopDown[0].address, 18); + const _bptAmountIn = TokenAmount.fromRawAmount(bptIn, bptAmountIn); + return { calls, bptAmountIn: _bptAmountIn }; +}; + +export const getProportionalExitCalls = ( + poolsSortedByLevel: NestedPool[], + chainId: number, + useNativeAssetAsWrappedAmountOut: boolean, + accountAddress: Address, + bptAmountIn: bigint, + toInternalBalance: boolean, +) => { + const calls: NestedExitCall[] = []; + for (const pool of poolsSortedByLevel) { + const sortedTokens = pool.tokens + .sort((a, b) => a.index - b.index) + .map((t) => new Token(chainId, t.address, t.decimals)); + + // const sortedTokensWithoutBpt = sortedTokens.filter( + // (t) => !t.isSameAddress(pool.address), + // ); + const upperLevelCall = calls.find((call) => + call.sortedTokens + .map((token) => token.address) + .includes(pool.address), + ); + calls.push({ + chainId: chainId, + useNativeAssetAsWrappedAmountOut, + sortedTokens, + poolId: pool.id, + poolType: pool.type, + kind: pool.type === 'ComposableStable' ? 3 : 0, + sender: accountAddress, + recipient: accountAddress, + bptAmountIn: + upperLevelCall === undefined + ? { + amount: bptAmountIn, + isRef: false, + } + : { + amount: upperLevelCall.outputReferenceKeys[ + upperLevelCall.sortedTokens + .map((token) => token.address) + .indexOf(pool.address) + ], + isRef: true, + }, + minAmountsOut: Array(sortedTokens.length).fill(0n), + toInternalBalance, + // TODO: previous implementation of nested exit didn't add an outputReferenceKey for the BPT token, + // but if I remove it from here, peek logic fails. Need to investigate why. + // Once we figure this out, we should be able to replace sortedTokens by sortedTokensWithoutBpt + outputReferenceKeys: sortedTokens.map( + (token) => + 100n + + BigInt(poolsSortedByLevel.indexOf(pool)) * 10n + + BigInt(sortedTokens.indexOf(token)), + ), + }); + } + return calls; +}; + +export const getSingleTokenExitCalls = ( + exitPath: NestedPool[], + chainId: number, + useNativeAssetAsWrappedAmountOut: boolean, + accountAddress: Address, + bptAmountIn: bigint, + toInternalBalance: boolean, + tokenOut: Address, +) => { + const calls: NestedExitCall[] = []; + + for (let i = 0; i < exitPath.length; i++) { + const pool = exitPath[i]; + const sortedTokens = pool.tokens + .sort((a, b) => a.index - b.index) + .map((t) => new Token(chainId, t.address, t.decimals)); + const upperLevelCall = i > 0 ? calls[i] : undefined; + const currenTokenOut = + i === exitPath.length - 1 ? tokenOut : exitPath[i + 1].address; + const tokenOutIndex = sortedTokens.findIndex((t) => + t.isSameAddress(currenTokenOut), + ); + calls.push({ + chainId: chainId, + useNativeAssetAsWrappedAmountOut, + sortedTokens, + poolId: pool.id, + poolType: pool.type, + kind: pool.type === 'ComposableStable' ? 3 : 0, + sender: accountAddress, + recipient: accountAddress, + bptAmountIn: + upperLevelCall === undefined + ? { + amount: bptAmountIn, + isRef: false, + } + : { + amount: upperLevelCall.outputReferenceKeys[0], + isRef: true, + }, + minAmountsOut: Array(sortedTokens.length).fill(0n), + toInternalBalance, + outputReferenceKeys: [ + 100n + + BigInt(exitPath.indexOf(pool)) * 10n + + BigInt(tokenOutIndex), + ], + tokenOutIndex, + }); + } + return calls; +}; diff --git a/src/entities/nestedExit/getPeekCalls.ts b/src/entities/nestedExit/getPeekCalls.ts new file mode 100644 index 00000000..b8fb0406 --- /dev/null +++ b/src/entities/nestedExit/getPeekCalls.ts @@ -0,0 +1,49 @@ +import { Hex } from 'viem'; +import { Token } from '../token'; +import { NestedExitCall } from './types'; +import { Relayer } from '../relayer'; +import { getPoolAddress } from '../../utils'; + +export const getPeekCalls = (calls: NestedExitCall[]) => { + const tokensOut: Token[] = []; + const peekCalls: Hex[] = []; + + const isSingleTokenExit = + calls[calls.length - 1].tokenOutIndex !== undefined; + if (isSingleTokenExit) { + const lastCall = calls[calls.length - 1]; + const tokenOut = + lastCall.sortedTokens[lastCall.tokenOutIndex as number]; + tokensOut.push(tokenOut); + peekCalls.push( + Relayer.encodePeekChainedReferenceValue( + Relayer.toChainedReference( + lastCall.outputReferenceKeys[0], + false, + ), + ), + ); + } else { + calls.forEach((call) => { + call.outputReferenceKeys.forEach((opRefKey) => { + const tokenOut = call.sortedTokens[Number(opRefKey % 10n)]; + const isTokenBeingUsedAsInput = calls.some( + (_call) => + _call.bptAmountIn.isRef === true && + tokenOut.isSameAddress(getPoolAddress(_call.poolId)), + ); + + if (!isTokenBeingUsedAsInput) { + tokensOut.push(tokenOut); + peekCalls.push( + Relayer.encodePeekChainedReferenceValue( + Relayer.toChainedReference(opRefKey, false), + ), + ); + } + }); + }); + } + + return { peekCalls, tokensOut }; +}; diff --git a/src/entities/nestedExit/index.ts b/src/entities/nestedExit/index.ts new file mode 100644 index 00000000..b154d78d --- /dev/null +++ b/src/entities/nestedExit/index.ts @@ -0,0 +1,139 @@ +import { encodeFunctionData } from 'viem'; +import { Address, Hex } from '../../types'; +import { BALANCER_RELAYER, getPoolAddress } from '../../utils'; +import { Relayer } from '../relayer'; +import { TokenAmount } from '../tokenAmount'; +import { balancerRelayerAbi, bathcRelayerLibraryAbi } from '../../abi'; +import { + NestedExitInput, + NestedExitQueryResult, + NestedExitCallInput, +} from './types'; +import { NestedPoolState } from '../types'; +import { doQueryNestedExit } from './doQueryNestedExit'; +import { getNestedExitCalls } from './getNestedExitCalls'; +import { parseNestedExitCall } from './parseNestedExitCall'; +import { getPeekCalls } from './getPeekCalls'; + +export class NestedExit { + async query( + input: NestedExitInput, + nestedPoolState: NestedPoolState, + ): Promise { + const { calls, bptAmountIn } = getNestedExitCalls( + input, + nestedPoolState, + ); + + const parsedCalls = calls.map((call) => parseNestedExitCall(call)); + + const encodedCalls = parsedCalls.map((parsedCall) => + encodeFunctionData({ + abi: bathcRelayerLibraryAbi, + functionName: 'exitPool', + args: parsedCall.args, + }), + ); + + const { peekCalls, tokensOut } = getPeekCalls(calls); + + // append peek calls to get amountsOut + encodedCalls.push(...peekCalls); + + const encodedMulticall = encodeFunctionData({ + abi: balancerRelayerAbi, + functionName: 'vaultActionsQueryMulticall', + args: [encodedCalls], + }); + + const peekedValues = await doQueryNestedExit( + input.chainId, + input.rpcUrl, + input.accountAddress, + encodedMulticall, + tokensOut.length, + ); + + console.log('peekedValues ', peekedValues); + + const amountsOut = tokensOut.map((tokenOut, i) => + TokenAmount.fromRawAmount(tokenOut, peekedValues[i]), + ); + + return { calls, bptAmountIn, amountsOut }; + } + + buildCall(input: NestedExitCallInput): { + call: Hex; + to: Address; + minAmountsOut: TokenAmount[]; + } { + // apply slippage to amountsOut + const minAmountsOut = input.amountsOut.map((amountOut) => + TokenAmount.fromRawAmount( + amountOut.token, + input.slippage.removeFrom(amountOut.amount), + ), + ); + + input.calls.forEach((call) => { + // update relevant calls with minAmountOut limits in place + minAmountsOut.forEach((minAmountOut, j) => { + const minAmountOutIndex = call.sortedTokens.findIndex((t) => + t.isSameAddress(minAmountOut.token.address), + ); + if (minAmountOutIndex !== -1) { + call.minAmountsOut[minAmountOutIndex] = + minAmountsOut[j].amount; + } + }); + + // remove output reference key related to bpt token if present + // TODO: check why query/peek logic needs it + call.outputReferenceKeys = call.outputReferenceKeys.filter( + (_, i) => { + const token = call.sortedTokens[i]; + const isBptToken = token.isSameAddress( + getPoolAddress(call.poolId), + ); + return !isBptToken; + }, + ); + }); + + const parsedCalls = input.calls.map((call) => + parseNestedExitCall(call), + ); + + const encodedCalls = parsedCalls.map((parsedCall) => + encodeFunctionData({ + abi: bathcRelayerLibraryAbi, + functionName: 'exitPool', + args: parsedCall.args, + }), + ); + + // prepend relayer approval if provided + if (input.relayerApprovalSignature !== undefined) { + encodedCalls.unshift( + Relayer.encodeSetRelayerApproval( + BALANCER_RELAYER[input.chainId], + true, + input.relayerApprovalSignature, + ), + ); + } + + const call = encodeFunctionData({ + abi: balancerRelayerAbi, + functionName: 'multicall', + args: [encodedCalls], + }); + + return { + call, + to: BALANCER_RELAYER[input.chainId], + minAmountsOut, + }; + } +} diff --git a/src/entities/nestedExit/parseNestedExitCall.ts b/src/entities/nestedExit/parseNestedExitCall.ts new file mode 100644 index 00000000..b57e92ef --- /dev/null +++ b/src/entities/nestedExit/parseNestedExitCall.ts @@ -0,0 +1,100 @@ +import { Hex } from '../../types'; +import { WeightedEncoder } from '../encoders'; +import { ComposableStableEncoder } from '../encoders/composableStable'; +import { NestedExitCall } from './types'; +import { Relayer } from '../relayer'; +import { replaceWrapped } from '../utils/replaceWrapped'; + +export const parseNestedExitCall = ({ + useNativeAssetAsWrappedAmountOut, + chainId, + sortedTokens, + poolId, + poolType, + kind, + sender, + recipient, + bptAmountIn, + minAmountsOut, + toInternalBalance, + outputReferenceKeys, + tokenOutIndex, +}: NestedExitCall) => { + // replace wrapped token with native asset if needed + let tokensOut = [...sortedTokens]; + if (chainId && useNativeAssetAsWrappedAmountOut) { + tokensOut = replaceWrapped([...sortedTokens], chainId); + } + + const _bptAmountIn = bptAmountIn.isRef + ? Relayer.toChainedReference(bptAmountIn.amount) + : bptAmountIn.amount; + + let userData: Hex; + let outputReferences: { index: bigint; key: bigint }[] = []; + + if (tokenOutIndex === undefined) { + // Proportional Exit + switch (poolType) { + case 'Weighted': + userData = WeightedEncoder.exitProportional(_bptAmountIn); + break; + case 'ComposableStable': + userData = + ComposableStableEncoder.exitProportional(_bptAmountIn); + break; + default: + throw new Error('Unsupported pool type'); + } + + outputReferences = outputReferenceKeys.map((k) => { + const tokenIndex = k % 10n; + return { + index: tokenIndex, + key: Relayer.toChainedReference(k), + }; + }); + } else { + // Single Token Exit + switch (poolType) { + case 'Weighted': + userData = WeightedEncoder.exitSingleAsset( + _bptAmountIn, + tokenOutIndex, + ); + break; + case 'ComposableStable': + userData = ComposableStableEncoder.exitSingleAsset( + _bptAmountIn, + tokenOutIndex, + ); + break; + default: + throw new Error('Unsupported pool type'); + } + outputReferences = [ + { + index: BigInt(tokenOutIndex), + key: Relayer.toChainedReference(outputReferenceKeys[0]), + }, + ]; + } + + const exitPoolRequest = { + assets: tokensOut.map((t) => t.address), // with BPT + minAmountsOut, // with BPT + userData, // wihtout BPT + toInternalBalance, + }; + + return { + args: [ + poolId, + kind, + sender, + recipient, + exitPoolRequest, + outputReferences, + ] as const, + }; +}; diff --git a/src/entities/nestedExit/types.ts b/src/entities/nestedExit/types.ts new file mode 100644 index 00000000..dd298a95 --- /dev/null +++ b/src/entities/nestedExit/types.ts @@ -0,0 +1,47 @@ +import { Address, Hex } from '../../types'; +import { Slippage } from '../slippage'; +import { Token } from '../token'; +import { TokenAmount } from '../tokenAmount'; + +export type NestedExitInput = { + bptAmountIn: bigint; + chainId: number; + rpcUrl: string; + accountAddress: Address; + tokenOut?: Address; + useNativeAssetAsWrappedAmountOut?: boolean; + toInternalBalance?: boolean; +}; + +export type NestedExitCall = { + chainId: number; + useNativeAssetAsWrappedAmountOut: boolean; + sortedTokens: Token[]; + poolId: Address; + poolType: string; + kind: number; + sender: Address; + recipient: Address; + bptAmountIn: { + amount: bigint; + isRef: boolean; + }; + minAmountsOut: bigint[]; + toInternalBalance: boolean; + outputReferenceKeys: bigint[]; + tokenOutIndex?: number; +}; + +export type NestedExitQueryResult = { + calls: NestedExitCall[]; + bptAmountIn: TokenAmount; + amountsOut: TokenAmount[]; +}; + +export type NestedExitCallInput = NestedExitQueryResult & { + chainId: number; + slippage: Slippage; + sender: Address; + recipient: Address; + relayerApprovalSignature?: Hex; +}; diff --git a/src/entities/nestedJoin/getNestedJoinCalls.ts b/src/entities/nestedJoin/getNestedJoinCalls.ts index 82f49b28..1c33120c 100644 --- a/src/entities/nestedJoin/getNestedJoinCalls.ts +++ b/src/entities/nestedJoin/getNestedJoinCalls.ts @@ -1,6 +1,7 @@ import { Token } from '../token'; import { getPoolAddress } from '../../utils'; -import { NestedJoinInput, NestedPoolState, NestedJoinCall } from './types'; +import { NestedJoinInput, NestedJoinCall } from './types'; +import { NestedPoolState } from '../types'; export const getNestedJoinCalls = ( { diff --git a/src/entities/nestedJoin/index.ts b/src/entities/nestedJoin/index.ts index 6f8c3d71..19473386 100644 --- a/src/entities/nestedJoin/index.ts +++ b/src/entities/nestedJoin/index.ts @@ -8,12 +8,12 @@ import { TokenAmount } from '../tokenAmount'; import { balancerRelayerAbi, bathcRelayerLibraryAbi } from '../../abi'; import { NestedJoinInput, - NestedPoolState, NestedJoinQueryResult, NestedJoinCallInput, } from './types'; import { getNestedJoinCalls } from './getNestedJoinCalls'; import { doQueryNestedJoin } from './doQueryNestedJoin'; +import { NestedPoolState } from '../types'; export class NestedJoin { async query( diff --git a/src/entities/nestedJoin/types.ts b/src/entities/nestedJoin/types.ts index 2377b0a5..b70f819b 100644 --- a/src/entities/nestedJoin/types.ts +++ b/src/entities/nestedJoin/types.ts @@ -2,7 +2,6 @@ import { Address, Hex } from '../../types'; import { Slippage } from '../slippage'; import { Token } from '../token'; import { TokenAmount } from '../tokenAmount'; -import { MinimalToken } from '../../data'; export type NestedJoinInput = { amountsIn: { @@ -17,18 +16,6 @@ export type NestedJoinInput = { fromInternalBalance?: boolean; }; -export type NestedPool = { - id: Hex; - address: Address; - type: string; - level: number; // 0 is the bottom and the highest level is the top - tokens: MinimalToken[]; // each token should have at least one -}; - -export type NestedPoolState = { - pools: NestedPool[]; -}; - export type NestedJoinCall = { chainId: number; useNativeAssetAsWrappedAmountIn: boolean; diff --git a/src/entities/types.ts b/src/entities/types.ts index 4be403fe..0b3583c6 100644 --- a/src/entities/types.ts +++ b/src/entities/types.ts @@ -28,3 +28,14 @@ export type AmountsExit = { tokenOutIndex: number | undefined; maxBptAmountIn: bigint; }; +export type NestedPool = { + id: Hex; + address: Address; + type: string; + level: number; // 0 is the bottom and the highest level is the top + tokens: MinimalToken[]; // each token should have at least one +}; + +export type NestedPoolState = { + pools: NestedPool[]; +}; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 186886b2..36baa548 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -128,7 +128,7 @@ export const BALANCER_VAULT: Address = '0xBA12222222228d8Ba445958a75a0704d566BF2C8'; export const BALANCER_RELAYER: Record = { - [ChainId.MAINNET]: '0xa8d297D643a11cE83b432e87eEBce6bee0fd2bAb', + [ChainId.MAINNET]: '0xc775bF567D67018dfFac4E89a7Cf10f0EDd0Be93', }; export const BALANCER_HELPERS: Record = { diff --git a/test/nestedExit.integration.test.ts b/test/nestedExit.integration.test.ts new file mode 100644 index 00000000..fc970c28 --- /dev/null +++ b/test/nestedExit.integration.test.ts @@ -0,0 +1,395 @@ +// pnpm test -- nestedExit.integration.test.ts +import { describe, expect, test, beforeAll } from 'vitest'; +import dotenv from 'dotenv'; +dotenv.config(); + +import { + Client, + createTestClient, + http, + parseUnits, + publicActions, + PublicActions, + TestActions, + WalletActions, + walletActions, +} from 'viem'; + +import { + Slippage, + NestedExit, + replaceWrapped, + NestedPoolState, +} from '../src/entities'; +import { Address } from '../src/types'; + +import { + BALANCER_RELAYER, + BALANCER_VAULT, + CHAINS, + ChainId, +} from '../src/utils'; + +import { + approveToken, + findTokenBalanceSlot, + sendTransactionGetBalances, + setTokenBalance, +} from './lib/utils/helper'; +import { authorizerAbi, vaultAbi } from '../src/abi'; +import { Relayer } from '../src/entities/relayer'; +import { NestedExitInput } from '../src/entities/nestedExit/types'; + +/** + * Deploy the new relayer contract with the new helper address: + * + * in the mono repo: + * cd pkg/standalone-utils + * forge create --rpc-url http://0.0.0.0:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 contracts/BatchRelayerQueryLibrary.sol:BatchRelayerQueryLibrary --constructor-args "0xBA12222222228d8Ba445958a75a0704d566BF2C8" + * + * [take the address] + * + * forge create --rpc-url http://0.0.0.0:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 contracts/relayer/BalancerRelayer.sol:BalancerRelayer --constructor-args "0xBA12222222228d8Ba445958a75a0704d566BF2C8" "0xf77018c0d817da22cadbdf504c00c0d32ce1e5c2" "[paste the address]" "5" + * + * update `BALANCER_RELAYER` on constants.ts + * + */ + +type TxInput = { + bptAmountIn: bigint; + chainId: ChainId; + rpcUrl: string; + testAddress: Address; + nestedExit: NestedExit; + nestedPoolFromApi: NestedPoolState; + client: Client & PublicActions & TestActions & WalletActions; + tokenOut?: Address; + useNativeAssetAsWrappedAmountOut?: boolean; +}; + +describe('nested exit test', () => { + let api: MockApi; + let chainId: ChainId; + let rpcUrl: string; + let client: Client & PublicActions & TestActions & WalletActions; + let poolAddress: Address; + let nestedPoolFromApi: NestedPoolState; + let nestedExit: NestedExit; + let testAddress: Address; + + beforeAll(async () => { + // setup mock api + api = new MockApi(); + + // setup chain and test client + chainId = ChainId.MAINNET; + rpcUrl = 'http://127.0.0.1:8545/'; + client = createTestClient({ + mode: 'hardhat', + chain: CHAINS[chainId], + transport: http(rpcUrl), + }) + .extend(publicActions) + .extend(walletActions); + + testAddress = (await client.getAddresses())[0]; + + poolAddress = '0x08775ccb6674d6bdceb0797c364c2653ed84f384'; // WETH-3POOL-BPT + + // get pool state from api + nestedPoolFromApi = await api.getNestedPool(poolAddress); + + // setup join helper + nestedExit = new NestedExit(); + + // Fork setup - done only once per fork reset + // Governance grant roles to the relayer + await grantRoles(client); + + // User approve vault to spend their tokens and update user balance + const tokens = [ + ...new Set( + nestedPoolFromApi.pools.flatMap((p) => { + return { address: p.address, decimals: 18 }; + }), + ), + ]; + for (const token of tokens) { + await approveToken(client, testAddress, token.address); + + const slot = (await findTokenBalanceSlot( + client, + testAddress, + token.address, + )) as number; + + await setTokenBalance( + client, + testAddress, + token.address, + slot, + parseUnits('1000', token.decimals), + ); + } + }); + + test('proportional exit', async () => { + const bptAmountIn = parseUnits('1', 18); + await doTransaction({ + bptAmountIn, + chainId, + rpcUrl, + testAddress, + nestedExit, + nestedPoolFromApi, + client, + }); + }); + + test('proportional exit - native asset', async () => { + const bptAmountIn = parseUnits('1', 18); + + await doTransaction({ + bptAmountIn, + chainId, + rpcUrl, + testAddress, + nestedExit, + nestedPoolFromApi, + client, + useNativeAssetAsWrappedAmountOut: true, + }); + }); + + test('single token exit', async () => { + const bptAmountIn = parseUnits('1', 18); + const tokenOut = '0x6b175474e89094c44da98b954eedeac495271d0f'; // DAI + await doTransaction({ + bptAmountIn, + chainId, + rpcUrl, + testAddress, + nestedExit, + nestedPoolFromApi, + client, + tokenOut, + }); + }); + + // TODO: uncomment after SC team fixes relayer queries issue with output references + // test('single token exit - native asset', async () => { + // const bptAmountIn = parseUnits('1', 18); + // const tokenOut = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2'; // WETH + // await doTransaction({ + // bptAmountIn, + // chainId, + // rpcUrl, + // testAddress, + // nestedExit, + // nestedPoolFromApi, + // client, + // tokenOut, + // useNativeAssetAsWrappedAmountOut: true, + // }); + // }); +}); + +export const doTransaction = async ({ + bptAmountIn, + chainId, + rpcUrl, + testAddress, + nestedExit, + nestedPoolFromApi, + client, + tokenOut, + useNativeAssetAsWrappedAmountOut = false, +}: TxInput) => { + const exitInput: NestedExitInput = { + bptAmountIn, + chainId, + rpcUrl, + accountAddress: testAddress, + useNativeAssetAsWrappedAmountOut, + tokenOut, + }; + const queryResult = await nestedExit.query(exitInput, nestedPoolFromApi); + + // build join call with expected minBpOut based on slippage + const slippage = Slippage.fromPercentage('1'); // 1% + + const signature = await Relayer.signRelayerApproval( + BALANCER_RELAYER[chainId], + testAddress, + client, + ); + + const { call, to, minAmountsOut } = nestedExit.buildCall({ + ...queryResult, + chainId, + slippage, + sender: testAddress, + recipient: testAddress, + relayerApprovalSignature: signature, + }); + + let tokensOut = minAmountsOut.map((a) => a.token); + if (useNativeAssetAsWrappedAmountOut) { + tokensOut = replaceWrapped(tokensOut, chainId); + } + + // send join transaction and check balance changes + const { transactionReceipt, balanceDeltas } = + await sendTransactionGetBalances( + [ + queryResult.bptAmountIn.token.address, + ...tokensOut.map((t) => t.address), + ], + client, + testAddress, + to, + call, + ); + + expect(transactionReceipt.status).to.eq('success'); + queryResult.amountsOut.map( + (amountOut) => expect(amountOut.amount > 0n).to.be.true, + ); + const expectedDeltas = [ + queryResult.bptAmountIn.amount, + ...queryResult.amountsOut.map((amountOut) => amountOut.amount), + ]; + expect(expectedDeltas).to.deep.eq(balanceDeltas); + const expectedMinAmountsOut = queryResult.amountsOut.map((amountOut) => + slippage.removeFrom(amountOut.amount), + ); + expect(expectedMinAmountsOut).to.deep.eq( + minAmountsOut.map((a) => a.amount), + ); +}; + +/*********************** Mock To Represent API Requirements **********************/ + +export class MockApi { + public async getNestedPool(address: Address): Promise { + if (address !== '0x08775ccb6674d6bdceb0797c364c2653ed84f384') + throw Error(); + return { + pools: [ + { + id: '0x08775ccb6674d6bdceb0797c364c2653ed84f3840002000000000000000004f0', + address: '0x08775ccb6674d6bdceb0797c364c2653ed84f384', + type: 'Weighted', + level: 1, + tokens: [ + { + address: + '0x79c58f70905f734641735bc61e45c19dd9ad60bc', // 3POOL-BPT + decimals: 18, + index: 0, + }, + { + address: + '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', // WETH + decimals: 18, + index: 1, + }, + ], + }, + { + id: '0x79c58f70905f734641735bc61e45c19dd9ad60bc0000000000000000000004e7', + address: '0x79c58f70905f734641735bc61e45c19dd9ad60bc', + type: 'ComposableStable', + level: 0, + tokens: [ + { + address: + '0x6b175474e89094c44da98b954eedeac495271d0f', // DAI + decimals: 18, + index: 0, + }, + { + address: + '0x79c58f70905f734641735bc61e45c19dd9ad60bc', // 3POOL-BPT + decimals: 18, + index: 1, + }, + { + address: + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC + decimals: 6, + index: 2, + }, + { + address: + '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT + decimals: 6, + index: 3, + }, + ], + }, + ], + }; + } +} + +export const grantRoles = async ( + client: Client & TestActions & WalletActions, +) => { + const balancerDaoAddress = '0x10A19e7eE7d7F8a52822f6817de8ea18204F2e4f'; + const authorizerAddress = '0xA331D84eC860Bf466b4CdCcFb4aC09a1B43F3aE6'; + + // Check for available roles on balancer-deployments repo: + // https://github.com/balancer/balancer-deployments/blob/master/action-ids/mainnet/action-ids.json + const exitRole = + '0xc149e88b59429ded7f601ab52ecd62331cac006ae07c16543439ed138dcb8d34'; + const joinRole = + '0x78ad1b68d148c070372f8643c4648efbb63c6a8a338f3c24714868e791367653'; + const swapRole = + '0x7b8a1d293670124924a0f532213753b89db10bde737249d4540e9a03657d1aff'; + const batchSwapRole = + '0x1282ab709b2b70070f829c46bc36f76b32ad4989fecb2fcb09a1b3ce00bbfc30'; + const setRelayerApprovalRole = + '0x0014a06d322ff07fcc02b12f93eb77bb76e28cdee4fc0670b9dec98d24bbfec8'; + + await client.impersonateAccount({ + address: balancerDaoAddress, + }); + const roles: Address[] = [ + exitRole, + joinRole, + swapRole, + batchSwapRole, + setRelayerApprovalRole, + ]; + const chainId = await client.getChainId(); + for (const role of roles) { + await client.writeContract({ + account: balancerDaoAddress, + address: authorizerAddress, + chain: client.chain, + abi: authorizerAbi, + functionName: 'grantRole', + args: [role, BALANCER_RELAYER[chainId]], + }); + } + await client.stopImpersonatingAccount({ + address: balancerDaoAddress, + }); +}; + +export const approveRelayer = async ( + client: Client & WalletActions, + account: Address, +) => { + const chainId = await client.getChainId(); + await client.writeContract({ + account, + address: BALANCER_VAULT, + chain: client.chain, + abi: vaultAbi, + functionName: 'setRelayerApproval', + args: [account, BALANCER_RELAYER[chainId], true], + }); +}; +/******************************************************************************/ diff --git a/test/nestedJoin.integration.test.ts b/test/nestedJoin.integration.test.ts index 7ac45704..a31de12b 100644 --- a/test/nestedJoin.integration.test.ts +++ b/test/nestedJoin.integration.test.ts @@ -15,7 +15,13 @@ import { walletActions, } from 'viem'; -import { Slippage, NestedJoin, Token, replaceWrapped } from '../src/entities'; +import { + Slippage, + NestedJoin, + Token, + replaceWrapped, + NestedPoolState, +} from '../src/entities'; import { Address } from '../src/types'; import { @@ -33,10 +39,7 @@ import { } from './lib/utils/helper'; import { authorizerAbi, vaultAbi } from '../src/abi'; import { Relayer } from '../src/entities/relayer'; -import { - NestedJoinInput, - NestedPoolState, -} from '../src/entities/nestedJoin/types'; +import { NestedJoinInput } from '../src/entities/nestedJoin/types'; /** * Deploy the new relayer contract with the new helper address: @@ -247,7 +250,7 @@ export const doTransaction = async ({ amountsIn, chainId, rpcUrl, - testAddress, + accountAddress: testAddress, useNativeAssetAsWrappedAmountIn, }; const queryResult = await nestedJoin.query(joinInput, nestedPoolFromApi);