-
Notifications
You must be signed in to change notification settings - Fork 15
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
base: main
Are you sure you want to change the base?
Changes from 2 commits
8afdb62
301f8b8
c2f55e7
bbe4ffd
d6cf007
b29dbba
3521e0b
911df4d
bc50f6d
bec194c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||||||||||
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); | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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); | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Then, for both the threshold and the surgeCoefficient:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will check with Zen on best way to validate. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From Zen:
|
||||||||||
} | ||||||||||
|
||||||||||
/// @inheritdoc IHooks | ||||||||||
function getHookFlags() public pure override returns (HookFlags memory hookFlags) { | ||||||||||
hookFlags.shouldCallComputeDynamicSwapFee = true; | ||||||||||
} | ||||||||||
|
||||||||||
/// @inheritdoc IHooks | ||||||||||
function onRegister( | ||||||||||
address factory, | ||||||||||
address pool, | ||||||||||
TokenConfig[] memory, | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
* @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. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
* @param weightAfterSwap Weight after swap | ||||||||||
* @param thresholdBoundary Threshold that surge fee will be applied | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
* @param swapFeePercentage Pools static swap fee | ||||||||||
* @param surgeCoefficient Amplification coefficient to amplify the degree a fee increases | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
*/ | ||||||||||
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. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The other examples derive from Ownable and use the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
*/ | ||||||||||
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); | ||||||||||
} | ||||||||||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.)