diff --git a/testData/bun.lockb b/testData/bun.lockb index f3d097c..b03b2d3 100755 Binary files a/testData/bun.lockb and b/testData/bun.lockb differ diff --git a/typescript/src/hooks/exitFeeHook.ts b/typescript/src/hooks/exitFeeHook.ts new file mode 100644 index 0000000..fdf0b8d --- /dev/null +++ b/typescript/src/hooks/exitFeeHook.ts @@ -0,0 +1,3 @@ +export type HookStateExitFee = { + removeLiquidityHookFeePercentage: bigint; +}; diff --git a/typescript/src/hooks/types.ts b/typescript/src/hooks/types.ts index 2efce8a..2a7be7a 100644 --- a/typescript/src/hooks/types.ts +++ b/typescript/src/hooks/types.ts @@ -15,7 +15,6 @@ export type AfterSwapParams = { amountCalculatedRaw: bigint; router: string; pool: string; - userData: string; }; export interface HookBase { @@ -26,6 +25,7 @@ export interface HookBase { shouldCallAfterAddLiquidity: boolean; shouldCallBeforeRemoveLiquidity: boolean; shouldCallAfterRemoveLiquidity: boolean; + enableHookAdjustedAmounts: boolean; onBeforeAddLiquidity( router: string, @@ -34,7 +34,6 @@ export interface HookBase { maxAmountsInScaled18: bigint[], minBptAmountOut: bigint, balancesScaled18: bigint[], - userData: string, ): boolean; onAfterAddLiquidity( router: string, @@ -44,7 +43,6 @@ export interface HookBase { amountsInRaw: bigint[], bptAmountOut: bigint, balancesScaled18: bigint[], - userData: string, ): { success: boolean; hookAdjustedAmountsInRaw: bigint[] }; onBeforeRemoveLiquidity( router: string, @@ -53,17 +51,14 @@ export interface HookBase { maxBptAmountIn: bigint, minAmountsOutScaled18: bigint[], balancesScaled18: bigint[], - userData: string, ): boolean; onAfterRemoveLiquidity( - router: string, - pool: string, kind: RemoveKind, bptAmountIn: bigint, amountsOutScaled18: bigint[], amountsOutRaw: bigint[], balancesScaled18: bigint[], - userData: string, + hookState: HookState | unknown, ): { success: boolean; hookAdjustedAmountsOutRaw: bigint[] }; onBeforeSwap(params: SwapInput, poolAddress: string): boolean; onAfterSwap(params: AfterSwapParams): { diff --git a/typescript/src/vault/types.ts b/typescript/src/vault/types.ts index c4c216f..8f581a4 100644 --- a/typescript/src/vault/types.ts +++ b/typescript/src/vault/types.ts @@ -5,6 +5,7 @@ export type PoolState = { tokenRates: bigint[]; balancesLiveScaled18: bigint[]; swapFee: bigint; + aggregateSwapFee: bigint; totalSupply: bigint; hookType?: string; }; diff --git a/typescript/src/vault/vault.ts b/typescript/src/vault/vault.ts index a75a111..289fb33 100644 --- a/typescript/src/vault/vault.ts +++ b/typescript/src/vault/vault.ts @@ -24,7 +24,6 @@ import { import { HookBase, HookClassConstructor, HookState } from '../hooks/types'; import { defaultHook } from '../hooks/constants'; - // eslint-disable-next-line @typescript-eslint/no-explicit-any type PoolClassConstructor = new (..._args: any[]) => PoolBase; type PoolClasses = Readonly>; @@ -59,7 +58,10 @@ export class Vault { return new poolClass(poolState); } - public getHook(hookName?: string, hookState?: HookState): HookBase { + public getHook( + hookName?: string, + hookState?: HookState | unknown, + ): HookBase { if (!hookName) return defaultHook; const hookClass = this.hookClasses[hookName]; if (!hookClass) throw new Error(`Unsupported Hook Type: ${hookName}`); @@ -247,7 +249,7 @@ export class Vault { public removeLiquidity( input: RemoveLiquidityInput, poolState: PoolState, - hookState?: HookState, + hookState?: HookState | unknown, ): { amountsOut: bigint[]; bptAmountIn: bigint } { if (poolState.poolType === 'Buffer') throw Error('Buffer pools do not support removeLiquidity'); @@ -279,9 +281,13 @@ export class Vault { let tokenOutIndex: number; let bptAmountIn: bigint; let amountsOutScaled18: bigint[]; + let swapFeeAmountsScaled18: bigint[]; if (input.kind === RemoveKind.PROPORTIONAL) { bptAmountIn = input.maxBptAmountIn; + swapFeeAmountsScaled18 = new Array(poolState.tokens.length).fill( + 0n, + ); amountsOutScaled18 = computeProportionalAmountsOut( poolState.balancesLiveScaled18, poolState.totalSupply, @@ -305,6 +311,7 @@ export class Vault { ), ); amountsOutScaled18[tokenOutIndex] = computed.amountOutWithFee; + swapFeeAmountsScaled18 = computed.swapFeeAmounts; } else if (input.kind === RemoveKind.SINGLE_TOKEN_EXACT_OUT) { amountsOutScaled18 = minAmountsOutScaled18; tokenOutIndex = this._getSingleInputIndex(input.minAmountsOut); @@ -318,9 +325,11 @@ export class Vault { pool.computeInvariant(balancesLiveScaled18), ); bptAmountIn = computed.bptAmountIn; + swapFeeAmountsScaled18 = computed.swapFeeAmounts; } else throw new Error('Unsupported RemoveLiquidity Kind'); const amountsOutRaw = new Array(poolState.tokens.length); + const updatedBalancesLiveScaled18 = [...poolState.balancesLiveScaled18]; for (let i = 0; i < poolState.tokens.length; ++i) { // amountsOut are amounts exiting the Pool, so we round down. @@ -329,11 +338,46 @@ export class Vault { poolState.scalingFactors[i], poolState.tokenRates[i], ); + + // A Pool's token balance always decreases after an exit + // Computes protocol and pool creator fee which is eventually taken from pool balance + const aggregateSwapFeeAmountScaled18 = + this._computeAndChargeAggregateSwapFees( + swapFeeAmountsScaled18[i], + poolState.aggregateSwapFee, + ); + + updatedBalancesLiveScaled18[i] = + poolState.balancesLiveScaled18[i] - + (amountsOutScaled18[i] + aggregateSwapFeeAmountScaled18); } - // hook: shouldCallAfterRemoveLiquidity + // AmountsOut can be changed by onAfterRemoveLiquidity if the hook charges fees or gives discounts if (hook.shouldCallAfterRemoveLiquidity) { - throw new Error('Hook Unsupported: shouldCallAfterRemoveLiquidity'); + const { success, hookAdjustedAmountsOutRaw } = + hook.onAfterRemoveLiquidity( + input.kind, + bptAmountIn, + amountsOutScaled18, + amountsOutRaw, + updatedBalancesLiveScaled18, + hookState, + ); + + if ( + success === false || + hookAdjustedAmountsOutRaw.length != amountsOutRaw.length + ) { + throw new Error( + `AfterRemoveLiquidityHookFailed ${poolState.poolType} ${poolState.hookType}`, + ); + } + + // If hook adjusted amounts is not enabled, ignore amounts returned by the hook + if (hook.enableHookAdjustedAmounts) + hookAdjustedAmountsOutRaw.forEach( + (a, i) => (amountsOutRaw[i] = a), + ); } return { @@ -342,6 +386,19 @@ export class Vault { }; } + private _computeAndChargeAggregateSwapFees( + swapFeeAmountScaled18: bigint, + aggregateSwapFeePercentage: bigint, + ): bigint { + if (swapFeeAmountScaled18 > 0 && aggregateSwapFeePercentage > 0) { + return MathSol.mulUpFixed( + swapFeeAmountScaled18, + aggregateSwapFeePercentage, + ); + } + return 0n; + } + private _getSingleInputIndex(maxAmountsIn: bigint[]): number { const length = maxAmountsIn.length; let inputIndex = length; diff --git a/typescript/test/hooks/afterRemoveLiquidity.test.ts b/typescript/test/hooks/afterRemoveLiquidity.test.ts new file mode 100644 index 0000000..668873a --- /dev/null +++ b/typescript/test/hooks/afterRemoveLiquidity.test.ts @@ -0,0 +1,178 @@ +// pnpm test -- afterRemoveLiquidity.test.ts +import { describe, expect, test } from 'vitest'; +import { RemoveKind, Vault, type PoolBase } from '../../src'; +import { HookBase, HookState } from '@/hooks/types'; + +const removeLiquidityInput = { + pool: '0xb2456a6f51530053bc41b0ee700fe6a2c37282e8', + minAmountsOut: [0n, 1n], + maxBptAmountIn: 100000000000000000n, + kind: RemoveKind.SINGLE_TOKEN_EXACT_IN, +}; + +/* +remove: + SINGLE_TOKEN_EXACT_IN: + bptAmountIn: 100000000000000000n + returns: + amountsOutScaled18: [ 0n, 909999999999999999n ] + amountsOutRaw: [ 0n, 909999999999999999n ] +*/ +const pool = { + poolType: 'CustomPool', + hookType: 'CustomHook', + chainId: '11155111', + blockNumber: '5955145', + poolAddress: '0xb2456a6f51530053bc41b0ee700fe6a2c37282e8', + tokens: [ + '0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9', + '0xb19382073c7A0aDdbb56Ac6AF1808Fa49e377B75', + ], + scalingFactors: [1000000000000000000n, 1000000000000000000n], + weights: [500000000000000000n, 500000000000000000n], + swapFee: 100000000000000000n, + aggregateSwapFee: 500000000000000000n, + balancesLiveScaled18: [1000000000000000000n, 1000000000000000000n], + tokenRates: [1000000000000000000n, 1000000000000000000n], + totalSupply: 1000000000000000000n, +}; + +describe('hook - afterRemoveLiquidity', () => { + const vault = new Vault({ + customPoolClasses: { + CustomPool: CustomPool, + }, + customHookClasses: { + CustomHook: CustomHook, + }, + }); + + test('aggregateSwapFee of 0 should not take any protocol fees from updated balances', () => { + /* + hook state is used to pass expected value to tests + Original balance is 1 + Amount out is 0.9099... + Leaves 0.090000000000000001 + Swap fee amount is: 0.09 which is all left in pool because aggregateFee is 0 + */ + const inputHookState = { + expectedBalancesLiveScaled18: [ + 1000000000000000000n, + 90000000000000001n, + ], + }; + const test = vault.removeLiquidity( + removeLiquidityInput, + { + ...pool, + aggregateSwapFee: 0n, + }, + inputHookState, + ); + expect(test.bptAmountIn).to.eq(removeLiquidityInput.maxBptAmountIn); + expect(test.amountsOut).to.deep.eq([0n, 0n]); + }); + + test('aggregateSwapFee of 50% should take half of remaining', () => { + /* + hook state is used to pass expected value to tests + Original balance is 1 + Amount out is 0.9099... + Leaves 0.090000000000000001 + Swap fee amount is: 0.09 + Aggregate fee amount is 50% of swap fee: 0.045 + Leaves 0.045000000000000001 in pool + */ + const inputHookState = { + expectedBalancesLiveScaled18: [ + 1000000000000000000n, + 45000000000000001n, + ], + }; + const test = vault.removeLiquidity( + removeLiquidityInput, + pool, + inputHookState, + ); + expect(test.bptAmountIn).to.eq(removeLiquidityInput.maxBptAmountIn); + expect(test.amountsOut).to.deep.eq([0n, 0n]); + }); +}); + +class CustomPool implements PoolBase { + constructor() {} + + getMaxSwapAmount(): bigint { + return 1n; + } + + onSwap(): bigint { + return 1n; + } + computeInvariant(): bigint { + return 1n; + } + computeBalance(): bigint { + return 1n; + } +} + +class CustomHook 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 false; + } + onAfterAddLiquidity() { + return { success: false, hookAdjustedAmountsInRaw: [] }; + } + onBeforeRemoveLiquidity() { + return false; + } + onAfterRemoveLiquidity( + kind: RemoveKind, + bptAmountIn: bigint, + amountsOutScaled18: bigint[], + amountsOutRaw: bigint[], + balancesScaled18: bigint[], + hookState: HookState | unknown, + ) { + if ( + !( + typeof hookState === 'object' && + hookState !== null && + 'expectedBalancesLiveScaled18' in hookState + ) + ) + throw new Error('Unexpected hookState'); + expect(kind).to.eq(removeLiquidityInput.kind); + expect(bptAmountIn).to.eq(removeLiquidityInput.maxBptAmountIn); + expect(amountsOutScaled18).to.deep.eq([0n, 909999999999999999n]); + expect(amountsOutRaw).to.deep.eq([0n, 909999999999999999n]); + expect(balancesScaled18).to.deep.eq( + hookState.expectedBalancesLiveScaled18, + ); + return { + success: true, + hookAdjustedAmountsOutRaw: new Array( + amountsOutScaled18.length, + ).fill(0n), + }; + } + onBeforeSwap() { + return false; + } + onAfterSwap() { + return { success: false, hookAdjustedAmountCalculatedRaw: 0n }; + } + onComputeDynamicSwapFee() { + return { success: false, dynamicSwapFee: 0n }; + } +}