diff --git a/.forge-snapshots/autodynamic fee.snap b/.forge-snapshots/autodynamic fee.snap new file mode 100644 index 00000000..58d87c97 --- /dev/null +++ b/.forge-snapshots/autodynamic fee.snap @@ -0,0 +1 @@ +242952 \ No newline at end of file diff --git a/.forge-snapshots/counter.snap b/.forge-snapshots/counter.snap index 7573abf5..4a86f960 100644 --- a/.forge-snapshots/counter.snap +++ b/.forge-snapshots/counter.snap @@ -1 +1 @@ -339161 \ No newline at end of file +358444 \ No newline at end of file diff --git a/.forge-snapshots/hookFee.snap b/.forge-snapshots/hookFee.snap index c0ced99f..16c3b790 100644 --- a/.forge-snapshots/hookFee.snap +++ b/.forge-snapshots/hookFee.snap @@ -1 +1 @@ -246486 \ No newline at end of file +265804 \ No newline at end of file diff --git a/.forge-snapshots/manual dynamic fee.snap b/.forge-snapshots/manual dynamic fee.snap new file mode 100644 index 00000000..8b180eb5 --- /dev/null +++ b/.forge-snapshots/manual dynamic fee.snap @@ -0,0 +1 @@ +219217 \ No newline at end of file diff --git a/README.md b/README.md index 737e983c..16f36880 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,5 @@ Generate react, register routes, and rebuild search index ```bash npm run generate ``` + +Update the [changelog](src/pages/index.tsx#L11) \ No newline at end of file diff --git a/forge-test/Counter.t.sol b/forge-test/Counter.t.sol index 03f54023..69f7cf51 100644 --- a/forge-test/Counter.t.sol +++ b/forge-test/Counter.t.sol @@ -30,8 +30,10 @@ contract CounterTest is HookTest, GasSnapshot { HookTest.initHookTestEnv(); // Deploy the hook to an address with the correct flags - uint160 flags = - uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG | Hooks.BEFORE_ADD_LIQUIDITY_FLAG | Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG); + uint160 flags = uint160( + Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG | Hooks.BEFORE_ADD_LIQUIDITY_FLAG + | Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG + ); (address hookAddress, bytes32 salt) = HookMiner.find(address(this), flags, type(Counter).creationCode, abi.encode(address(manager))); counter = new Counter{salt: salt}(IPoolManager(address(manager))); diff --git a/forge-test/DynamicFees.t.sol b/forge-test/DynamicFees.t.sol new file mode 100644 index 00000000..37b85bcf --- /dev/null +++ b/forge-test/DynamicFees.t.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import {IHooks} from "v4-core/interfaces/IHooks.sol"; +import {Hooks} from "v4-core/libraries/Hooks.sol"; +import {TickMath} from "v4-core/libraries/TickMath.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {Constants} from "v4-core/../test/utils/Constants.sol"; +import {CurrencyLibrary, Currency} from "v4-core/types/Currency.sol"; +import {FeeLibrary} from "v4-core/libraries/FeeLibrary.sol"; +import {HookTest} from "@v4-by-example/utils/HookTest.sol"; +import {HookMiner} from "./utils/HookMiner.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; + +import {ManualDynamicFee} from "@v4-by-example/pages/fees/dynamic-fee/ManualDynamicFee.sol"; +import {AutoDynamicFee} from "@v4-by-example/pages/fees/dynamic-fee/AutoDynamicFee.sol"; + +contract DynamicFeesTest is HookTest, GasSnapshot { + using FixedPointMathLib for uint256; + using PoolIdLibrary for PoolKey; + using CurrencyLibrary for Currency; + + AutoDynamicFee autoDynamicFee; + ManualDynamicFee manualDynamicFee; + + PoolKey autoDynamicFeePoolKey; + PoolKey manualDynamicFeePoolKey; + + function setUp() public { + // creates the pool manager, test tokens, and other utility routers + HookTest.initHookTestEnv(); + + // Deploy the hook to an address with the correct flags + uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG); + (address hookAddress, bytes32 salt) = + HookMiner.find(address(this), flags, type(AutoDynamicFee).creationCode, abi.encode(address(manager))); + autoDynamicFee = new AutoDynamicFee{salt: salt}(IPoolManager(address(manager))); + require(address(autoDynamicFee) == hookAddress, "hook address mismatch"); + + (hookAddress, salt) = + HookMiner.find(address(this), uint160(0), type(ManualDynamicFee).creationCode, abi.encode(address(manager))); + manualDynamicFee = new ManualDynamicFee{salt: salt}(IPoolManager(address(manager))); + require(address(manualDynamicFee) == hookAddress, "hook address mismatch"); + + // Create the pools + autoDynamicFeePoolKey = PoolKey( + Currency.wrap(address(token0)), + Currency.wrap(address(token1)), + FeeLibrary.DYNAMIC_FEE_FLAG, + 60, + IHooks(autoDynamicFee) + ); + initializeRouter.initialize(autoDynamicFeePoolKey, Constants.SQRT_RATIO_1_1, ZERO_BYTES); + + manualDynamicFeePoolKey = PoolKey( + Currency.wrap(address(token0)), + Currency.wrap(address(token1)), + FeeLibrary.DYNAMIC_FEE_FLAG, + 60, + IHooks(manualDynamicFee) + ); + initializeRouter.initialize(manualDynamicFeePoolKey, Constants.SQRT_RATIO_1_1, ZERO_BYTES); + + // Provide liquidity to the pool + modifyPositionRouter.modifyLiquidity( + autoDynamicFeePoolKey, + IPoolManager.ModifyLiquidityParams(TickMath.minUsableTick(60), TickMath.maxUsableTick(60), 100000 ether), + ZERO_BYTES + ); + + modifyPositionRouter.modifyLiquidity( + manualDynamicFeePoolKey, + IPoolManager.ModifyLiquidityParams(TickMath.minUsableTick(60), TickMath.maxUsableTick(60), 100000 ether), + ZERO_BYTES + ); + } + + function test_start_autoFee() public { + // Perform a test swap // + int256 amount = 1e18; + bool zeroForOne = true; + BalanceDelta swapDelta = swap(autoDynamicFeePoolKey, amount, zeroForOne, ZERO_BYTES); + // ------------------- // + + // fee on output token, so expect ~0.95e18 output + assertLt(uint256(-int256(swapDelta.amount1())), 0.95e18); + assertGt(uint256(-int256(swapDelta.amount1())), 0.94e18); + assertApproxEqAbs( + uint256(-int256(swapDelta.amount1())), uint256(amount), uint256(amount).mulWadDown(0.05001e18) + ); + } + + function test_floor_autoFee() public { + // skip 496,000 seconds, fee is now floored at 0.05% + skip(496000); + + // Perform a test swap // + int256 amount = 1e18; + bool zeroForOne = true; + BalanceDelta swapDelta = swap(autoDynamicFeePoolKey, amount, zeroForOne, ZERO_BYTES); + // ------------------- // + + assertLt(uint256(-int256(swapDelta.amount1())), 0.9995e18); + assertGt(uint256(-int256(swapDelta.amount1())), 0.9994e18); + assertApproxEqAbs( + uint256(-int256(swapDelta.amount1())), uint256(amount), uint256(amount).mulWadDown(0.00051e18) + ); + } + + function test_start_manualFee() public { + // Perform a test swap // + int256 amount = 1e18; + bool zeroForOne = true; + BalanceDelta swapDelta = swap(manualDynamicFeePoolKey, amount, zeroForOne, ZERO_BYTES); + // ------------------- // + + // fee on output token, so expect ~0.95e18 output + assertLt(uint256(-int256(swapDelta.amount1())), 0.95e18); + assertGt(uint256(-int256(swapDelta.amount1())), 0.94e18); + assertApproxEqAbs( + uint256(-int256(swapDelta.amount1())), uint256(amount), uint256(amount).mulWadDown(0.05001e18) + ); + } + + function test_floor_manualFee() public { + // skip 496,000 seconds, fee is now floored at 0.05% + skip(496000); + + // poke the pool manager + manager.updateDynamicSwapFee(manualDynamicFeePoolKey); + + // Perform a test swap // + int256 amount = 1e18; + bool zeroForOne = true; + BalanceDelta swapDelta = swap(manualDynamicFeePoolKey, amount, zeroForOne, ZERO_BYTES); + // ------------------- // + + assertLt(uint256(-int256(swapDelta.amount1())), 0.9995e18); + assertGt(uint256(-int256(swapDelta.amount1())), 0.9994e18); + assertApproxEqAbs( + uint256(-int256(swapDelta.amount1())), uint256(amount), uint256(amount).mulWadDown(0.00051e18) + ); + } + + function test_staleFee_manualFee() public { + // skip 496,000 seconds, but the fee is still 5% since its stale and wasnt updated + skip(496000); + + // Perform a test swap // + int256 amount = 1e18; + bool zeroForOne = true; + BalanceDelta swapDelta = swap(manualDynamicFeePoolKey, amount, zeroForOne, ZERO_BYTES); + // ------------------- // + + // fee on output token, so expect ~0.95e18 output + assertLt(uint256(-int256(swapDelta.amount1())), 0.95e18); + assertGt(uint256(-int256(swapDelta.amount1())), 0.94e18); + assertApproxEqAbs( + uint256(-int256(swapDelta.amount1())), uint256(amount), uint256(amount).mulWadDown(0.05001e18) + ); + } + + function test_snapshot_autoFee() public { + skip(100_000); + int256 amount = 1e18; + bool zeroForOne = true; + IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: amount, + sqrtPriceLimitX96: zeroForOne ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT // unlimited impact + }); + + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({withdrawTokens: true, settleUsingTransfer: true, currencyAlreadySent: false}); + + snapStart("autodynamic fee"); + swapRouter.swap(autoDynamicFeePoolKey, params, testSettings, ZERO_BYTES); + snapEnd(); + } + + function test_snapshot_manualFee() public { + skip(100_000); + // poke the pool manager + manager.updateDynamicSwapFee(manualDynamicFeePoolKey); + + int256 amount = 1e18; + bool zeroForOne = true; + IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: amount, + sqrtPriceLimitX96: zeroForOne ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT // unlimited impact + }); + + PoolSwapTest.TestSettings memory testSettings = + PoolSwapTest.TestSettings({withdrawTokens: true, settleUsingTransfer: true, currencyAlreadySent: false}); + + snapStart("manual dynamic fee"); + swapRouter.swap(manualDynamicFeePoolKey, params, testSettings, ZERO_BYTES); + snapEnd(); + } +} diff --git a/forge-test/utils/HookMiner.sol b/forge-test/utils/HookMiner.sol index 31bd8144..ca421b5b 100644 --- a/forge-test/utils/HookMiner.sol +++ b/forge-test/utils/HookMiner.sol @@ -8,7 +8,7 @@ library HookMiner { uint160 constant FLAG_MASK = 0xFFF << 148; // Maximum number of iterations to find a salt, avoid infinite loops - uint256 constant MAX_LOOP = 10_000; + uint256 constant MAX_LOOP = 20_000; /// @notice Find a salt that produces a hook address with the desired `flags` /// @param deployer The address that will deploy the hook. In `forge test`, this will be the test contract `address(this)` or the pranking address diff --git a/src/keywords.json b/src/keywords.json index 5ebfb955..298e092a 100644 --- a/src/keywords.json +++ b/src/keywords.json @@ -45,6 +45,13 @@ "static fee", "hook fee" ], + "/fees/dynamic-fee": [ + "fee", + "fees", + "dynamic fee", + "dynamic", + "poke" + ], "/create-liquidity": [ "liquidity", "LP", diff --git a/src/nav.ts b/src/nav.ts index b995a19b..abb8c262 100644 --- a/src/nav.ts +++ b/src/nav.ts @@ -44,6 +44,10 @@ const FEE_ROUTES: Route[] = [ { path: "fixed-hook-fee", title: "Static Hook Fee" + }, + { + path: "dynamic-fee", + title: "Dynamic Swap Fee" } ] diff --git a/src/pages/fees/dynamic-fee/AutoDynamicFee.sol b/src/pages/fees/dynamic-fee/AutoDynamicFee.sol new file mode 100644 index 00000000..334c892f --- /dev/null +++ b/src/pages/fees/dynamic-fee/AutoDynamicFee.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +// TODO: update to v4-periphery/BaseHook.sol when its compatible +import {BaseHook} from "@v4-by-example/utils/BaseHook.sol"; + +import {Hooks} from "v4-core/libraries/Hooks.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {IDynamicFeeManager} from "v4-core/interfaces/IDynamicFeeManager.sol"; + +/// @notice A time-decaying dynamically fee, updated automatically with beforeSwap() +contract AutoDynamicFee is BaseHook, IDynamicFeeManager { + uint256 public immutable startTimestamp; + + // Start at 5% fee, decaying at rate of 0.00001% per second + // after 495,000 seconds (5.72 days), fee will be a minimum of 0.05% + // NOTE: because fees are uint24, we will lose some precision + uint128 public constant START_FEE = 500000; // represents 5% + uint128 public constant MIN_FEE = 500; // minimum fee of 0.05% + + uint128 public constant decayRate = 1; // 0.00001% per second + + constructor(IPoolManager _poolManager) BaseHook(_poolManager) { + startTimestamp = block.timestamp; + } + + /// @inheritdoc IDynamicFeeManager + function getFee(address, PoolKey calldata) external view override returns (uint24 _currentFee) { + unchecked { + uint256 timeElapsed = block.timestamp - startTimestamp; + _currentFee = timeElapsed > 495000 ? uint24(MIN_FEE) : uint24((START_FEE - (timeElapsed * decayRate)) / 10); + } + } + + /// @dev this example hook contract does not implement any hooks + function getHookPermissions() public pure override returns (Hooks.Permissions memory) { + return Hooks.Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeAddLiquidity: false, + afterAddLiquidity: false, + beforeRemoveLiquidity: false, + afterRemoveLiquidity: false, + beforeSwap: true, + afterSwap: false, + beforeDonate: false, + afterDonate: false, + noOp: false, + accessLock: false + }); + } + + function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, bytes calldata) + external + override + returns (bytes4) + { + // poke the poolmanager to update the fee for every swap + poolManager.updateDynamicSwapFee(key); + return BaseHook.beforeSwap.selector; + } +} diff --git a/src/pages/fees/dynamic-fee/ManualDynamicFee.sol b/src/pages/fees/dynamic-fee/ManualDynamicFee.sol new file mode 100644 index 00000000..01537e32 --- /dev/null +++ b/src/pages/fees/dynamic-fee/ManualDynamicFee.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +// TODO: update to v4-periphery/BaseHook.sol when its compatible +import {BaseHook} from "@v4-by-example/utils/BaseHook.sol"; + +import {Hooks} from "v4-core/libraries/Hooks.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {IDynamicFeeManager} from "v4-core/interfaces/IDynamicFeeManager.sol"; + +/// @notice A time-decaying dynamically fee, updated manually with external PoolManager.updateDynamicSwapFee() calls +contract ManualDynamicFee is BaseHook, IDynamicFeeManager { + uint256 public immutable startTimestamp; + + // Start at 5% fee, decaying at rate of 0.00001% per second + // after 495,000 seconds (5.72 days), fee will be a minimum of 0.05% + // NOTE: because fees are uint24, we will lose some precision + uint128 public constant START_FEE = 500000; // represents 5% + uint128 public constant MIN_FEE = 500; // minimum fee of 0.05% + + uint128 public constant decayRate = 1; // 0.00001% per second + + constructor(IPoolManager _poolManager) BaseHook(_poolManager) { + startTimestamp = block.timestamp; + } + + /// @dev Deteremines a Pool's swap fee. Called and cached by PoolManager.updateDynamicFee() + function getFee(address, PoolKey calldata) external view override returns (uint24 _currentFee) { + // Linearly decaying fee, y = mx + b + // After 495,000 seconds (5.72 days), fee will be a minimum of 0.05% + unchecked { + uint256 timeElapsed = block.timestamp - startTimestamp; + _currentFee = timeElapsed > 495000 ? uint24(MIN_FEE) : uint24((START_FEE - (timeElapsed * decayRate)) / 10); + } + } + + /// @dev this example hook contract does not implement any hooks + function getHookPermissions() public pure override returns (Hooks.Permissions memory) { + return Hooks.Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeAddLiquidity: false, + afterAddLiquidity: false, + beforeRemoveLiquidity: false, + afterRemoveLiquidity: false, + beforeSwap: false, + afterSwap: false, + beforeDonate: false, + afterDonate: false, + noOp: false, + accessLock: false + }); + } +} diff --git a/src/pages/fees/dynamic-fee/UpdateDynamicFee.solsnippet b/src/pages/fees/dynamic-fee/UpdateDynamicFee.solsnippet new file mode 100644 index 00000000..ea580eb3 --- /dev/null +++ b/src/pages/fees/dynamic-fee/UpdateDynamicFee.solsnippet @@ -0,0 +1,6 @@ +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; + +// poke the poolmanager to update the "dynamic fee" +PoolKey memory key = PoolKey(...); +poolManager.updateDynamicSwapFee(key); diff --git a/src/pages/fees/dynamic-fee/index.html.ts b/src/pages/fees/dynamic-fee/index.html.ts new file mode 100644 index 00000000..5fbf863d --- /dev/null +++ b/src/pages/fees/dynamic-fee/index.html.ts @@ -0,0 +1,184 @@ +// metadata +export const version = "0.8.20" +export const title = "Dynamic Fees" +export const description = "Design a v4 pool with a dynamic fee" + +export const keywords = [ + "fee", + "fees", + "dynamic fee", + "dynamic", + "poke", +] + +export const codes = [ + { + fileName: "AutoDynamicFee.sol", + code: "Ly8gU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IE1JVApwcmFnbWEgc29saWRpdHkgXjAuOC4xOTsKCi8vIFRPRE86IHVwZGF0ZSB0byB2NC1wZXJpcGhlcnkvQmFzZUhvb2suc29sIHdoZW4gaXRzIGNvbXBhdGlibGUKaW1wb3J0IHtCYXNlSG9va30gZnJvbSAiQHY0LWJ5LWV4YW1wbGUvdXRpbHMvQmFzZUhvb2suc29sIjsKCmltcG9ydCB7SG9va3N9IGZyb20gInY0LWNvcmUvbGlicmFyaWVzL0hvb2tzLnNvbCI7CmltcG9ydCB7SVBvb2xNYW5hZ2VyfSBmcm9tICJ2NC1jb3JlL2ludGVyZmFjZXMvSVBvb2xNYW5hZ2VyLnNvbCI7CmltcG9ydCB7UG9vbEtleX0gZnJvbSAidjQtY29yZS90eXBlcy9Qb29sS2V5LnNvbCI7CmltcG9ydCB7SUR5bmFtaWNGZWVNYW5hZ2VyfSBmcm9tICJ2NC1jb3JlL2ludGVyZmFjZXMvSUR5bmFtaWNGZWVNYW5hZ2VyLnNvbCI7CgovLy8gQG5vdGljZSBBIHRpbWUtZGVjYXlpbmcgZHluYW1pY2FsbHkgZmVlLCB1cGRhdGVkIGF1dG9tYXRpY2FsbHkgd2l0aCBiZWZvcmVTd2FwKCkKY29udHJhY3QgQXV0b0R5bmFtaWNGZWUgaXMgQmFzZUhvb2ssIElEeW5hbWljRmVlTWFuYWdlciB7CiAgICB1aW50MjU2IHB1YmxpYyBpbW11dGFibGUgc3RhcnRUaW1lc3RhbXA7CgogICAgLy8gU3RhcnQgYXQgNSUgZmVlLCBkZWNheWluZyBhdCByYXRlIG9mIDAuMDAwMDElIHBlciBzZWNvbmQKICAgIC8vIGFmdGVyIDQ5NSwwMDAgc2Vjb25kcyAoNS43MiBkYXlzKSwgZmVlIHdpbGwgYmUgYSBtaW5pbXVtIG9mIDAuMDUlCiAgICAvLyBOT1RFOiBiZWNhdXNlIGZlZXMgYXJlIHVpbnQyNCwgd2Ugd2lsbCBsb3NlIHNvbWUgcHJlY2lzaW9uCiAgICB1aW50MTI4IHB1YmxpYyBjb25zdGFudCBTVEFSVF9GRUUgPSA1MDAwMDA7IC8vIHJlcHJlc2VudHMgNSUKICAgIHVpbnQxMjggcHVibGljIGNvbnN0YW50IE1JTl9GRUUgPSA1MDA7IC8vIG1pbmltdW0gZmVlIG9mIDAuMDUlCgogICAgdWludDEyOCBwdWJsaWMgY29uc3RhbnQgZGVjYXlSYXRlID0gMTsgLy8gMC4wMDAwMSUgcGVyIHNlY29uZAoKICAgIGNvbnN0cnVjdG9yKElQb29sTWFuYWdlciBfcG9vbE1hbmFnZXIpIEJhc2VIb29rKF9wb29sTWFuYWdlcikgewogICAgICAgIHN0YXJ0VGltZXN0YW1wID0gYmxvY2sudGltZXN0YW1wOwogICAgfQoKICAgIC8vLyBAaW5oZXJpdGRvYyBJRHluYW1pY0ZlZU1hbmFnZXIKICAgIGZ1bmN0aW9uIGdldEZlZShhZGRyZXNzLCBQb29sS2V5IGNhbGxkYXRhKSBleHRlcm5hbCB2aWV3IG92ZXJyaWRlIHJldHVybnMgKHVpbnQyNCBfY3VycmVudEZlZSkgewogICAgICAgIHVuY2hlY2tlZCB7CiAgICAgICAgICAgIHVpbnQyNTYgdGltZUVsYXBzZWQgPSBibG9jay50aW1lc3RhbXAgLSBzdGFydFRpbWVzdGFtcDsKICAgICAgICAgICAgX2N1cnJlbnRGZWUgPSB0aW1lRWxhcHNlZCA+IDQ5NTAwMCA/IHVpbnQyNChNSU5fRkVFKSA6IHVpbnQyNCgoU1RBUlRfRkVFIC0gKHRpbWVFbGFwc2VkICogZGVjYXlSYXRlKSkgLyAxMCk7CiAgICAgICAgfQogICAgfQoKICAgIC8vLyBAZGV2IHRoaXMgZXhhbXBsZSBob29rIGNvbnRyYWN0IGRvZXMgbm90IGltcGxlbWVudCBhbnkgaG9va3MKICAgIGZ1bmN0aW9uIGdldEhvb2tQZXJtaXNzaW9ucygpIHB1YmxpYyBwdXJlIG92ZXJyaWRlIHJldHVybnMgKEhvb2tzLlBlcm1pc3Npb25zIG1lbW9yeSkgewogICAgICAgIHJldHVybiBIb29rcy5QZXJtaXNzaW9ucyh7CiAgICAgICAgICAgIGJlZm9yZUluaXRpYWxpemU6IGZhbHNlLAogICAgICAgICAgICBhZnRlckluaXRpYWxpemU6IGZhbHNlLAogICAgICAgICAgICBiZWZvcmVBZGRMaXF1aWRpdHk6IGZhbHNlLAogICAgICAgICAgICBhZnRlckFkZExpcXVpZGl0eTogZmFsc2UsCiAgICAgICAgICAgIGJlZm9yZVJlbW92ZUxpcXVpZGl0eTogZmFsc2UsCiAgICAgICAgICAgIGFmdGVyUmVtb3ZlTGlxdWlkaXR5OiBmYWxzZSwKICAgICAgICAgICAgYmVmb3JlU3dhcDogdHJ1ZSwKICAgICAgICAgICAgYWZ0ZXJTd2FwOiBmYWxzZSwKICAgICAgICAgICAgYmVmb3JlRG9uYXRlOiBmYWxzZSwKICAgICAgICAgICAgYWZ0ZXJEb25hdGU6IGZhbHNlLAogICAgICAgICAgICBub09wOiBmYWxzZSwKICAgICAgICAgICAgYWNjZXNzTG9jazogZmFsc2UKICAgICAgICB9KTsKICAgIH0KCiAgICBmdW5jdGlvbiBiZWZvcmVTd2FwKGFkZHJlc3MsIFBvb2xLZXkgY2FsbGRhdGEga2V5LCBJUG9vbE1hbmFnZXIuU3dhcFBhcmFtcyBjYWxsZGF0YSwgYnl0ZXMgY2FsbGRhdGEpCiAgICAgICAgZXh0ZXJuYWwKICAgICAgICBvdmVycmlkZQogICAgICAgIHJldHVybnMgKGJ5dGVzNCkKICAgIHsKICAgICAgICAvLyBwb2tlIHRoZSBwb29sbWFuYWdlciB0byB1cGRhdGUgdGhlIGZlZSBmb3IgZXZlcnkgc3dhcAogICAgICAgIHBvb2xNYW5hZ2VyLnVwZGF0ZUR5bmFtaWNTd2FwRmVlKGtleSk7CiAgICAgICAgcmV0dXJuIEJhc2VIb29rLmJlZm9yZVN3YXAuc2VsZWN0b3I7CiAgICB9Cn0K", + }, + { + fileName: "ManualDynamicFee.sol", + code: "Ly8gU1BEWC1MaWNlbnNlLUlkZW50aWZpZXI6IE1JVApwcmFnbWEgc29saWRpdHkgXjAuOC4xOTsKCi8vIFRPRE86IHVwZGF0ZSB0byB2NC1wZXJpcGhlcnkvQmFzZUhvb2suc29sIHdoZW4gaXRzIGNvbXBhdGlibGUKaW1wb3J0IHtCYXNlSG9va30gZnJvbSAiQHY0LWJ5LWV4YW1wbGUvdXRpbHMvQmFzZUhvb2suc29sIjsKCmltcG9ydCB7SG9va3N9IGZyb20gInY0LWNvcmUvbGlicmFyaWVzL0hvb2tzLnNvbCI7CmltcG9ydCB7SVBvb2xNYW5hZ2VyfSBmcm9tICJ2NC1jb3JlL2ludGVyZmFjZXMvSVBvb2xNYW5hZ2VyLnNvbCI7CmltcG9ydCB7UG9vbEtleX0gZnJvbSAidjQtY29yZS90eXBlcy9Qb29sS2V5LnNvbCI7CmltcG9ydCB7SUR5bmFtaWNGZWVNYW5hZ2VyfSBmcm9tICJ2NC1jb3JlL2ludGVyZmFjZXMvSUR5bmFtaWNGZWVNYW5hZ2VyLnNvbCI7CgovLy8gQG5vdGljZSBBIHRpbWUtZGVjYXlpbmcgZHluYW1pY2FsbHkgZmVlLCB1cGRhdGVkIG1hbnVhbGx5IHdpdGggZXh0ZXJuYWwgUG9vbE1hbmFnZXIudXBkYXRlRHluYW1pY1N3YXBGZWUoKSBjYWxscwpjb250cmFjdCBNYW51YWxEeW5hbWljRmVlIGlzIEJhc2VIb29rLCBJRHluYW1pY0ZlZU1hbmFnZXIgewogICAgdWludDI1NiBwdWJsaWMgaW1tdXRhYmxlIHN0YXJ0VGltZXN0YW1wOwoKICAgIC8vIFN0YXJ0IGF0IDUlIGZlZSwgZGVjYXlpbmcgYXQgcmF0ZSBvZiAwLjAwMDAxJSBwZXIgc2Vjb25kCiAgICAvLyBhZnRlciA0OTUsMDAwIHNlY29uZHMgKDUuNzIgZGF5cyksIGZlZSB3aWxsIGJlIGEgbWluaW11bSBvZiAwLjA1JQogICAgLy8gTk9URTogYmVjYXVzZSBmZWVzIGFyZSB1aW50MjQsIHdlIHdpbGwgbG9zZSBzb21lIHByZWNpc2lvbgogICAgdWludDEyOCBwdWJsaWMgY29uc3RhbnQgU1RBUlRfRkVFID0gNTAwMDAwOyAvLyByZXByZXNlbnRzIDUlCiAgICB1aW50MTI4IHB1YmxpYyBjb25zdGFudCBNSU5fRkVFID0gNTAwOyAvLyBtaW5pbXVtIGZlZSBvZiAwLjA1JQoKICAgIHVpbnQxMjggcHVibGljIGNvbnN0YW50IGRlY2F5UmF0ZSA9IDE7IC8vIDAuMDAwMDElIHBlciBzZWNvbmQKCiAgICBjb25zdHJ1Y3RvcihJUG9vbE1hbmFnZXIgX3Bvb2xNYW5hZ2VyKSBCYXNlSG9vayhfcG9vbE1hbmFnZXIpIHsKICAgICAgICBzdGFydFRpbWVzdGFtcCA9IGJsb2NrLnRpbWVzdGFtcDsKICAgIH0KCiAgICAvLy8gQGRldiBEZXRlcmVtaW5lcyBhIFBvb2wncyBzd2FwIGZlZS4gQ2FsbGVkIGFuZCBjYWNoZWQgYnkgUG9vbE1hbmFnZXIudXBkYXRlRHluYW1pY0ZlZSgpCiAgICBmdW5jdGlvbiBnZXRGZWUoYWRkcmVzcywgUG9vbEtleSBjYWxsZGF0YSkgZXh0ZXJuYWwgdmlldyBvdmVycmlkZSByZXR1cm5zICh1aW50MjQgX2N1cnJlbnRGZWUpIHsKICAgICAgICAvLyBMaW5lYXJseSBkZWNheWluZyBmZWUsIHkgPSBteCArIGIKICAgICAgICAvLyBBZnRlciA0OTUsMDAwIHNlY29uZHMgKDUuNzIgZGF5cyksIGZlZSB3aWxsIGJlIGEgbWluaW11bSBvZiAwLjA1JQogICAgICAgIHVuY2hlY2tlZCB7CiAgICAgICAgICAgIHVpbnQyNTYgdGltZUVsYXBzZWQgPSBibG9jay50aW1lc3RhbXAgLSBzdGFydFRpbWVzdGFtcDsKICAgICAgICAgICAgX2N1cnJlbnRGZWUgPSB0aW1lRWxhcHNlZCA+IDQ5NTAwMCA/IHVpbnQyNChNSU5fRkVFKSA6IHVpbnQyNCgoU1RBUlRfRkVFIC0gKHRpbWVFbGFwc2VkICogZGVjYXlSYXRlKSkgLyAxMCk7CiAgICAgICAgfQogICAgfQoKICAgIC8vLyBAZGV2IHRoaXMgZXhhbXBsZSBob29rIGNvbnRyYWN0IGRvZXMgbm90IGltcGxlbWVudCBhbnkgaG9va3MKICAgIGZ1bmN0aW9uIGdldEhvb2tQZXJtaXNzaW9ucygpIHB1YmxpYyBwdXJlIG92ZXJyaWRlIHJldHVybnMgKEhvb2tzLlBlcm1pc3Npb25zIG1lbW9yeSkgewogICAgICAgIHJldHVybiBIb29rcy5QZXJtaXNzaW9ucyh7CiAgICAgICAgICAgIGJlZm9yZUluaXRpYWxpemU6IGZhbHNlLAogICAgICAgICAgICBhZnRlckluaXRpYWxpemU6IGZhbHNlLAogICAgICAgICAgICBiZWZvcmVBZGRMaXF1aWRpdHk6IGZhbHNlLAogICAgICAgICAgICBhZnRlckFkZExpcXVpZGl0eTogZmFsc2UsCiAgICAgICAgICAgIGJlZm9yZVJlbW92ZUxpcXVpZGl0eTogZmFsc2UsCiAgICAgICAgICAgIGFmdGVyUmVtb3ZlTGlxdWlkaXR5OiBmYWxzZSwKICAgICAgICAgICAgYmVmb3JlU3dhcDogZmFsc2UsCiAgICAgICAgICAgIGFmdGVyU3dhcDogZmFsc2UsCiAgICAgICAgICAgIGJlZm9yZURvbmF0ZTogZmFsc2UsCiAgICAgICAgICAgIGFmdGVyRG9uYXRlOiBmYWxzZSwKICAgICAgICAgICAgbm9PcDogZmFsc2UsCiAgICAgICAgICAgIGFjY2Vzc0xvY2s6IGZhbHNlCiAgICAgICAgfSk7CiAgICB9Cn0K", + }, + { + fileName: "UpdateDynamicFee.sol", + code: "aW1wb3J0IHtJUG9vbE1hbmFnZXJ9IGZyb20gInY0LWNvcmUvaW50ZXJmYWNlcy9JUG9vbE1hbmFnZXIuc29sIjsKaW1wb3J0IHtQb29sS2V5fSBmcm9tICJ2NC1jb3JlL3R5cGVzL1Bvb2xLZXkuc29sIjsKCi8vIHBva2UgdGhlIHBvb2xtYW5hZ2VyIHRvIHVwZGF0ZSB0aGUgImR5bmFtaWMgZmVlIgpQb29sS2V5IG1lbW9yeSBrZXkgPSBQb29sS2V5KC4uLik7CnBvb2xNYW5hZ2VyLnVwZGF0ZUR5bmFtaWNTd2FwRmVlKGtleSk7Cg==", + }, +] + +const html = `
Uniswap v4 pools can support dynamic swap fees, and do not need to adhere to a static fee (0.05% / 0.30% / 1.0%). The hook needs to inherit IDynamicFeeManager
and use FeeLibrary.DYNAMIC_FEE_FLAG
in its PoolKey.fee
.
Despite its name, the fee is cached by the PoolManager
and an external call PoolManager.updateDynamicFee()
is required to change the swap fee.
Note: dynamic fees can be computed every swap, but incurs a gas overhead
+import {FeeLibrary} from "v4-core/libraries/FeeLibrary.sol";
+
+
+poolKey = PoolKey(
+ Currency.wrap(address(token0)),
+ Currency.wrap(address(token1)),
+ FeeLibrary.DYNAMIC_FEE_FLAG, // signal that the pool has a dynamic fee
+ 60,
+ IHooks(hook)
+);
+initializeRouter.initialize(poolKey, startingPrice, hookData);
+
Implements a time-decaying dynamic fee
+IDynamicFeeManager
getFee()
PoolManager.updateDynamicFee()
to change the fee// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.19;
+
+// TODO: update to v4-periphery/BaseHook.sol when its compatible
+import {BaseHook} from "@v4-by-example/utils/BaseHook.sol";
+
+import {Hooks} from "v4-core/libraries/Hooks.sol";
+import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
+import {PoolKey} from "v4-core/types/PoolKey.sol";
+import {IDynamicFeeManager} from "v4-core/interfaces/IDynamicFeeManager.sol";
+
+/// @notice A time-decaying dynamically fee, updated manually with external PoolManager.updateDynamicSwapFee() calls
+contract ManualDynamicFee is BaseHook, IDynamicFeeManager {
+ uint256 public immutable startTimestamp;
+
+ // Start at 5% fee, decaying at rate of 0.00001% per second
+ // after 495,000 seconds (5.72 days), fee will be a minimum of 0.05%
+ // NOTE: because fees are uint24, we will lose some precision
+ uint128 public constant START_FEE = 500000; // represents 5%
+ uint128 public constant MIN_FEE = 500; // minimum fee of 0.05%
+
+ uint128 public constant decayRate = 1; // 0.00001% per second
+
+ constructor(IPoolManager _poolManager) BaseHook(_poolManager) {
+ startTimestamp = block.timestamp;
+ }
+
+ /// @dev Deteremines a Pool's swap fee. Called and cached by PoolManager.updateDynamicFee()
+ function getFee(address, PoolKey calldata) external view override returns (uint24 _currentFee) {
+ // Linearly decaying fee, y = mx + b
+ // After 495,000 seconds (5.72 days), fee will be a minimum of 0.05%
+ unchecked {
+ uint256 timeElapsed = block.timestamp - startTimestamp;
+ _currentFee = timeElapsed > 495000 ? uint24(MIN_FEE) : uint24((START_FEE - (timeElapsed * decayRate)) / 10);
+ }
+ }
+
+ /// @dev this example hook contract does not implement any hooks
+ function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
+ return Hooks.Permissions({
+ beforeInitialize: false,
+ afterInitialize: false,
+ beforeAddLiquidity: false,
+ afterAddLiquidity: false,
+ beforeRemoveLiquidity: false,
+ afterRemoveLiquidity: false,
+ beforeSwap: false,
+ afterSwap: false,
+ beforeDonate: false,
+ afterDonate: false,
+ noOp: false,
+ accessLock: false
+ });
+ }
+}
+
Implements an automatically-updated, time-decaying dynamic fee
+The hook uses beforeSwap
to automatically poke the PoolManager, ensuring the fee is always up-to-date
incurs +23,000 gas overhead
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.19;
+
+// TODO: update to v4-periphery/BaseHook.sol when its compatible
+import {BaseHook} from "@v4-by-example/utils/BaseHook.sol";
+
+import {Hooks} from "v4-core/libraries/Hooks.sol";
+import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
+import {PoolKey} from "v4-core/types/PoolKey.sol";
+import {IDynamicFeeManager} from "v4-core/interfaces/IDynamicFeeManager.sol";
+
+/// @notice A time-decaying dynamically fee, updated automatically with beforeSwap()
+contract AutoDynamicFee is BaseHook, IDynamicFeeManager {
+ uint256 public immutable startTimestamp;
+
+ // Start at 5% fee, decaying at rate of 0.00001% per second
+ // after 495,000 seconds (5.72 days), fee will be a minimum of 0.05%
+ // NOTE: because fees are uint24, we will lose some precision
+ uint128 public constant START_FEE = 500000; // represents 5%
+ uint128 public constant MIN_FEE = 500; // minimum fee of 0.05%
+
+ uint128 public constant decayRate = 1; // 0.00001% per second
+
+ constructor(IPoolManager _poolManager) BaseHook(_poolManager) {
+ startTimestamp = block.timestamp;
+ }
+
+ /// @inheritdoc IDynamicFeeManager
+ function getFee(address, PoolKey calldata) external view override returns (uint24 _currentFee) {
+ unchecked {
+ uint256 timeElapsed = block.timestamp - startTimestamp;
+ _currentFee = timeElapsed > 495000 ? uint24(MIN_FEE) : uint24((START_FEE - (timeElapsed * decayRate)) / 10);
+ }
+ }
+
+ /// @dev this example hook contract does not implement any hooks
+ function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
+ return Hooks.Permissions({
+ beforeInitialize: false,
+ afterInitialize: false,
+ beforeAddLiquidity: false,
+ afterAddLiquidity: false,
+ beforeRemoveLiquidity: false,
+ afterRemoveLiquidity: false,
+ beforeSwap: true,
+ afterSwap: false,
+ beforeDonate: false,
+ afterDonate: false,
+ noOp: false,
+ accessLock: false
+ });
+ }
+
+ function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, bytes calldata)
+ external
+ override
+ returns (bytes4)
+ {
+ // poke the poolmanager to update the fee for every swap
+ poolManager.updateDynamicSwapFee(key);
+ return BaseHook.beforeSwap.selector;
+ }
+}
+
`
+
+export default html
diff --git a/src/pages/fees/dynamic-fee/index.md b/src/pages/fees/dynamic-fee/index.md
new file mode 100644
index 00000000..b0ef5baf
--- /dev/null
+++ b/src/pages/fees/dynamic-fee/index.md
@@ -0,0 +1,59 @@
+---
+title: Dynamic Fees
+version: 0.8.20
+description: Design a v4 pool with a dynamic fee
+keywords: [fee, fees, dynamic fee, dynamic, poke]
+---
+
+- Design a v4 pool with a dynamic fee
+
+Uniswap v4 pools can support dynamic swap fees, and do not need to adhere to a static fee (0.05% / 0.30% / 1.0%). The hook needs to inherit `IDynamicFeeManager` and use `FeeLibrary.DYNAMIC_FEE_FLAG` in its `PoolKey.fee`.
+
+Despite its name, the fee is *cached* by the `PoolManager` and an external call `PoolManager.updateDynamicFee()` is required to change the swap fee.
+
+**Note: dynamic fees can be computed every swap, but incurs a gas overhead**
+
+---
+
+### Initialize a Dynamic Fee Pool
+
+```solidity
+import {FeeLibrary} from "v4-core/libraries/FeeLibrary.sol";
+
+
+poolKey = PoolKey(
+ Currency.wrap(address(token0)),
+ Currency.wrap(address(token1)),
+ FeeLibrary.DYNAMIC_FEE_FLAG, // signal that the pool has a dynamic fee
+ 60,
+ IHooks(hook)
+);
+initializeRouter.initialize(poolKey, startingPrice, hookData);
+```
+
+## Example: Manual Dynamic Fee
+
+*Implements a time-decaying dynamic fee*
+
+* The swap fee starts at 5.0%
+* The fee decays 0.00001% every second
+* After 495,000 seconds, the minimum fee is set to 0.05%
+
+
+1) Inherit `IDynamicFeeManager`
+2) Implement `getFee()`
+3) Poke `PoolManager.updateDynamicFee()` to change the fee
+```solidity
+{{{ManualDynamicFee}}}
+```
+
+## Example: Automatic Dynamic Fee
+
+*Implements an automatically-updated, time-decaying dynamic fee*
+
+The hook uses `beforeSwap` to automatically poke the PoolManager, ensuring the fee is always up-to-date
+
+*incurs +23,000 gas overhead*
+```solidity
+{{{AutoDynamicFee}}}
+```
diff --git a/src/pages/fees/dynamic-fee/index.tsx b/src/pages/fees/dynamic-fee/index.tsx
new file mode 100644
index 00000000..1c63a4f4
--- /dev/null
+++ b/src/pages/fees/dynamic-fee/index.tsx
@@ -0,0 +1,29 @@
+import React from "react"
+import Example from "../../../components/Example"
+import html, { version, title, description, codes } from "./index.html"
+
+interface Path {
+ path: string
+ title: string
+}
+
+interface Props {
+ prev: Path | null
+ next: Path | null
+}
+
+const ExamplePage: React.FC