diff --git a/typescript/src/hooks/exitFeeHook.ts b/typescript/src/hooks/exitFeeHook.ts index fdf0b8d..e9d8ae0 100644 --- a/typescript/src/hooks/exitFeeHook.ts +++ b/typescript/src/hooks/exitFeeHook.ts @@ -1,3 +1,97 @@ +import { RemoveKind } from '../vault/types'; +import { HookBase } from './types'; +import { MathSol } from '../utils/math'; + export type HookStateExitFee = { + tokens: string[]; removeLiquidityHookFeePercentage: bigint; }; + +/** + * This hook implements the ExitFeeHookExample found in mono-repo: https://github.com/balancer/balancer-v3-monorepo/blob/c848c849cb44dc35f05d15858e4fba9f17e92d5e/pkg/pool-hooks/contracts/ExitFeeHookExample.sol + */ +export class ExitFeeHook implements HookBase { + public shouldCallComputeDynamicSwapFee = false; + public shouldCallBeforeSwap = false; + public shouldCallAfterSwap = false; + public shouldCallBeforeAddLiquidity = false; + public shouldCallAfterAddLiquidity = false; + public shouldCallBeforeRemoveLiquidity = false; + public shouldCallAfterRemoveLiquidity = true; + public enableHookAdjustedAmounts = true; + + onBeforeAddLiquidity() { + return { success: false, hookAdjustedBalancesScaled18: [] }; + } + onAfterAddLiquidity() { + return { success: false, hookAdjustedAmountsInRaw: [] }; + } + onBeforeRemoveLiquidity() { + return { success: false, hookAdjustedBalancesScaled18: [] }; + } + onAfterRemoveLiquidity( + kind: RemoveKind, + bptAmountIn: bigint, + amountsOutScaled18: bigint[], + amountsOutRaw: bigint[], + balancesScaled18: bigint[], + hookState: HookStateExitFee, + ) { + if ( + !( + typeof hookState === 'object' && + hookState !== null && + 'removeLiquidityHookFeePercentage' in hookState && + 'tokens' in hookState + ) + ) + throw new Error('Unexpected hookState'); + + // Our current architecture only supports fees on tokens. Since we must always respect exact `amountsOut`, and + // non-proportional remove liquidity operations would require taking fees in BPT, we only support proportional + // removeLiquidity. + if (kind !== RemoveKind.PROPORTIONAL) { + throw new Error(`ExitFeeHook: Unsupported RemoveKind: ${kind}`); + } + const accruedFees = new Array(hookState.tokens.length).fill(0n); + const hookAdjustedAmountsOutRaw = [...amountsOutRaw]; + if (hookState.removeLiquidityHookFeePercentage > 0) { + // Charge fees proportional to amounts out of each token + for (let i = 0; i < amountsOutRaw.length; i++) { + const hookFee = MathSol.mulDownFixed( + amountsOutRaw[i], + hookState.removeLiquidityHookFeePercentage, + ); + accruedFees[i] = hookFee; + hookAdjustedAmountsOutRaw[i] -= hookFee; + // Fees don't need to be transferred to the hook, because donation will reinsert them in the vault + } + + // In SC Hook Donates accrued fees back to LPs + // _vault.addLiquidity( + // AddLiquidityParams({ + // pool: pool, + // to: msg.sender, // It would mint BPTs to router, but it's a donation so no BPT is minted + // maxAmountsIn: accruedFees, // Donate all accrued fees back to the pool (i.e. to the LPs) + // minBptAmountOut: 0, // Donation does not return BPTs, any number above 0 will revert + // kind: AddLiquidityKind.DONATION, + // userData: bytes(''), // User data is not used by donation, so we can set to an empty string + // }), + // ); + } + + return { + success: true, + hookAdjustedAmountsOutRaw, + }; + } + onBeforeSwap() { + return { success: false, hookAdjustedBalancesScaled18: [] }; + } + onAfterSwap() { + return { success: false, hookAdjustedAmountCalculatedRaw: 0n }; + } + onComputeDynamicSwapFee() { + return { success: false, dynamicSwapFee: 0n }; + } +} diff --git a/typescript/src/vault/vault.ts b/typescript/src/vault/vault.ts index ab63aad..a345ea6 100644 --- a/typescript/src/vault/vault.ts +++ b/typescript/src/vault/vault.ts @@ -23,6 +23,7 @@ import { } from './types'; import { HookBase, HookClassConstructor, HookState } from '../hooks/types'; import { defaultHook } from '../hooks/constants'; +import { ExitFeeHook } from '../hooks/exitFeeHook'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type PoolClassConstructor = new (..._args: any[]) => PoolBase; @@ -46,6 +47,7 @@ export class Vault { ...customPoolClasses, }; this.hookClasses = { + ExitFee: ExitFeeHook, // custom hooks take precedence over base types ...hookClasses, }; diff --git a/typescript/test/customPool.test.ts b/typescript/test/customPool.test.ts index e64c184..25fd56d 100644 --- a/typescript/test/customPool.test.ts +++ b/typescript/test/customPool.test.ts @@ -53,6 +53,13 @@ class CustomPool implements PoolBase { return 1n; } + getMaxSingleTokenRemoveAmount() { + return 1n; + } + getMaxSingleTokenAddAmount() { + return 1n; + } + onSwap(): bigint { return this.randoms[0]; } diff --git a/typescript/test/hooks/afterRemoveLiquidity.test.ts b/typescript/test/hooks/afterRemoveLiquidity.test.ts index 609fdef..a26897e 100644 --- a/typescript/test/hooks/afterRemoveLiquidity.test.ts +++ b/typescript/test/hooks/afterRemoveLiquidity.test.ts @@ -106,6 +106,13 @@ class CustomPool implements PoolBase { return 1n; } + getMaxSingleTokenRemoveAmount() { + return 1n; + } + getMaxSingleTokenAddAmount() { + return 1n; + } + onSwap(): bigint { return 1n; } diff --git a/typescript/test/hooks/afterSwap.test.ts b/typescript/test/hooks/afterSwap.test.ts index 974be10..d3331d7 100644 --- a/typescript/test/hooks/afterSwap.test.ts +++ b/typescript/test/hooks/afterSwap.test.ts @@ -84,6 +84,13 @@ class CustomPool implements PoolBase { return 1n; } + getMaxSingleTokenRemoveAmount() { + return 1n; + } + getMaxSingleTokenAddAmount() { + return 1n; + } + onSwap(): bigint { return 100000000000n; } diff --git a/typescript/test/hooks/beforeRemoveLiquidity.test.ts b/typescript/test/hooks/beforeRemoveLiquidity.test.ts index 16ac87a..24279a7 100644 --- a/typescript/test/hooks/beforeRemoveLiquidity.test.ts +++ b/typescript/test/hooks/beforeRemoveLiquidity.test.ts @@ -71,6 +71,13 @@ class CustomPool implements PoolBase { return 1n; } + getMaxSingleTokenRemoveAmount() { + return 1n; + } + getMaxSingleTokenAddAmount() { + return 1n; + } + onSwap(): bigint { return 1n; } diff --git a/typescript/test/hooks/dynamicFee.test.ts b/typescript/test/hooks/dynamicFee.test.ts deleted file mode 100644 index d9f5be3..0000000 --- a/typescript/test/hooks/dynamicFee.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// pnpm test -- dynamicFee.test.ts -import { describe, expect, test } from 'vitest'; -import { SwapInput, SwapKind, Vault, Weighted } from '../../src'; -import { HookBase, HookState } from '@/hooks/types'; - -const pool = { - poolType: 'CustomPool', - hookType: 'CustomHook', - chainId: '11155111', - blockNumber: '5955145', - poolAddress: '0xb2456a6f51530053bc41b0ee700fe6a2c37282e8', - tokens: [ - '0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9', - '0xb19382073c7A0aDdbb56Ac6AF1808Fa49e377B75', - ], - scalingFactors: [1000000000000000000n, 1000000000000000000n], - weights: [500000000000000000n, 500000000000000000n], - swapFee: 0n, - - aggregateSwapFee: 500000000000000000n, - balancesLiveScaled18: [2000000000000000000n, 2000000000000000000n], - tokenRates: [1000000000000000000n, 1000000000000000000n], - totalSupply: 1000000000000000000n, -}; - -const swapInput = { - swapKind: SwapKind.GivenIn, - amountRaw: 100000000000000n, - tokenIn: pool.tokens[0], - tokenOut: pool.tokens[1], -}; - -describe('hook - dynamicFee', () => { - const vault = new Vault({ - customPoolClasses: { - CustomPool: CustomPool, - }, - customHookClasses: { - CustomHook: CustomHook, - }, - }); - - test('should use dynamicFee set by hook', () => { - /* - hook state is used to pass new swapFee which gives expected swap result - */ - const inputHookState = { - newSwapFee: 100000000000000000n, - }; - const test = vault.swap(swapInput, pool, inputHookState); - expect(test).to.eq(89995500224987n); - }); -}); - -class CustomPool extends Weighted {} - -class CustomHook implements HookBase { - public shouldCallComputeDynamicSwapFee = true; - public shouldCallBeforeSwap = false; - public shouldCallAfterSwap = false; - public shouldCallBeforeAddLiquidity = false; - public shouldCallAfterAddLiquidity = false; - public shouldCallBeforeRemoveLiquidity = false; - public shouldCallAfterRemoveLiquidity = false; - public enableHookAdjustedAmounts = false; - - onBeforeAddLiquidity() { - return { success: false, hookAdjustedBalancesScaled18: [] }; - } - onAfterAddLiquidity() { - return { success: false, hookAdjustedAmountsInRaw: [] }; - } - onBeforeRemoveLiquidity() { - return { success: false, hookAdjustedBalancesScaled18: [] }; - } - onAfterRemoveLiquidity() { - return { - success: false, - hookAdjustedAmountsOutRaw: [], - }; - } - onBeforeSwap() { - return { success: false, hookAdjustedBalancesScaled18: [] }; - } - onAfterSwap() { - return { success: false, hookAdjustedAmountCalculatedRaw: 0n }; - } - onComputeDynamicSwapFee( - params: SwapInput, - staticSwapFeePercentage: bigint, - hookState: HookState | unknown, - ) { - if ( - !( - typeof hookState === 'object' && - hookState !== null && - 'newSwapFee' in hookState - ) - ) - throw new Error('Unexpected hookState'); - expect(params.swapKind).to.eq(swapInput.swapKind); - expect(params.tokenIn).to.eq(swapInput.tokenIn); - expect(params.tokenOut).to.eq(swapInput.tokenOut); - expect(params.amountRaw).to.eq(swapInput.amountRaw); - expect(staticSwapFeePercentage).to.eq(pool.swapFee); - return { - success: true, - dynamicSwapFee: hookState.newSwapFee as bigint, - }; - } -} diff --git a/typescript/test/hooks/exitFee.test.ts b/typescript/test/hooks/exitFee.test.ts new file mode 100644 index 0000000..9c25af0 --- /dev/null +++ b/typescript/test/hooks/exitFee.test.ts @@ -0,0 +1,65 @@ +// pnpm test -- exitFee.test.ts +import { describe, expect, test } from 'vitest'; +import { RemoveKind, Vault } from '../../src'; + +const poolState = { + poolType: 'Weighted', + hookType: 'ExitFee', + chainId: '11155111', + blockNumber: '5955145', + poolAddress: '0x03722034317d8fb16845213bd3ce15439f9ce136', + tokens: [ + '0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9', + '0xb19382073c7A0aDdbb56Ac6AF1808Fa49e377B75', + ], + scalingFactors: [1000000000000000000n, 1000000000000000000n], + weights: [500000000000000000n, 500000000000000000n], + swapFee: 100000000000000000n, + aggregateSwapFee: 0n, + balancesLiveScaled18: [5000000000000000n, 5000000000000000000n], + tokenRates: [1000000000000000000n, 1000000000000000000n], + totalSupply: 158113883008415798n, +}; + +const removeLiquidityInput = { + pool: '0xb2456a6f51530053bc41b0ee700fe6a2c37282e8', + minAmountsOut: [1n, 1n], + maxBptAmountIn: 10000000000000n, + kind: RemoveKind.PROPORTIONAL, +}; + +describe('hook - exitFee', () => { + const vault = new Vault(); + + test('exitFee of 0', () => { + const inputHookState = { + removeLiquidityHookFeePercentage: 0n, + tokens: poolState.tokens, + }; + const outPutAmount = vault.removeLiquidity( + removeLiquidityInput, + poolState, + inputHookState, + ); + expect(outPutAmount.amountsOut).to.deep.eq([ + 316227766016n, + 316227766016840n, + ]); + }); + + test('exitFee of 5%', () => { + const inputHookState = { + removeLiquidityHookFeePercentage: 50000000000000000n, + tokens: poolState.tokens, + }; + const outPutAmount = vault.removeLiquidity( + removeLiquidityInput, + poolState, + inputHookState, + ); + expect(outPutAmount.amountsOut).to.deep.eq([ + 300416377716n, + 300416377715998n, + ]); + }); +}); diff --git a/typescript/test/hooks/hook.test.ts b/typescript/test/hooks/hook.test.ts index ad96170..3d232f7 100644 --- a/typescript/test/hooks/hook.test.ts +++ b/typescript/test/hooks/hook.test.ts @@ -71,6 +71,13 @@ class CustomPool implements PoolBase { return 1n; } + getMaxSingleTokenRemoveAmount() { + return 1n; + } + getMaxSingleTokenAddAmount() { + return 1n; + } + onSwap(): bigint { return this.randoms[0]; }