diff --git a/src/abi/index.ts b/src/abi/index.ts index 6a321725..6da5dc98 100644 --- a/src/abi/index.ts +++ b/src/abi/index.ts @@ -24,6 +24,7 @@ import { gyro2CLPAbi_V3, gyroECLPAbi_V3, lBPMigrationRouterAbi_V3, + unbalancedAddViaSwapRouterAbi_V3, } from './v3'; export * from './authorizer'; @@ -87,6 +88,12 @@ export const balancerMigrationRouterAbiExtended = [ ...commonABIsV3, ...poolABIsV3, ]; + +export const balancerUnbalancedAddViaSwapRouterAbiExtended = [ + ...unbalancedAddViaSwapRouterAbi_V3, + ...commonABIsV3, + ...poolABIsV3, +]; // V3 Pool Factories ABIs Extended export const weightedPoolFactoryAbiExtended_V3 = [ diff --git a/src/abi/v3/index.ts b/src/abi/v3/index.ts index 494ff71e..092b57d9 100644 --- a/src/abi/v3/index.ts +++ b/src/abi/v3/index.ts @@ -18,3 +18,4 @@ export * from './liquidityBootstrappingPool'; export * from './gyro2CLP'; export * from './gyroECLP'; export * from './mockGyroEclpPool'; +export * from './unbalancedAddViaSwapRouter'; diff --git a/src/abi/v3/unbalancedAddViaSwapRouter.ts b/src/abi/v3/unbalancedAddViaSwapRouter.ts new file mode 100644 index 00000000..0c560638 --- /dev/null +++ b/src/abi/v3/unbalancedAddViaSwapRouter.ts @@ -0,0 +1,817 @@ +export const unbalancedAddViaSwapRouterAbi_V3 = [ + { + inputs: [ + { internalType: 'contract IVault', name: 'vault', type: 'address' }, + { internalType: 'contract IWETH', name: 'weth', type: 'address' }, + { + internalType: 'contract IPermit2', + name: 'permit2', + type: 'address', + }, + { internalType: 'string', name: 'routerVersion', type: 'string' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + inputs: [{ internalType: 'address', name: 'target', type: 'address' }], + name: 'AddressEmptyCode', + type: 'error', + }, + { + inputs: [ + { internalType: 'uint256', name: 'amountIn', type: 'uint256' }, + { + internalType: 'uint256', + name: 'maxAdjustableAmount', + type: 'uint256', + }, + ], + name: 'AmountInAboveMaxAdjustableAmount', + type: 'error', + }, + { + inputs: [ + { internalType: 'uint256', name: 'amountIn', type: 'uint256' }, + { internalType: 'uint256', name: 'exactAmount', type: 'uint256' }, + ], + name: 'AmountInDoesNotMatchExact', + type: 'error', + }, + { inputs: [], name: 'ErrorSelectorNotFound', type: 'error' }, + { inputs: [], name: 'EthTransfer', type: 'error' }, + { inputs: [], name: 'FailedCall', type: 'error' }, + { inputs: [], name: 'InputLengthMismatch', type: 'error' }, + { + inputs: [ + { internalType: 'uint256', name: 'balance', type: 'uint256' }, + { internalType: 'uint256', name: 'needed', type: 'uint256' }, + ], + name: 'InsufficientBalance', + type: 'error', + }, + { inputs: [], name: 'InsufficientEth', type: 'error' }, + { + inputs: [ + { internalType: 'contract IERC20', name: 'token', type: 'address' }, + ], + name: 'InsufficientPayment', + type: 'error', + }, + { inputs: [], name: 'NotTwoTokenPool', type: 'error' }, + { inputs: [], name: 'OperationNotSupported', type: 'error' }, + { inputs: [], name: 'ReentrancyGuardReentrantCall', type: 'error' }, + { + inputs: [ + { internalType: 'uint8', name: 'bits', type: 'uint8' }, + { internalType: 'uint256', name: 'value', type: 'uint256' }, + ], + name: 'SafeCastOverflowedUintDowncast', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'token', type: 'address' }], + name: 'SafeERC20FailedOperation', + type: 'error', + }, + { + inputs: [{ internalType: 'address', name: 'sender', type: 'address' }], + name: 'SenderIsNotVault', + type: 'error', + }, + { inputs: [], name: 'SwapDeadline', type: 'error' }, + { + inputs: [ + { + components: [ + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { internalType: 'address', name: 'pool', type: 'address' }, + { + internalType: 'uint256[]', + name: 'maxAmountsIn', + type: 'uint256[]', + }, + { + internalType: 'uint256', + name: 'minBptAmountOut', + type: 'uint256', + }, + { + internalType: 'enum AddLiquidityKind', + name: 'kind', + type: 'uint8', + }, + { internalType: 'bool', name: 'wethIsEth', type: 'bool' }, + { internalType: 'bytes', name: 'userData', type: 'bytes' }, + ], + internalType: 'struct AddLiquidityHookParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'addLiquidityHook', + outputs: [ + { internalType: 'uint256[]', name: 'amountsIn', type: 'uint256[]' }, + { internalType: 'uint256', name: 'bptAmountOut', type: 'uint256' }, + { internalType: 'bytes', name: 'returnData', type: 'bytes' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'pool', type: 'address' }, + { internalType: 'uint256', name: 'deadline', type: 'uint256' }, + { internalType: 'bool', name: 'wethIsEth', type: 'bool' }, + { + components: [ + { + internalType: 'uint256', + name: 'exactBptAmountOut', + type: 'uint256', + }, + { + internalType: 'contract IERC20', + name: 'exactToken', + type: 'address', + }, + { + internalType: 'uint256', + name: 'exactAmount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'maxAdjustableAmount', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'addLiquidityUserData', + type: 'bytes', + }, + { + internalType: 'bytes', + name: 'swapUserData', + type: 'bytes', + }, + ], + internalType: + 'struct IUnbalancedAddViaSwapRouter.AddLiquidityAndSwapParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'addLiquidityUnbalanced', + outputs: [ + { internalType: 'uint256[]', name: 'amountsIn', type: 'uint256[]' }, + ], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'pool', type: 'address' }, + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + { internalType: 'bool', name: 'wethIsEth', type: 'bool' }, + { + components: [ + { + internalType: 'uint256', + name: 'exactBptAmountOut', + type: 'uint256', + }, + { + internalType: 'contract IERC20', + name: 'exactToken', + type: 'address', + }, + { + internalType: 'uint256', + name: 'exactAmount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'maxAdjustableAmount', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'addLiquidityUserData', + type: 'bytes', + }, + { + internalType: 'bytes', + name: 'swapUserData', + type: 'bytes', + }, + ], + internalType: + 'struct IUnbalancedAddViaSwapRouter.AddLiquidityAndSwapParams', + name: 'operationParams', + type: 'tuple', + }, + ], + internalType: + 'struct IUnbalancedAddViaSwapRouter.AddLiquidityAndSwapHookParams', + name: 'hookParams', + type: 'tuple', + }, + ], + name: 'addLiquidityUnbalancedHook', + outputs: [ + { internalType: 'uint256[]', name: 'amountsIn', type: 'uint256[]' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'getPermit2', + outputs: [ + { internalType: 'contract IPermit2', name: '', type: 'address' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getSender', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getVault', + outputs: [ + { internalType: 'contract IVault', name: '', type: 'address' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getWeth', + outputs: [ + { internalType: 'contract IWETH', name: '', type: 'address' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { internalType: 'address', name: 'pool', type: 'address' }, + { + internalType: 'contract IERC20[]', + name: 'tokens', + type: 'address[]', + }, + { + internalType: 'uint256[]', + name: 'exactAmountsIn', + type: 'uint256[]', + }, + { + internalType: 'uint256', + name: 'minBptAmountOut', + type: 'uint256', + }, + { internalType: 'bool', name: 'wethIsEth', type: 'bool' }, + { internalType: 'bytes', name: 'userData', type: 'bytes' }, + ], + internalType: 'struct InitializeHookParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'initializeHook', + outputs: [ + { internalType: 'uint256', name: 'bptAmountOut', type: 'uint256' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes[]', name: 'data', type: 'bytes[]' }], + name: 'multicall', + outputs: [ + { internalType: 'bytes[]', name: 'results', type: 'bytes[]' }, + ], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'token', type: 'address' }, + { internalType: 'address', name: 'owner', type: 'address' }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { internalType: 'uint256', name: 'nonce', type: 'uint256' }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + ], + internalType: 'struct IRouterCommon.PermitApproval[]', + name: 'permitBatch', + type: 'tuple[]', + }, + { + internalType: 'bytes[]', + name: 'permitSignatures', + type: 'bytes[]', + }, + { + components: [ + { + components: [ + { + internalType: 'address', + name: 'token', + type: 'address', + }, + { + internalType: 'uint160', + name: 'amount', + type: 'uint160', + }, + { + internalType: 'uint48', + name: 'expiration', + type: 'uint48', + }, + { + internalType: 'uint48', + name: 'nonce', + type: 'uint48', + }, + ], + internalType: + 'struct IAllowanceTransfer.PermitDetails[]', + name: 'details', + type: 'tuple[]', + }, + { + internalType: 'address', + name: 'spender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'sigDeadline', + type: 'uint256', + }, + ], + internalType: 'struct IAllowanceTransfer.PermitBatch', + name: 'permit2Batch', + type: 'tuple', + }, + { internalType: 'bytes', name: 'permit2Signature', type: 'bytes' }, + { internalType: 'bytes[]', name: 'multicallData', type: 'bytes[]' }, + ], + name: 'permitBatchAndCall', + outputs: [{ internalType: 'bytes[]', name: '', type: 'bytes[]' }], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { internalType: 'address', name: 'pool', type: 'address' }, + { + internalType: 'uint256[]', + name: 'maxAmountsIn', + type: 'uint256[]', + }, + { + internalType: 'uint256', + name: 'minBptAmountOut', + type: 'uint256', + }, + { + internalType: 'enum AddLiquidityKind', + name: 'kind', + type: 'uint8', + }, + { internalType: 'bool', name: 'wethIsEth', type: 'bool' }, + { internalType: 'bytes', name: 'userData', type: 'bytes' }, + ], + internalType: 'struct AddLiquidityHookParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'queryAddLiquidityHook', + outputs: [ + { internalType: 'uint256[]', name: 'amountsIn', type: 'uint256[]' }, + { internalType: 'uint256', name: 'bptAmountOut', type: 'uint256' }, + { internalType: 'bytes', name: 'returnData', type: 'bytes' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'pool', type: 'address' }, + { internalType: 'address', name: 'sender', type: 'address' }, + { + components: [ + { + internalType: 'uint256', + name: 'exactBptAmountOut', + type: 'uint256', + }, + { + internalType: 'contract IERC20', + name: 'exactToken', + type: 'address', + }, + { + internalType: 'uint256', + name: 'exactAmount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'maxAdjustableAmount', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'addLiquidityUserData', + type: 'bytes', + }, + { + internalType: 'bytes', + name: 'swapUserData', + type: 'bytes', + }, + ], + internalType: + 'struct IUnbalancedAddViaSwapRouter.AddLiquidityAndSwapParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'queryAddLiquidityUnbalanced', + outputs: [ + { internalType: 'uint256[]', name: 'amountsIn', type: 'uint256[]' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'pool', type: 'address' }, + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + { internalType: 'bool', name: 'wethIsEth', type: 'bool' }, + { + components: [ + { + internalType: 'uint256', + name: 'exactBptAmountOut', + type: 'uint256', + }, + { + internalType: 'contract IERC20', + name: 'exactToken', + type: 'address', + }, + { + internalType: 'uint256', + name: 'exactAmount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'maxAdjustableAmount', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'addLiquidityUserData', + type: 'bytes', + }, + { + internalType: 'bytes', + name: 'swapUserData', + type: 'bytes', + }, + ], + internalType: + 'struct IUnbalancedAddViaSwapRouter.AddLiquidityAndSwapParams', + name: 'operationParams', + type: 'tuple', + }, + ], + internalType: + 'struct IUnbalancedAddViaSwapRouter.AddLiquidityAndSwapHookParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'queryAddLiquidityUnbalancedHook', + outputs: [ + { internalType: 'uint256[]', name: 'amountsIn', type: 'uint256[]' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { internalType: 'address', name: 'pool', type: 'address' }, + { + internalType: 'uint256[]', + name: 'minAmountsOut', + type: 'uint256[]', + }, + { + internalType: 'uint256', + name: 'maxBptAmountIn', + type: 'uint256', + }, + { + internalType: 'enum RemoveLiquidityKind', + name: 'kind', + type: 'uint8', + }, + { internalType: 'bool', name: 'wethIsEth', type: 'bool' }, + { internalType: 'bytes', name: 'userData', type: 'bytes' }, + ], + internalType: 'struct RemoveLiquidityHookParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'queryRemoveLiquidityHook', + outputs: [ + { internalType: 'uint256', name: 'bptAmountIn', type: 'uint256' }, + { + internalType: 'uint256[]', + name: 'amountsOut', + type: 'uint256[]', + }, + { internalType: 'bytes', name: 'returnData', type: 'bytes' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'pool', type: 'address' }, + { internalType: 'address', name: 'sender', type: 'address' }, + { + internalType: 'uint256', + name: 'exactBptAmountIn', + type: 'uint256', + }, + ], + name: 'queryRemoveLiquidityRecoveryHook', + outputs: [ + { + internalType: 'uint256[]', + name: 'amountsOut', + type: 'uint256[]', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + internalType: 'enum SwapKind', + name: 'kind', + type: 'uint8', + }, + { internalType: 'address', name: 'pool', type: 'address' }, + { + internalType: 'contract IERC20', + name: 'tokenIn', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: 'tokenOut', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amountGiven', + type: 'uint256', + }, + { internalType: 'uint256', name: 'limit', type: 'uint256' }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + { internalType: 'bool', name: 'wethIsEth', type: 'bool' }, + { internalType: 'bytes', name: 'userData', type: 'bytes' }, + ], + internalType: 'struct SwapSingleTokenHookParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'querySwapHook', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { internalType: 'address', name: 'pool', type: 'address' }, + { + internalType: 'uint256[]', + name: 'minAmountsOut', + type: 'uint256[]', + }, + { + internalType: 'uint256', + name: 'maxBptAmountIn', + type: 'uint256', + }, + { + internalType: 'enum RemoveLiquidityKind', + name: 'kind', + type: 'uint8', + }, + { internalType: 'bool', name: 'wethIsEth', type: 'bool' }, + { internalType: 'bytes', name: 'userData', type: 'bytes' }, + ], + internalType: 'struct RemoveLiquidityHookParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'removeLiquidityHook', + outputs: [ + { internalType: 'uint256', name: 'bptAmountIn', type: 'uint256' }, + { + internalType: 'uint256[]', + name: 'amountsOut', + type: 'uint256[]', + }, + { internalType: 'bytes', name: 'returnData', type: 'bytes' }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'pool', type: 'address' }, + { internalType: 'address', name: 'sender', type: 'address' }, + { + internalType: 'uint256', + name: 'exactBptAmountIn', + type: 'uint256', + }, + { + internalType: 'uint256[]', + name: 'minAmountsOut', + type: 'uint256[]', + }, + ], + name: 'removeLiquidityRecoveryHook', + outputs: [ + { + internalType: 'uint256[]', + name: 'amountsOut', + type: 'uint256[]', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + internalType: 'enum SwapKind', + name: 'kind', + type: 'uint8', + }, + { internalType: 'address', name: 'pool', type: 'address' }, + { + internalType: 'contract IERC20', + name: 'tokenIn', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: 'tokenOut', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amountGiven', + type: 'uint256', + }, + { internalType: 'uint256', name: 'limit', type: 'uint256' }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + { internalType: 'bool', name: 'wethIsEth', type: 'bool' }, + { internalType: 'bytes', name: 'userData', type: 'bytes' }, + ], + internalType: 'struct SwapSingleTokenHookParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'swapSingleTokenHook', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'version', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, + { stateMutability: 'payable', type: 'receive' }, +] as const; diff --git a/src/entities/addLiquidityUnbalancedViaSwap/doAddLiquidityUnbalancedViaSwapQuery.ts b/src/entities/addLiquidityUnbalancedViaSwap/doAddLiquidityUnbalancedViaSwapQuery.ts new file mode 100644 index 00000000..a0597379 --- /dev/null +++ b/src/entities/addLiquidityUnbalancedViaSwap/doAddLiquidityUnbalancedViaSwapQuery.ts @@ -0,0 +1,45 @@ +import { createPublicClient, http } from 'viem'; +import { ChainId, CHAINS } from '@/utils'; +import { Address, Hex } from '@/types'; +import { balancerUnbalancedAddViaSwapRouterAbiExtended } from '@/abi'; +import { AddressProvider } from '@/entities/inputValidator/utils/addressProvider'; + +export const doAddLiquidityUnbalancedViaSwapQuery = async ( + rpcUrl: string, + chainId: ChainId, + pool: Address, + sender: Address, + exactBptAmountOut: bigint, + exactToken: Address, + exactAmount: bigint, + maxAdjustableAmount: bigint, + addLiquidityUserData: Hex, + swapUserData: Hex, + block?: bigint, +): Promise => { + const client = createPublicClient({ + transport: http(rpcUrl), + chain: CHAINS[chainId], + }); + + const { result: amountsIn } = await client.simulateContract({ + address: AddressProvider.UnbalancedAddViaSwapRouter(chainId), + abi: balancerUnbalancedAddViaSwapRouterAbiExtended, + functionName: 'queryAddLiquidityUnbalanced', + args: [ + pool, + sender, + { + exactBptAmountOut, + exactToken, + exactAmount, + maxAdjustableAmount, + addLiquidityUserData, + swapUserData, + }, + ], + blockNumber: block, + }); + + return [...amountsIn]; +}; diff --git a/src/entities/addLiquidityUnbalancedViaSwap/helpers.ts b/src/entities/addLiquidityUnbalancedViaSwap/helpers.ts new file mode 100644 index 00000000..970ecc2d --- /dev/null +++ b/src/entities/addLiquidityUnbalancedViaSwap/helpers.ts @@ -0,0 +1,21 @@ +import { AddLiquidityUnbalancedViaSwapBuildCallInput } from './types'; +import { TokenAmount } from '../tokenAmount'; + +export const getAmountsCallUnbalancedViaSwap = ( + input: AddLiquidityUnbalancedViaSwapBuildCallInput, +): { + exactBptAmountOut: bigint; + maxAdjustableAmount: bigint; +} => { + // Apply slippage to decrease exactBptAmountOut (negative direction) + const exactBptAmountOut = input.slippage.applyTo(input.bptOut.amount, -1); + + // Keep adjustable amount unchanged (no slippage applied) + const adjustableAmount = input.amountsIn[input.adjustableTokenIndex]; + const maxAdjustableAmount = adjustableAmount.amount; + + return { + exactBptAmountOut, + maxAdjustableAmount, + }; +}; diff --git a/src/entities/addLiquidityUnbalancedViaSwap/index.ts b/src/entities/addLiquidityUnbalancedViaSwap/index.ts new file mode 100644 index 00000000..e078746b --- /dev/null +++ b/src/entities/addLiquidityUnbalancedViaSwap/index.ts @@ -0,0 +1,224 @@ +import { encodeFunctionData, zeroAddress } from 'viem'; +import { TokenAmount } from '@/entities/tokenAmount'; +import { Token } from '@/entities/token'; +import { PoolState } from '@/entities/types'; +import { Permit2 } from '@/entities/permit2Helper'; +import { + balancerUnbalancedAddViaSwapRouterAbiExtended, + balancerRouterAbiExtended, +} from '@/abi'; +import { AddressProvider } from '@/entities/inputValidator/utils/addressProvider'; +import { getValue, getSortedTokens } from '@/entities/utils'; +import { Hex, Address, InputAmount } from '@/types'; +import { doAddLiquidityUnbalancedViaSwapQuery } from './doAddLiquidityUnbalancedViaSwapQuery'; +import { validateAddLiquidityUnbalancedViaSwapInput } from './validateInputs'; +import { getAmountsCallUnbalancedViaSwap } from './helpers'; +import { + AddLiquidityUnbalancedViaSwapInput, + AddLiquidityUnbalancedViaSwapQueryOutput, + AddLiquidityUnbalancedViaSwapBuildCallInput, + AddLiquidityUnbalancedViaSwapBuildCallOutput, +} from './types'; +import { getBptAmountFromReferenceAmountnbalancedViaSwapFromAdjustableAmount } from '../utils/unbalancedJoinViaSwapHelpers'; +import { SDKError } from '@/utils/errors'; +import { AddLiquidityKind } from '../addLiquidity/types'; + +// Export types +export type { + AddLiquidityUnbalancedViaSwapInput, + AddLiquidityUnbalancedViaSwapQueryOutput, + AddLiquidityUnbalancedViaSwapBuildCallInput, + AddLiquidityUnbalancedViaSwapBuildCallOutput, +} from './types'; + +export class AddLiquidityUnbalancedViaSwapV3 { + async query( + input: AddLiquidityUnbalancedViaSwapInput, + poolState: PoolState, + block?: bigint, + ): Promise { + validateAddLiquidityUnbalancedViaSwapInput(input, poolState); + + const sender = input.sender ?? zeroAddress; + const addLiquidityUserData = input.addLiquidityUserData ?? '0x'; + const swapUserData = input.swapUserData ?? '0x'; + + // Convert input amounts to TokenAmount objects + const sortedTokens = getSortedTokens(poolState.tokens, input.chainId); + const amountsIn = sortedTokens.map((token) => { + const inputAmount = input.amountsIn.find( + (amount) => + amount.address.toLowerCase() === + token.address.toLowerCase(), + ); + if (!inputAmount) { + throw new Error(`Token amount not found for ${token.address}`); + } + return TokenAmount.fromRawAmount(token, inputAmount.rawAmount); + }); + + // Use the provided exact token index + const exactTokenIndex = input.exactTokenIndex; + const adjustableTokenIndex = exactTokenIndex === 0 ? 1 : 0; + const exactToken = amountsIn[exactTokenIndex].token.address; + + if ( + input.amountsIn[exactTokenIndex].rawAmount > 0n && + input.amountsIn[adjustableTokenIndex].rawAmount === 0n + ) { + throw new SDKError( + 'UnbalancedJoinViaSwap', + 'AddLiquidityUnbalancedViaSwapV3.query', + 'Single-sided joins with maxAdjustableAmount = 0 are not supported by UnbalancedAddViaSwapRouter. Please provide a non-zero adjustable amount or use a different path.', + ); + } + if ( + input.amountsIn[exactTokenIndex].rawAmount === 0n && + input.amountsIn[adjustableTokenIndex].rawAmount > 0n + ) { + // Single-sided join: exact token amount is zero, adjustable token has a finite budget. + // We treat the adjustable token as the reference and derive a BPT target from it. + + const adjustableBudgetRaw = amountsIn[adjustableTokenIndex].amount; + + const bptAmount = + await getBptAmountFromReferenceAmountnbalancedViaSwapFromAdjustableAmount( + { + chainId: input.chainId, + rpcUrl: input.rpcUrl, + referenceAmount: { + address: + amountsIn[adjustableTokenIndex].token.address, + rawAmount: adjustableBudgetRaw, + decimals: + amountsIn[adjustableTokenIndex].token.decimals, + }, + kind: AddLiquidityKind.Proportional, + maxAdjustableAmountRaw: adjustableBudgetRaw, + }, + poolState, + ); + + const bptToken = new Token(input.chainId, poolState.address, 18); + const bptOut = TokenAmount.fromRawAmount( + bptToken, + bptAmount.rawAmount, + ); + + // Query the router with exactAmount = 0 and maxAdjustableAmount = adjustableBudgetRaw. + const amountsInNumbers = await doAddLiquidityUnbalancedViaSwapQuery( + input.rpcUrl, + input.chainId, + input.pool, + sender, + bptAmount.rawAmount, + exactToken, + 0n, + adjustableBudgetRaw, + addLiquidityUserData, + swapUserData, + block, + ); + + const finalAmountsIn = sortedTokens.map((token, index) => + TokenAmount.fromRawAmount(token, amountsInNumbers[index]), + ); + + const output: AddLiquidityUnbalancedViaSwapQueryOutput = { + pool: input.pool, + bptOut, + amountsIn: finalAmountsIn, + chainId: input.chainId, + protocolVersion: 3, + to: AddressProvider.Router(input.chainId), + addLiquidityUserData, + swapUserData, + exactToken, + exactAmount: 0n, + adjustableTokenIndex, + }; + return output; + } + // Two token join - currently not supported + throw new SDKError( + 'UnbalancedJoinViaSwap', + 'AddLiquidityUnbalancedViaSwapV3.query', + 'Two-token joins are not supported by The SDK yet. Please provide a single-sided join.', + ); + } + + buildCall( + input: AddLiquidityUnbalancedViaSwapBuildCallInput, + ): AddLiquidityUnbalancedViaSwapBuildCallOutput { + // the queryOutput returns the actual amountsIn for a calculated BPT amount. + // the BPT amount is calculated based on a proportional join helper with some + // inline bptAmount adjustments. (bpt gets increased by a certain percentage). + // Simply using slippage to decrease the exactBptAmount would open up the + // join with some freetom in the adjustable amount. + const amounts = getAmountsCallUnbalancedViaSwap(input); + const wethIsEth = input.wethIsEth ?? false; + + const callData = encodeFunctionData({ + abi: balancerUnbalancedAddViaSwapRouterAbiExtended, + functionName: 'addLiquidityUnbalanced', + args: [ + input.pool, + input.deadline, + wethIsEth, + { + exactBptAmountOut: amounts.exactBptAmountOut, + exactToken: input.exactToken, + exactAmount: input.exactAmount, + maxAdjustableAmount: amounts.maxAdjustableAmount, + addLiquidityUserData: input.addLiquidityUserData, + swapUserData: input.swapUserData, + }, + ] as const, + }); + + const value = getValue(input.amountsIn, wethIsEth); + const exactBptAmountOut = TokenAmount.fromRawAmount( + input.bptOut.token, + amounts.exactBptAmountOut, + ); + const maxAdjustableAmount = TokenAmount.fromRawAmount( + input.amountsIn[input.adjustableTokenIndex].token, + amounts.maxAdjustableAmount, + ); + + return { + callData, + to: AddressProvider.UnbalancedAddViaSwapRouter(input.chainId), + value, + exactBptAmountOut, + maxAdjustableAmount, + }; + } + + public buildCallWithPermit2( + input: AddLiquidityUnbalancedViaSwapBuildCallInput, + permit2: Permit2, + ): AddLiquidityUnbalancedViaSwapBuildCallOutput { + // Generate same calldata as buildCall + const buildCallOutput = this.buildCall(input); + + const args = [ + [], + [], + permit2.batch, + permit2.signature, + [buildCallOutput.callData], + ] as const; + + const callData = encodeFunctionData({ + abi: balancerRouterAbiExtended, + functionName: 'permitBatchAndCall', + args, + }); + + return { + ...buildCallOutput, + callData, + }; + } +} diff --git a/src/entities/addLiquidityUnbalancedViaSwap/types.ts b/src/entities/addLiquidityUnbalancedViaSwap/types.ts new file mode 100644 index 00000000..16c6dc7a --- /dev/null +++ b/src/entities/addLiquidityUnbalancedViaSwap/types.ts @@ -0,0 +1,54 @@ +import { Address, Hex } from 'viem'; +import { Slippage } from '../slippage'; +import { TokenAmount } from '../tokenAmount'; +import { InputAmount } from '@/types'; +import { SwapKind } from '@/types'; + +export type AddLiquidityUnbalancedViaSwapInput = { + chainId: number; + rpcUrl: string; + pool: Address; + amountsIn: InputAmount[]; + exactTokenIndex: number; + addLiquidityUserData?: Hex; + swapUserData?: Hex; + sender?: Address; + swapKind?: SwapKind; + minSwapAmount?: TokenAmount; + /** + * When true (and swapKind is GivenIn), the SDK will choose exactBptAmountOut + * to minimize the adjustable token in as much as possible (subject to Vault + * constraints such as min swap size), instead of staying as close as + * possible to the proportional join. + * + * Default: false. + */ + minimizeAdjustableAmount?: boolean; +}; +export type AddLiquidityUnbalancedViaSwapQueryOutput = { + pool: Address; + bptOut: TokenAmount; + amountsIn: TokenAmount[]; + chainId: number; + protocolVersion: 3; + to: Address; + addLiquidityUserData: Hex; + swapUserData: Hex; + exactToken: Address; + exactAmount: bigint; + adjustableTokenIndex: number; +}; + +export type AddLiquidityUnbalancedViaSwapBuildCallInput = { + slippage: Slippage; + wethIsEth?: boolean; + deadline: bigint; +} & AddLiquidityUnbalancedViaSwapQueryOutput; + +export type AddLiquidityUnbalancedViaSwapBuildCallOutput = { + callData: Hex; + to: Address; + value: bigint; + exactBptAmountOut: TokenAmount; + maxAdjustableAmount: TokenAmount; +}; diff --git a/src/entities/addLiquidityUnbalancedViaSwap/validateInputs.ts b/src/entities/addLiquidityUnbalancedViaSwap/validateInputs.ts new file mode 100644 index 00000000..c6eba93a --- /dev/null +++ b/src/entities/addLiquidityUnbalancedViaSwap/validateInputs.ts @@ -0,0 +1,60 @@ +import { SDKError } from '@/utils'; +import { AddLiquidityUnbalancedViaSwapInput } from './types'; +import { PoolState } from '../types'; + +export const validateAddLiquidityUnbalancedViaSwapInput = ( + input: AddLiquidityUnbalancedViaSwapInput, + poolState: PoolState, +): void => { + if (poolState.type !== 'RECLAMM') { + throw new SDKError( + 'AddLiquidityUnbalancedViaSwap', + 'validateInput', + 'Weighted pools are not supported for unbalanced via swap', + ); + } + if (!input.pool) { + throw new SDKError( + 'AddLiquidityUnbalancedViaSwap', + 'validateInput', + 'Pool address is required', + ); + } + + if (!input.amountsIn || input.amountsIn.length !== 2) { + throw new SDKError( + 'AddLiquidityUnbalancedViaSwap', + 'validateInput', + 'Exactly 2 token amounts are required for unbalanced via swap', + ); + } + + if ( + input.exactTokenIndex < 0 || + input.exactTokenIndex >= input.amountsIn.length + ) { + throw new SDKError( + 'AddLiquidityUnbalancedViaSwap', + 'validateInput', + 'exactTokenIndex must be 0 or 1 for two-token pools', + ); + } + + // Validate that at least one amount is greater than 0 + for (const amount of input.amountsIn) { + if (amount.rawAmount > 0n) { + return; + } + } + + // Validate that no token is the pool address + for (const amount of input.amountsIn) { + if (amount.address.toLowerCase() === input.pool.toLowerCase()) { + throw new SDKError( + 'AddLiquidityUnbalancedViaSwap', + 'validateInput', + 'Token cannot be the same as pool address', + ); + } + } +}; diff --git a/src/entities/index.ts b/src/entities/index.ts index ddbbf64f..8f6e06dd 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -1,5 +1,6 @@ export * from './addLiquidity'; export * from './addLiquidity/types'; +export * from './addLiquidityUnbalancedViaSwap'; export * from './addLiquidityBoosted'; export * from './addLiquidityBoosted/types'; export * from './addLiquidityBuffer'; diff --git a/src/entities/inputValidator/utils/addressProvider.ts b/src/entities/inputValidator/utils/addressProvider.ts index 4cd679f8..4cb361f0 100644 --- a/src/entities/inputValidator/utils/addressProvider.ts +++ b/src/entities/inputValidator/utils/addressProvider.ts @@ -90,6 +90,12 @@ export class AddressProvider { static Router(chainId: ChainId): Hex { return AddressProvider.getAddress(balancerV3Contracts.Router, chainId); } + static UnbalancedAddViaSwapRouter(chainId: ChainId): Hex { + return AddressProvider.getAddress( + balancerV3Contracts.UnbalancedAddViaSwapRouter, + chainId, + ); + } static StablePoolFactory(chainId: ChainId): Hex { return AddressProvider.getAddress( balancerV3Contracts.StablePoolFactory, diff --git a/src/entities/utils/unbalancedJoinViaSwapHelpers.ts b/src/entities/utils/unbalancedJoinViaSwapHelpers.ts new file mode 100644 index 00000000..2ea34a2c --- /dev/null +++ b/src/entities/utils/unbalancedJoinViaSwapHelpers.ts @@ -0,0 +1,116 @@ +import { Address, parseUnits } from 'viem'; +import { InputAmount } from '@/types'; +import { HumanAmount } from '@/data'; +import { + inputValidationError, + isSameAddress, + MathSol, + WAD, + SDKError, +} from '@/utils'; +import { AddLiquidityProportionalInput } from '../addLiquidity/types'; +import { + PoolState, + PoolStateWithUnderlyingBalances, + PoolStateWithUnderlyings, +} from '../types'; +import { getPoolStateWithBalancesV2 } from './getPoolStateWithBalancesV2'; +import { + getBoostedPoolStateWithBalancesV3, + getPoolStateWithBalancesV3, +} from './getPoolStateWithBalancesV3'; +import { AddLiquidityBoostedProportionalInput } from '../addLiquidityBoosted/types'; + +import { calculateProportionalAmounts } from './proportionalAmountsHelpers'; + +export function calculateBptAmountFromUnbalancedJoinTwoTokensFromAdjustableAmount( + pool: { + address: Address; + totalShares: HumanAmount; + tokens: { address: Address; balance: HumanAmount; decimals: number }[]; + }, + referenceAmount: InputAmount, + maxAdjustableAmountRaw: bigint, +): { + tokenAmounts: InputAmount[]; + bptAmount: InputAmount; +} { + // Validate that the adjustable token (referenceAmount.address) is one of the pool tokens. + const adjustableTokenIndex = pool.tokens.findIndex( + (t) => + t.address.toLowerCase() === referenceAmount.address.toLowerCase(), + ); + if (adjustableTokenIndex === -1) { + throw inputValidationError( + 'Calculate Proportional Amounts', + `Reference amount token ${referenceAmount.address} must be relative to a token in the pool`, + ); + } + if (pool.tokens.length !== 2) { + throw new SDKError( + 'UnbalancedJoinViaSwap', + 'calculateBptAmountFromUnbalancedJoinTwoTokensFromAdjustableAmount', + 'Pool must have exactly 2 tokens', + ); + } + + // Use half of the user's adjustable budget as the proportional reference. + const halfAdjustableRaw = maxAdjustableAmountRaw / 2n; + if (halfAdjustableRaw === 0n) { + throw new SDKError( + 'UnbalancedJoinViaSwap', + 'calculateBptAmountFromUnbalancedJoinTwoTokensFromAdjustableAmount', + 'maxAdjustableAmountRaw is too small to derive a meaningful proportional reference', + ); + } + + const halfReferenceAmount: InputAmount = { + address: referenceAmount.address, + decimals: referenceAmount.decimals, + rawAmount: halfAdjustableRaw, + }; + + const { tokenAmounts, bptAmount } = calculateProportionalAmounts( + pool, + halfReferenceAmount, + ); + + // Optionally allow a small uplift on the proportional BPT estimate. + // For now we keep this at +20% to use a bit more of the user's budget, + // while still leaving headroom + const upliftNumerator = 120n; // 20% increase + const upliftDenominator = 100n; + const increasedBptRaw = + (bptAmount.rawAmount * upliftNumerator) / upliftDenominator; + + return { + tokenAmounts, + bptAmount: { + ...bptAmount, + rawAmount: increasedBptRaw, + }, + }; +} + +export const getBptAmountFromReferenceAmountnbalancedViaSwapFromAdjustableAmount = + async ( + input: AddLiquidityProportionalInput & { + maxAdjustableAmountRaw: bigint; + }, + poolState: PoolState, + ): Promise => { + const poolStateWithBalances = await getPoolStateWithBalancesV3( + poolState, + input.chainId, + input.rpcUrl, + ); + + const { bptAmount } = + calculateBptAmountFromUnbalancedJoinTwoTokensFromAdjustableAmount( + poolStateWithBalances, + input.referenceAmount, + input.maxAdjustableAmountRaw, + ); + + return bptAmount; + }; diff --git a/src/utils/balancerV3Contracts.ts b/src/utils/balancerV3Contracts.ts index f79c20a5..40b80a6c 100644 --- a/src/utils/balancerV3Contracts.ts +++ b/src/utils/balancerV3Contracts.ts @@ -97,6 +97,9 @@ export const balancerV3Contracts = { [ChainId.SONIC]: '0x93db4682A40721e7c698ea0a842389D10FA8Dae5', [ChainId.X_LAYER]: '0xc3ccacE87f6d3A81724075ADcb5ddd85a8A1bB68', }, + UnbalancedAddViaSwapRouter: { + [ChainId.SEPOLIA]: '0xe7823b82B165434a6949f19451B866c0e06858dF', + }, StablePoolFactory: { [ChainId.ARBITRUM_ONE]: '0x44d33798dddCdAbc93Fe6a40C80588033Dc502d3', [ChainId.AVALANCHE]: '0xEAedc32a51c510d35ebC11088fD5fF2b47aACF2E', diff --git a/test/lib/utils/addresses.ts b/test/lib/utils/addresses.ts index 579b6d22..ed46da2b 100644 --- a/test/lib/utils/addresses.ts +++ b/test/lib/utils/addresses.ts @@ -399,6 +399,14 @@ export const POOLS: Record> = { decimals: 18, slot: 0, }, + // reclamm + MOCK_RECLAMM_POOL: { + address: '0x6920364080c82ad004efb9d580f28b597c6d9c99', + id: '0x6920364080c82ad004efb9d580f28b597c6d9c99', + type: PoolType.ReClamm, + decimals: 18, + slot: 0, + }, }, [ChainId.GNOSIS_CHAIN]: { SDAI_BRLA_POOL: { @@ -408,6 +416,13 @@ export const POOLS: Record> = { decimals: 18, slot: 0, }, + reclammGNO_wstETH: { + address: '0xa50085ff1dfa173378e7d26a76117d68d5eba539', + id: '0xa50085ff1dfa173378e7d26a76117d68d5eba539', + type: PoolType.ReClamm, + decimals: 18, + slot: 0, + }, }, [ChainId.HYPEREVM]: { MOCK_FEUSD_FEWETH_POOL: { diff --git a/test/lib/utils/helper.ts b/test/lib/utils/helper.ts index 82dfb0c3..b238f4d6 100644 --- a/test/lib/utils/helper.ts +++ b/test/lib/utils/helper.ts @@ -1,5 +1,6 @@ import { Address, + Abi, Client, Hex, PublicActions, @@ -10,8 +11,8 @@ import { erc20Abi, hexToBigInt, keccak256, - maxUint160, maxUint48, + maxUint160, pad, toBytes, toHex, @@ -20,16 +21,17 @@ import { } from 'viem'; import { permit2Abi } from '@/abi'; +import { AddressProvider } from '@/entities/inputValidator/utils/addressProvider'; import { - VAULT_V2, - MAX_UINT256, - ZERO_ADDRESS, - PERMIT2, // balancerV3Contracts, BALANCER_COMPOSITE_LIQUIDITY_ROUTER_NESTED, + MAX_UINT256, + PERMIT2, PublicWalletClient, + VAULT_V2, + ZERO_ADDRESS, } from '@/utils'; -import { AddressProvider } from '@/entities/inputValidator/utils/addressProvider'; +import { SimulateParams } from './types'; export type TxOutput = { transactionReceipt: TransactionReceipt; @@ -144,6 +146,18 @@ export const approveToken = async ( amount, deadline, ); + // Approve UnbalancedAddViaSwapRouter to spend account tokens using Permit2 + // for now only on Sepolia, as the Router is not deployed on other chains yet + if (client.chain?.id === 11155111) { + await approveSpenderOnPermit2( + client, + accountAddress, + tokenAddress, + AddressProvider.UnbalancedAddViaSwapRouter(chainId), + amount, + deadline, + ); + } approved = approved && routerApprovedOnPermit2 && @@ -323,6 +337,7 @@ export async function sendTransactionGetBalances( to: Address, data: Address, value?: bigint, + simulateParams?: SimulateParams, ): Promise { const balanceBefore = await getBalances( tokensForBalanceCheck, @@ -354,6 +369,21 @@ export async function sendTransactionGetBalances( // ], // }); + if (simulateParams) { + try { + await client.simulateContract({ + address: simulateParams.address, + abi: simulateParams.abi, + functionName: simulateParams.functionName, + args: simulateParams.args, + account: simulateParams.account, + value, + }); + } catch (error) { + console.error('Transaction simulation failed:', error); + } + } + // Send transaction to local fork const hash = await client.sendTransaction({ account: clientAddress, diff --git a/test/lib/utils/types.ts b/test/lib/utils/types.ts index d924b136..047cfd10 100644 --- a/test/lib/utils/types.ts +++ b/test/lib/utils/types.ts @@ -1,5 +1,5 @@ import { PublicWalletClient } from '@/utils/types'; -import { TestActions } from 'viem'; +import { Abi, TestActions } from 'viem'; import { AddLiquidity, AddLiquidityInput, @@ -72,3 +72,15 @@ export type AddLiquidityNestedTxInput = { client: PublicWalletClient & TestActions; wethIsEth?: boolean; }; + +/** + * Optional parameters for contract simulation to extract revert reasons + * Can be created in tests and optionally passed to sendTransactionGetBalances + */ +export type SimulateParams = { + abi: Abi; + functionName: string; + args: readonly unknown[]; + address: Address; // Contract address + account: Address; // Account address to use for simulation +}; diff --git a/test/v3/addLiquidityUnbalancedViaSwap/addLiquidityUnbalancedViaSwap.integration.test.ts b/test/v3/addLiquidityUnbalancedViaSwap/addLiquidityUnbalancedViaSwap.integration.test.ts new file mode 100644 index 00000000..b628bda9 --- /dev/null +++ b/test/v3/addLiquidityUnbalancedViaSwap/addLiquidityUnbalancedViaSwap.integration.test.ts @@ -0,0 +1,736 @@ +// pnpm test -- v3/addLiquidityUnbalancedViaSwap/addLiquidityUnbalancedViaSwap.integration.test.ts + +import { config } from 'dotenv'; +config(); + +import { + Address, + createTestClient, + http, + parseUnits, + publicActions, + TestActions, + walletActions, +} from 'viem'; + +import { + Slippage, + Hex, + PoolState, + CHAINS, + ChainId, + InputAmount, + PERMIT2, + PublicWalletClient, + SwapKind, + TokenAmount, +} from '@/index'; +import { + unbalancedAddViaSwapRouterAbi_V3, + vaultExtensionAbi_V3, + vaultAbi_V3, + permit2Abi, +} from '@/abi'; +import { + AddLiquidityUnbalancedViaSwapV3, + AddLiquidityUnbalancedViaSwapInput, +} from '@/entities/addLiquidityUnbalancedViaSwap'; +import { getPoolStateWithBalancesV3 } from '@/entities/utils/getPoolStateWithBalancesV3'; +import { AddressProvider } from '@/entities/inputValidator/utils/addressProvider'; +import { appendFileSync } from 'node:fs'; +import { + POOLS, + TOKENS, + setTokenBalances, + approveSpenderOnTokens, + approveTokens, + sendTransactionGetBalances, + areBigIntsWithinPercent, + SimulateParams, +} from '../../lib/utils'; +import { ANVIL_NETWORKS, startFork } from '../../anvil/anvil-global-setup'; + +const protocolVersion = 3; + +const chainId = ChainId.SEPOLIA; +const poolId = POOLS[chainId].MOCK_WETH_BAL_POOL.id; + +const WETH = TOKENS[chainId].WETH; +const DAI = TOKENS[chainId].DAI; +const STATA_USDC = TOKENS[chainId].stataUSDC; +const STATA_USDT = TOKENS[chainId].stataUSDT; + +// Toggle to control whether test results should be logged to files +const ENABLE_LOGGING = false; + +class MockApi { + async getPool(poolId: string): Promise { + const lowerId = poolId.toLowerCase(); + + if (lowerId === POOLS[chainId].MOCK_WETH_BAL_POOL.id.toLowerCase()) { + // WETH/BAL weighted pool + return { + id: poolId as `0x${string}`, + address: POOLS[chainId].MOCK_WETH_BAL_POOL.address as Address, + type: 'WEIGHTED', + protocolVersion: 3, + tokens: [ + { + address: WETH.address, + decimals: WETH.decimals, + index: 0, + }, + { + address: DAI.address, + decimals: DAI.decimals, + index: 1, + }, + ], + }; + } + + if (lowerId === POOLS[chainId].MOCK_BOOSTED_POOL.id.toLowerCase()) { + // stataUSDC/stataUSDT stable (boosted) pool at 0x59fa488dda749cdd41772bb068bb23ee955a6d7a + return { + id: poolId as `0x${string}`, + address: POOLS[chainId].MOCK_BOOSTED_POOL.address as Address, + type: 'STABLE', + protocolVersion: 3, + tokens: [ + { + address: STATA_USDC.address, + decimals: STATA_USDC.decimals, + index: 0, + }, + { + address: STATA_USDT.address, + decimals: STATA_USDT.decimals, + index: 1, + }, + ], + }; + } + + if (lowerId === POOLS[chainId].MOCK_RECLAMM_POOL.id.toLowerCase()) { + // ReClamm pool with WETH / DAI + return { + id: poolId as `0x${string}`, + address: POOLS[chainId].MOCK_RECLAMM_POOL.address as Address, + type: 'RECLAMM', + protocolVersion: 3, + tokens: [ + { + address: WETH.address, + decimals: WETH.decimals, + index: 0, + }, + { + address: DAI.address, + decimals: DAI.decimals, + index: 1, + }, + ], + }; + } + + throw new Error(`Unknown test poolId: ${poolId}`); + } +} + +describe('add liquidity unbalanced via swap test', () => { + let client: PublicWalletClient & TestActions; + let testAddress: Address; + let poolState: PoolState; + let reclammPoolState: PoolState; + let tokens: Address[]; + let rpcUrl: string; + let snapshot: Hex; + let addLiquidityUnbalancedViaSwap: AddLiquidityUnbalancedViaSwapV3; + + beforeAll(async () => { + // setup mock api + const api = new MockApi(); + + // get pool state from api + poolState = await api.getPool(poolId); + reclammPoolState = await api.getPool( + POOLS[chainId].MOCK_RECLAMM_POOL.id, + ); + + ({ rpcUrl } = await startFork( + ANVIL_NETWORKS[ChainId[chainId]], + undefined, + 9718873n, + )); + + client = createTestClient({ + mode: 'anvil', + chain: CHAINS[chainId], + transport: http(rpcUrl), + }) + .extend(publicActions) + .extend(walletActions); + + testAddress = (await client.getAddresses())[0]; + + addLiquidityUnbalancedViaSwap = new AddLiquidityUnbalancedViaSwapV3(); + + tokens = [...poolState.tokens.map((t) => t.address)]; + + await setTokenBalances( + client, + testAddress, + tokens, + [WETH.slot, DAI.slot] as number[], + [...poolState.tokens.map((t) => parseUnits('1000', t.decimals))], + ); + + await approveSpenderOnTokens( + client, + testAddress, + tokens, + PERMIT2[chainId], + ); + + snapshot = await client.snapshot(); + }); + + beforeEach(async () => { + await client.revert({ + id: snapshot, + }); + snapshot = await client.snapshot(); + }); + + describe('permit2 direct approval', () => { + // `approveTokens` pre‑approves all ERC20s used in the tests: + // - For protocolVersion 2 it approves the Balancer V2 Vault (`VAULT_V2`) as spender on each token. + // - For protocolVersion 3 (this file) it first approves Permit2 as spender on each token, and then, + // via Permit2, grants max allowances on those tokens to the Balancer V3 Routers + // (`Router`, `BatchRouter`, `CompositeLiquidityRouterNested`, `BufferRouter`) so they can pull funds + // from the test account when executing add-liquidity and swap operations. + beforeEach(async () => { + await approveTokens(client, testAddress, tokens, protocolVersion); + }); + + describe('ReClamm pool: error cases for unsupported scenarios', () => { + test('throws error when adjustableAmount is 0 and exactAmount > 0', async () => { + const addLiquidityInput: AddLiquidityUnbalancedViaSwapInput = { + chainId, + rpcUrl, + pool: reclammPoolState.address, + amountsIn: [ + { + rawAmount: parseUnits('0.01', WETH.decimals), + decimals: WETH.decimals, + address: WETH.address, + }, + { + rawAmount: 0n, // adjustableAmount = 0 (unsupported) + decimals: DAI.decimals, + address: DAI.address, + }, + ], + exactTokenIndex: 0, // WETH is exact, DAI is adjustable + addLiquidityUserData: '0x', + swapUserData: '0x', + sender: testAddress, + swapKind: SwapKind.GivenIn, + }; + + await expect( + addLiquidityUnbalancedViaSwap.query( + addLiquidityInput, + reclammPoolState, + ), + ).rejects.toThrow(); + }); + + test('throws error when both amounts are > 0', async () => { + const addLiquidityInput: AddLiquidityUnbalancedViaSwapInput = { + chainId, + rpcUrl, + pool: reclammPoolState.address, + amountsIn: [ + { + rawAmount: parseUnits('0.01', WETH.decimals), // exactAmount > 0 (unsupported) + decimals: WETH.decimals, + address: WETH.address, + }, + { + rawAmount: parseUnits('100', DAI.decimals), // adjustableAmount > 0 + decimals: DAI.decimals, + address: DAI.address, + }, + ], + exactTokenIndex: 0, // WETH is exact, DAI is adjustable + addLiquidityUserData: '0x', + swapUserData: '0x', + sender: testAddress, + swapKind: SwapKind.GivenIn, + }; + + await expect( + addLiquidityUnbalancedViaSwap.query( + addLiquidityInput, + reclammPoolState, + ), + ).rejects.toThrow(); + }); + }); + + describe('ReClamm pool: single-sided from adjustable (WETH exact = 0, DAI adjustable as % of pool DAI balance)', () => { + const FRACTIONS = [ + { label: '0.1%', num: 1n, den: 1000n }, + { label: '0.5%', num: 5n, den: 1000n }, + { label: '1%', num: 1n, den: 100n }, + { label: '5%', num: 5n, den: 100n }, + { label: '10%', num: 1n, den: 10n }, + { label: '20%', num: 2n, den: 10n }, + { label: '30%', num: 3n, den: 10n }, + { label: '40%', num: 4n, den: 10n }, + { label: '50%', num: 1n, den: 2n }, + { label: '60%', num: 3n, den: 5n }, + ] as const; + + let daiPoolBalanceRaw: bigint; + + beforeAll(async () => { + const reclammWithBalances = await getPoolStateWithBalancesV3( + reclammPoolState, + chainId, + rpcUrl, + ); + + const daiToken = reclammWithBalances.tokens.find( + (t) => + t.address.toLowerCase() === DAI.address.toLowerCase(), + ); + if (!daiToken) { + throw new Error('DAI token not found in ReClamm pool'); + } + + daiPoolBalanceRaw = parseUnits( + daiToken.balance, + daiToken.decimals, + ); + }); + + for (const { label, num, den } of FRACTIONS) { + test(`ReClamm single-sided adjustable with DAI budget = ${label} of pool DAI balance`, async () => { + const daiBudgetRaw = (daiPoolBalanceRaw * num) / den; + + const addLiquidityInput: AddLiquidityUnbalancedViaSwapInput = + { + chainId, + rpcUrl, + pool: reclammPoolState.address, + amountsIn: [ + { + // exact token (WETH) amount is zero + rawAmount: 0n, + decimals: WETH.decimals, + address: WETH.address, + }, + { + // adjustable token (DAI) budget as a fraction of pool DAI balance + rawAmount: daiBudgetRaw, + decimals: DAI.decimals, + address: DAI.address, + }, + ], + exactTokenIndex: 0, // WETH is exact, DAI is adjustable + addLiquidityUserData: '0x', + swapKind: SwapKind.GivenIn, + sender: testAddress, + }; + + const logBase = { + scenario: 'reclamm-single-sided-adjustable', + label, + daiBudgetRaw: daiBudgetRaw.toString(), + }; + + // the pool has these tokens + + // the add liquidity input is + // weth 0x7b79995e5f793a07bc00c21412e50ecae098e7f9 + // dai 0xb77eb1a70a96fdaaeb31db1b42f2b8b5846b2613 + + // the dai is not the same? + + try { + const queryOutput = + await addLiquidityUnbalancedViaSwap.query( + addLiquidityInput, + reclammPoolState, + ); + + // Assertions + expect(queryOutput).toBeDefined(); + expect(queryOutput.pool).toBe(reclammPoolState.address); + expect(queryOutput.chainId).toBe(chainId); + expect(queryOutput.protocolVersion).toBe(3); + expect(queryOutput.amountsIn).toHaveLength(2); + expect(queryOutput.bptOut.amount).toBeGreaterThan(0n); + + // Exact token is WETH with exactAmount = 0 + expect(queryOutput.exactToken.toLowerCase()).toBe( + WETH.address.toLowerCase(), + ); + expect(queryOutput.exactAmount).toBe(0n); + + // Adjustable token is DAI with some positive amount, within the budget + expect(queryOutput.adjustableTokenIndex).toBe(1); + const daiIn = + queryOutput.amountsIn[ + queryOutput.adjustableTokenIndex + ].amount; + expect(daiIn).toBeGreaterThan(0n); + expect(daiIn).toBeLessThanOrEqual(daiBudgetRaw); + + // + const deltaRaw = daiBudgetRaw - daiIn; + const deltaPctMilli = + daiBudgetRaw === 0n + ? 0n + : (deltaRaw * 100000n) / daiBudgetRaw; + const deltaPct = `${(deltaPctMilli / 1000n).toString()}.${(deltaPctMilli % 1000n).toString().padStart(3, '0')}`; + + // WETH leg should remain zero + const wethIn = + queryOutput.amountsIn[ + queryOutput.adjustableTokenIndex === 0 ? 1 : 0 + ].amount; + expect(wethIn).toBe(0n); + + // Execute the transaction + const deadline = 281474976710654n; // Large deadline for testing + // the queryoutput returns the actual amountsIn, not the + // daiBudgetRaw. + const buildCallInput = { + ...queryOutput, + slippage: Slippage.fromPercentage('1'), // 1% slippage + deadline, + }; + + const buildCallOutput = + addLiquidityUnbalancedViaSwap.buildCall( + buildCallInput, + ); + + expect(buildCallOutput.to).toBe( + AddressProvider.UnbalancedAddViaSwapRouter(chainId), + ); + expect(buildCallOutput.value).toBe(0n); + + // Send transaction and check balance changes + + // attach optional simulate params to the transaction + const simulateParams: SimulateParams = { + abi: [ + ...unbalancedAddViaSwapRouterAbi_V3, + ...vaultExtensionAbi_V3, + ...vaultAbi_V3, + ...permit2Abi, + ], + functionName: 'addLiquidityUnbalanced', + args: [ + buildCallInput.pool, + buildCallInput.deadline, + false, + { + exactBptAmountOut: + buildCallInput.bptOut.amount, + exactToken: buildCallInput.exactToken, + exactAmount: buildCallInput.exactAmount, + maxAdjustableAmount: + buildCallInput.amountsIn[ + buildCallInput.adjustableTokenIndex + ].amount, + addLiquidityUserData: '0x', + swapUserData: '0x', + }, + ] as const, + address: buildCallOutput.to, + account: testAddress, + }; + const { transactionReceipt, balanceDeltas } = + await sendTransactionGetBalances( + [ + ...reclammPoolState.tokens.map( + (t) => t.address, + ), + queryOutput.bptOut.token.address, // BPT token + ], + client, + testAddress, + buildCallOutput.to, + buildCallOutput.callData, + buildCallOutput.value, + simulateParams, + ); + + expect(transactionReceipt.status).toBe('success'); + + // Verify input token amounts (WETH should be 0, DAI should be used) + const wethDelta = balanceDeltas[0]; + const daiDelta = balanceDeltas[1]; + const bptDelta = balanceDeltas[2]; + + expect(wethDelta).toBe(0n); + expect(daiDelta).toBeGreaterThan(0n); + expect(daiDelta).toBeLessThanOrEqual(daiBudgetRaw); + expect(bptDelta).toBeGreaterThan(0n); + + // Verify BPT output is within acceptable tolerance + areBigIntsWithinPercent( + bptDelta, + queryOutput.bptOut.amount, + 0.01, // 1% tolerance + ); + + if (ENABLE_LOGGING) { + appendFileSync( + 'single-sided-adjustable-results.log', + `${JSON.stringify({ + ...logBase, + passed: true, + daiIn: daiIn.toString(), + wethIn: wethIn.toString(), + deltaRaw: deltaRaw.toString(), + deltaPct: deltaPct, + bptOut: queryOutput.bptOut.amount.toString(), + transactionExecuted: true, + bptDelta: bptDelta.toString(), + daiDelta: daiDelta.toString(), + })}\n`, + ); + } + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : String(err); + const isAmountAboveMax = + msg.includes('AmountInAboveMaxAdjustableAmount') || + msg.includes('AmountInAboveMaxAdjustable'); + + if (ENABLE_LOGGING) { + appendFileSync( + 'single-sided-adjustable-results.log', + `${JSON.stringify({ + ...logBase, + passed: false, + error: msg, + isAmountAboveMaxAdjustableAmount: + isAmountAboveMax, + })}\n`, + ); + } + + throw err; + } + }); + } + }); + + // SKIP as The router is not deployed on Gnosis yet + describe.skip('Gnosis ReClamm GNO/wstETH: single-sided from adjustable (wstETH exact = 0, GNO adjustable as % of pool GNO balance)', () => { + const gnosisChainId = ChainId.GNOSIS_CHAIN; + const RECLAMM_POOL = POOLS[gnosisChainId].reclammGNO_wstETH; + // Token metadata reused from Gnosis mapping (addresses/decimals match the pool) + const WSTETH = TOKENS[gnosisChainId].wstETH; + const GNO = TOKENS[gnosisChainId].GNO; + + const FRACTIONS = [ + { label: '0.1%', num: 1n, den: 1000n }, + { label: '0.5%', num: 5n, den: 1000n }, + { label: '1%', num: 1n, den: 100n }, + { label: '5%', num: 5n, den: 100n }, + { label: '10%', num: 1n, den: 10n }, + { label: '20%', num: 2n, den: 10n }, + { label: '30%', num: 3n, den: 10n }, + { label: '40%', num: 4n, den: 10n }, + { label: '50%', num: 1n, den: 2n }, + { label: '60%', num: 3n, den: 5n }, + ] as const; + + let gnosisReclammPoolState: PoolState; + let gnosisRpcUrl: string; + let gnosisAddLiquidityUnbalancedViaSwap: AddLiquidityUnbalancedViaSwapV3; + let gnoPoolBalanceRaw: bigint; + + beforeAll(async () => { + ({ rpcUrl: gnosisRpcUrl } = await startFork( + ANVIL_NETWORKS[ + ChainId[gnosisChainId] as keyof typeof ANVIL_NETWORKS + ], + )); + + // Minimal PoolState: type, address, tokens with correct addresses/decimals. + gnosisReclammPoolState = { + id: RECLAMM_POOL.id as Hex, + address: RECLAMM_POOL.address as Address, + type: 'RECLAMM', + protocolVersion: 3, + tokens: [ + { + address: WSTETH.address, + decimals: WSTETH.decimals, + index: 0, + }, + { + address: GNO.address, + decimals: GNO.decimals, + index: 1, + }, + ], + }; + + gnosisAddLiquidityUnbalancedViaSwap = + new AddLiquidityUnbalancedViaSwapV3(); + + // Get pool balance for GNO (adjustable token) + const reclammWithBalances = await getPoolStateWithBalancesV3( + gnosisReclammPoolState, + gnosisChainId, + gnosisRpcUrl, + ); + + const gnoToken = reclammWithBalances.tokens.find( + (t) => + t.address.toLowerCase() === GNO.address.toLowerCase(), + ); + if (!gnoToken) { + throw new Error('GNO token not found in ReClamm pool'); + } + + gnoPoolBalanceRaw = parseUnits( + gnoToken.balance, + gnoToken.decimals, + ); + }); + + for (const { label, num, den } of FRACTIONS) { + test(`Gnosis ReClamm single-sided adjustable with GNO budget = ${label} of pool GNO balance`, async () => { + // Arbitrary sender; we only call `query`, no on-chain execution. + const sender = + '0x0000000000000000000000000000000000000001' as Address; + + const gnoBudgetRaw = (gnoPoolBalanceRaw * num) / den; + + const addLiquidityInput: AddLiquidityUnbalancedViaSwapInput = + { + chainId: gnosisChainId, + rpcUrl: gnosisRpcUrl, + pool: RECLAMM_POOL.address as Address, + amountsIn: [ + { + // exact token (wstETH) amount is zero + rawAmount: 0n, + decimals: WSTETH.decimals, + address: WSTETH.address, + }, + { + // adjustable token (GNO) budget as a fraction of pool GNO balance + rawAmount: gnoBudgetRaw, + decimals: GNO.decimals, + address: GNO.address, + }, + ], + exactTokenIndex: 0, // wstETH is exact, GNO is adjustable + addLiquidityUserData: '0x', + swapKind: SwapKind.GivenIn, + sender, + }; + + const logBase = { + scenario: 'gnosis-reclamm-single-sided-adjustable', + label, + gnoBudgetRaw: gnoBudgetRaw.toString(), + }; + + try { + const queryOutput = + await gnosisAddLiquidityUnbalancedViaSwap.query( + addLiquidityInput, + gnosisReclammPoolState, + ); + + // Assertions + expect(queryOutput).toBeDefined(); + expect(queryOutput.pool).toBe(RECLAMM_POOL.address); + expect(queryOutput.chainId).toBe(gnosisChainId); + expect(queryOutput.protocolVersion).toBe(3); + expect(queryOutput.amountsIn).toHaveLength(2); + expect(queryOutput.bptOut.amount).toBeGreaterThan(0n); + + // Exact token is wstETH with exactAmount = 0 + expect(queryOutput.exactToken.toLowerCase()).toBe( + WSTETH.address.toLowerCase(), + ); + expect(queryOutput.exactAmount).toBe(0n); + + // Adjustable token is GNO with some positive amount, within the budget + expect(queryOutput.adjustableTokenIndex).toBe(1); + const gnoIn = + queryOutput.amountsIn[ + queryOutput.adjustableTokenIndex + ].amount; + expect(gnoIn).toBeGreaterThan(0n); + expect(gnoIn).toBeLessThanOrEqual(gnoBudgetRaw); + + const deltaRaw = gnoBudgetRaw - gnoIn; + const deltaPctMilli = + gnoBudgetRaw === 0n + ? 0n + : (deltaRaw * 100000n) / gnoBudgetRaw; + const deltaPct = `${(deltaPctMilli / 1000n).toString()}.${(deltaPctMilli % 1000n).toString().padStart(3, '0')}`; + + // wstETH leg should remain zero + const wstEthIn = + queryOutput.amountsIn[ + queryOutput.adjustableTokenIndex === 0 ? 1 : 0 + ].amount; + expect(wstEthIn).toBe(0n); + + if (ENABLE_LOGGING) { + appendFileSync( + 'gnosis-single-sided-adjustable-results.log', + `${JSON.stringify({ + ...logBase, + passed: true, + gnoIn: gnoIn.toString(), + wstEthIn: wstEthIn.toString(), + deltaRaw: deltaRaw.toString(), + deltaPct: deltaPct, + bptOut: queryOutput.bptOut.amount.toString(), + })}\n`, + ); + } + } catch (err: unknown) { + const msg = + err instanceof Error ? err.message : String(err); + const isAmountAboveMax = + msg.includes('AmountInAboveMaxAdjustableAmount') || + msg.includes('AmountInAboveMaxAdjustable'); + + if (ENABLE_LOGGING) { + appendFileSync( + 'gnosis-single-sided-adjustable-results.log', + `${JSON.stringify({ + ...logBase, + passed: false, + error: msg, + isAmountAboveMaxAdjustableAmount: + isAmountAboveMax, + })}\n`, + ); + } + + throw err; + } + }); + } + }); + }); +}); diff --git a/test/validateAllNetworks.test.ts b/test/validateAllNetworks.test.ts index bd2e9324..27aa0ed6 100644 --- a/test/validateAllNetworks.test.ts +++ b/test/validateAllNetworks.test.ts @@ -1,31 +1,24 @@ +// pnpm test test/validateAllNetworks.test.ts + import { config } from 'dotenv'; config(); -import { - Address, - createTestClient, - http, - parseEther, - publicActions, - walletActions, - TestActions, - Hex, -} from 'viem'; +import { AddressProvider } from '@/entities/inputValidator/utils/addressProvider'; +import { Path } from '@/entities/swap/paths/types'; import { CHAINS, ChainId, - SwapKind, - Swap, PERMIT2, PublicWalletClient, + Swap, + SwapKind, + Address, } from '@/index'; -import { Path } from '@/entities/swap/paths/types'; -import { AddressProvider } from '@/entities/inputValidator/utils/addressProvider'; +import { POOLS, TOKENS, TestPool, TestToken } from 'test/lib/utils/addresses'; import { approveSpenderOnTokens, approveTokens, setTokenBalances, } from 'test/lib/utils/helper'; -import { POOLS, TOKENS, TestToken, TestPool } from 'test/lib/utils/addresses'; import { assertSwapExactIn } from 'test/lib/utils/swapHelpers'; import { ANVIL_NETWORKS, @@ -33,6 +26,14 @@ import { stopAnvilFork, NetworkSetup, } from 'test/anvil/anvil-global-setup'; +import { + http, + TestActions, + createTestClient, + parseEther, + publicActions, + walletActions, +} from 'viem'; const protocolVersion = 3; diff --git a/test/validateNewChainSetup.test.ts b/test/validateNewChainSetup.test.ts index 18bd8660..9117a43a 100644 --- a/test/validateNewChainSetup.test.ts +++ b/test/validateNewChainSetup.test.ts @@ -1,3 +1,5 @@ +// pnpm test test/validateNewChainSetup.test.ts + import { API_CHAIN_NAMES, ChainId } from '@/utils'; import { NATIVE_ASSETS } from '@/utils/constants'; import { SorSwapPaths } from '@/data/providers/balancer-api/modules/sorSwapPaths';