Skip to content

Commit

Permalink
feat: afterRemove uses updated balances.
Browse files Browse the repository at this point in the history
  • Loading branch information
johngrantuk committed Jul 24, 2024
1 parent e6bb1a1 commit 5f16a88
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 12 deletions.
Binary file modified testData/bun.lockb
Binary file not shown.
3 changes: 3 additions & 0 deletions typescript/src/hooks/exitFeeHook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type HookStateExitFee = {
removeLiquidityHookFeePercentage: bigint;
};
9 changes: 2 additions & 7 deletions typescript/src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export type AfterSwapParams = {
amountCalculatedRaw: bigint;
router: string;
pool: string;
userData: string;
};

export interface HookBase {
Expand All @@ -26,6 +25,7 @@ export interface HookBase {
shouldCallAfterAddLiquidity: boolean;
shouldCallBeforeRemoveLiquidity: boolean;
shouldCallAfterRemoveLiquidity: boolean;
enableHookAdjustedAmounts: boolean;

onBeforeAddLiquidity(
router: string,
Expand All @@ -34,7 +34,6 @@ export interface HookBase {
maxAmountsInScaled18: bigint[],
minBptAmountOut: bigint,
balancesScaled18: bigint[],
userData: string,
): boolean;
onAfterAddLiquidity(
router: string,
Expand All @@ -44,7 +43,6 @@ export interface HookBase {
amountsInRaw: bigint[],
bptAmountOut: bigint,
balancesScaled18: bigint[],
userData: string,
): { success: boolean; hookAdjustedAmountsInRaw: bigint[] };
onBeforeRemoveLiquidity(
router: string,
Expand All @@ -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): {
Expand Down
1 change: 1 addition & 0 deletions typescript/src/vault/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type PoolState = {
tokenRates: bigint[];
balancesLiveScaled18: bigint[];
swapFee: bigint;
aggregateSwapFee: bigint;
totalSupply: bigint;
hookType?: string;
};
Expand Down
67 changes: 62 additions & 5 deletions typescript/src/vault/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, PoolClassConstructor>>;
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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;
Expand Down
178 changes: 178 additions & 0 deletions typescript/test/hooks/afterRemoveLiquidity.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}

0 comments on commit 5f16a88

Please sign in to comment.