Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add StableSurge hook example with tests. #934

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
235 changes: 235 additions & 0 deletions pkg/pool-hooks/contracts/StableSurgeHookExample.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// SPDX-License-Identifier: GPL-3.0-or-later

pragma solidity ^0.8.24;

import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";

import { IBasePoolFactory } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePoolFactory.sol";
import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol";
import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol";
import { IStablePool } from "@balancer-labs/v3-interfaces/contracts/pool-stable/IStablePool.sol";
import {
LiquidityManagement,
TokenConfig,
PoolSwapParams,
HookFlags,
SwapKind
} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol";
import { VaultGuard } from "@balancer-labs/v3-vault/contracts/VaultGuard.sol";
import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.sol";

import { StableMath } from "@balancer-labs/v3-solidity-utils/contracts/math/StableMath.sol";
import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol";

/**
* @notice Hook that applies a fee for out of range or undesirable amounts of tokens in relation to a threshold.
* @dev Uses the dynamic fee mechanism to apply a directional fee.
*/
contract StableSurgeHookExample is BaseHooks, VaultGuard, Ownable {
using FixedPoint for uint256;
// Only pools from a specific factory are able to register and use this hook.
address private immutable _allowedFactory;
// Defines the range in which surging will not occur; meaning surging will occur when:
// B(i-after swap) / (sum(B(n-after swap))) > 1/n + gamma
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what all the variables mean: i, n, B(), etc. Range of what? Maybe give some more details / a numerical example.

Copy link
Member Author

@johngrantuk johngrantuk Sep 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried to clarify this a bit more in a more detailed comment. Let me know if this works?
(Also see the linked Notion in the description, not sure how much of that is practical to include in the contract so would be great to get guidance there.)

uint256 private _threshold;
// An amplification coefficient to amplify the degree a fee increases upon the threshold being triggered
johngrantuk marked this conversation as resolved.
Show resolved Hide resolved
uint256 private _surgeCoefficient;

/**
* @notice A new `StableSurgeHookExample` contract has been registered successfully.
* @dev If the registration fails the call will revert, so there will be no event.
* @param hooksContract This contract
* @param factory The factory (must be the allowed factory, or the call will revert)
* @param pool The pool on which the hook was registered
*/
event StableSurgeHookExampleRegistered(
address indexed hooksContract,
address indexed factory,
address indexed pool
);

/**
* @notice The threshold has been changed in a `StableSurgeHookExample` contract.
* @dev Note, the initial threshold is set on deployment and an event is emitted.
* @param hooksContract This contract
* @param threshold The new threshold
*/
event ThresholdChanged(address indexed hooksContract, uint256 indexed threshold);

/**
* @notice The surgeCoefficient has been changed in a `StableSurgeHookExample` contract.
* @dev Note, the initial surgeCoefficient is set on deployment and an event is emitted.
* @param hooksContract This contract
* @param surgeCoefficient The new surgeCoefficient
*/
event SurgeCoefficientChanged(address indexed hooksContract, uint256 indexed surgeCoefficient);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per our style doc (https://www.notion.so/Code-Style-51bb09b3192f457aa61bd8dec77847d2), events should come before errors, etc. Should be:

uint256 public constant DEFAULT_SURGE_COEFFICIENT = 50e18;
// A threshold of 0.1 for a 2 token pool means surging occurs if any token reaches 60% of the total of balances.
uint256 public constant DEFAULT_THRESHOLD = 0.1e18;

// Defines the range in which surging will not occur
mapping(address pool => uint256 threshold) public poolThresholdPercentage;
// An amplification coefficient to amplify the degree to which a fee increases after the threshold is met.
mapping(address pool => uint256 surgeCoefficient) public poolSurgeCoefficient;

// Only pools from a specific factory are able to register and use this hook.
address private immutable _allowedFactory;

Then events, then errors.


constructor(
IVault vault,
address allowedFactory,
uint256 threshold,
uint256 surgeCoefficient
) VaultGuard(vault) Ownable(msg.sender) {
_allowedFactory = allowedFactory;
_threshold = threshold;
_surgeCoefficient = surgeCoefficient;
emit ThresholdChanged(address(this), threshold);
emit SurgeCoefficientChanged(address(this), surgeCoefficient);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What we typically do here is encapsulate the event with the set function.

Suggested change
emit ThresholdChanged(address(this), threshold);
emit SurgeCoefficientChanged(address(this), surgeCoefficient);
_setThreshold(threshold);
_setSurgeCoefficient(surgeCoefficient);

Then, for both the threshold and the surgeCoefficient:

function setThreshold(uint64 newThreshold) external onlyOwner {
    _setThreshold(newThreshold);
}

function _setThreshold(uint64 newThreshold) private {
    // This should be validated; e.g. <= FixedPoint.ONE or some max value?
    _threshold = newThreshold;

    emit ThresholdChanged(address(this), newThreshold);
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is surge coefficient validated? (i.e., what is the range)? You can't go over 100% on a dynamic fee, or the Vault will revert.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will check with Zen on best way to validate.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From Zen:

  • I don’t think the threshold should matter so much as long as it’s less than 1 - 1/n
  • surge coefficient: baseFee * 𝜇 / (1/n) < 100 would be the test then

}

/// @inheritdoc IHooks
function getHookFlags() public pure override returns (HookFlags memory hookFlags) {
hookFlags.shouldCallComputeDynamicSwapFee = true;
}

/// @inheritdoc IHooks
function onRegister(
address factory,
address pool,
TokenConfig[] memory,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't use rates anywhere when calculating values. Does this only work with STANDARD tokens then? If so, we should also check that here. Revert if there's a WITH_RATE token. (Or handle rates, if that's a valid case... but then you need to worry about rounding, etc.)

The factory (presumably our standard StableFactory) already restricts it to stable pools.

LiquidityManagement calldata
) public override onlyVault returns (bool) {
// This hook implements a restrictive approach, where we check if the factory is an allowed factory and if
// the pool was created by the allowed factory.
emit StableSurgeHookExampleRegistered(address(this), factory, pool);
EndymionJkb marked this conversation as resolved.
Show resolved Hide resolved

return factory == _allowedFactory && IBasePoolFactory(factory).isPoolFromFactory(pool);
}

/// @inheritdoc IHooks
function onComputeDynamicSwapFeePercentage(
PoolSwapParams calldata params,
address pool,
uint256 staticSwapFeePercentage
) public view override onlyVault returns (bool, uint256) {
uint256 amp;
(amp, , ) = IStablePool(pool).getAmplificationParameter();
EndymionJkb marked this conversation as resolved.
Show resolved Hide resolved

// In order to calculate weightAfterSwap we need balances after swap so we must compute swap amount
johngrantuk marked this conversation as resolved.
Show resolved Hide resolved
uint256 invariant = StableMath.computeInvariant(amp, params.balancesScaled18);
uint256 weightAfterSwap;
if (params.kind == SwapKind.EXACT_IN) {
uint256 amountCalculatedScaled18 = StableMath.computeOutGivenExactIn(
amp,
params.balancesScaled18,
params.indexIn,
params.indexOut,
params.amountGivenScaled18,
invariant
);
// Swap fee is always a percentage of the amountCalculated. On ExactIn, subtract it from the calculated
// amountOut. Round up to avoid losses during precision loss.
uint256 swapFeeAmountScaled18 = amountCalculatedScaled18.mulUp(staticSwapFeePercentage);
amountCalculatedScaled18 -= swapFeeAmountScaled18;
weightAfterSwap = getWeightAfterSwap(
params.balancesScaled18,
params.indexIn,
params.amountGivenScaled18,
amountCalculatedScaled18
);
} else {
uint256 amountCalculatedScaled18 = StableMath.computeInGivenExactOut(
amp,
params.balancesScaled18,
params.indexIn,
params.indexOut,
params.amountGivenScaled18,
invariant
);
// To ensure symmetry with EXACT_IN, the swap fee used by ExactOut is
// `amountCalculated * fee% / (100% - fee%)`. Add it to the calculated amountIn. Round up to avoid losses
// during precision loss.
uint256 swapFeeAmountScaled18 = amountCalculatedScaled18.mulDivUp(
staticSwapFeePercentage,
staticSwapFeePercentage.complement()
);

amountCalculatedScaled18 += swapFeeAmountScaled18;
weightAfterSwap = getWeightAfterSwap(
params.balancesScaled18,
params.indexIn,
amountCalculatedScaled18,
params.amountGivenScaled18
);
}

uint256 thresholdBoundary = getThresholdBoundary(params.balancesScaled18.length, _threshold);
if (weightAfterSwap > thresholdBoundary) {
return (true, getSurgeFee(weightAfterSwap, thresholdBoundary, staticSwapFeePercentage, _surgeCoefficient));
} else {
return (true, staticSwapFeePercentage);
}
}

/**
* Defines the range in which surging will not occur.
* @dev An expected value for threshold in a 2 token (n=2) would be 0.1.
* This would mean surging would occur if any token reaches 60% of the total of balances.
* @param numberOfAssets Number of assets in the pool.
* @param threshold Theshold value.
*/
function getThresholdBoundary(uint256 numberOfAssets, uint256 threshold) public pure returns (uint256) {
return 1e18 / numberOfAssets + threshold;
johngrantuk marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* The weight after swap, used to determine if surge fee should be applied.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* The weight after swap, used to determine if surge fee should be applied.
* @notice The weight after swap, used to determine whether the surge fee should be applied.

* @param balancesScaled18 Balances of pool
* @param indexIn Index of token in
* @param amountInScaled18 Amount in of swap
* @param amountOutScaled18 Amount out of swap
*/
function getWeightAfterSwap(
uint256[] memory balancesScaled18,
uint256 indexIn,
uint256 amountInScaled18,
uint256 amountOutScaled18
) public pure returns (uint256) {
uint256 balancesTotal;
for (uint256 i = 0; i < balancesScaled18.length; ++i) {
balancesTotal += balancesScaled18[i];
}
uint256 balanceTokenInAfterSwap = balancesScaled18[indexIn] + amountInScaled18;
uint256 balancesTotalAfterSwap = balancesTotal + amountInScaled18 - amountOutScaled18;
return balanceTokenInAfterSwap.divDown(balancesTotalAfterSwap);
}

/**
* A fee based on the virtual weights of the tokens.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* A fee based on the virtual weights of the tokens.
* @notice A fee based on the virtual weights of the tokens.

* @param weightAfterSwap Weight after swap
* @param thresholdBoundary Threshold that surge fee will be applied
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @param thresholdBoundary Threshold that surge fee will be applied
* @param thresholdBoundary Threshold at which the surge fee will be applied

* @param swapFeePercentage Pools static swap fee
* @param surgeCoefficient Amplification coefficient to amplify the degree a fee increases
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @param surgeCoefficient Amplification coefficient to amplify the degree a fee increases
* @param surgeCoefficient Amplification coefficient to amplify the magnitude of the fee increase

*/
function getSurgeFee(
uint256 weightAfterSwap,
uint256 thresholdBoundary,
uint256 swapFeePercentage,
uint256 surgeCoefficient
) public pure returns (uint256) {
uint256 weightRatio = weightAfterSwap.divDown(thresholdBoundary);
return swapFeePercentage.mulDown(surgeCoefficient).mulDown(weightRatio);
}

// Permissioned functions

/**
* @notice Sets the hook threshold.
* @dev This function must be permissioned.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other examples derive from Ownable and use the onlyOwner modifier for permissioned functions. Here, we're using the swapManager for the pool, vs. the "owner" of the hook, so we could state that explicitly here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @dev This function must be permissioned.
* @dev This function can only be called by the pool's `swapManager`.

*/
function setThreshold(uint64 newThreshold) external onlyOwner {
_threshold = newThreshold;

emit ThresholdChanged(address(this), newThreshold);
}

/**
* @notice Sets the hook surgeCoefficient.
* @dev This function must be permissioned.
*/
function setSurgeCoefficient(uint64 newSurgeCoefficient) external onlyOwner {
_surgeCoefficient = newSurgeCoefficient;

emit SurgeCoefficientChanged(address(this), newSurgeCoefficient);
}
}
Loading
Loading