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

+
+

Initialize a Dynamic Fee Pool

+
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

+ +
    +
  1. Inherit IDynamicFeeManager
  2. +
  3. Implement getFee()
  4. +
  5. Poke PoolManager.updateDynamicFee() to change the fee
  6. +
+
// 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
+        });
+    }
+}
+

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

+
// 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 = ({ prev, next }) => { + return ( + + ) +} + +export default ExamplePage diff --git a/src/pages/index.tsx b/src/pages/index.tsx index bf408dfc..2bdaac71 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -8,7 +8,7 @@ import styles from "./index.module.css" import youTube from "../components/youtube.png" import { ROUTES, ROUTES_BY_CATEGORY, TRANSLATIONS } from "../nav" -const UPDATES = ["2024/01/08 - Quoter", "2023/12/15 - Update v4", "2023/12/11 - Custom Curves", "2023/12/03 - Static Hook Fee", "2023/11/28 - Updated pool initialization", "2023/11/28 - NoOp", "2023/11/13 - Make snippets concise", "2023/10/18 - Initial V4 Snippets"] +const UPDATES = ["2024/01/23 - Dynamic Fees", "2024/01/08 - Quoter", "2023/12/15 - Update v4", "2023/12/11 - Custom Curves", "2023/12/03 - Static Hook Fee", "2023/11/28 - Updated pool initialization", "2023/11/28 - NoOp", "2023/11/13 - Make snippets concise", "2023/10/18 - Initial V4 Snippets"] export default function HomePage() { const [query, setQuery] = useState("") diff --git a/src/routes.tsx b/src/routes.tsx index e87863ef..023ff0a0 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,4 +1,5 @@ import component_create_liquidity from "./pages/create-liquidity" +import component_fees_dynamic_fee from "./pages/fees/dynamic-fee" import component_fees_fixed_hook_fee from "./pages/fees/fixed-hook-fee" import component_hooks_custom_curve from "./pages/hooks/custom-curve" import component_hooks_no_op from "./pages/hooks/no-op" @@ -28,6 +29,10 @@ const routes: Route[] = [ path: "/create-liquidity", component: component_create_liquidity }, + { + path: "/fees/dynamic-fee", + component: component_fees_dynamic_fee + }, { path: "/fees/fixed-hook-fee", component: component_fees_fixed_hook_fee diff --git a/src/search.json b/src/search.json index a6562aa2..17493745 100644 --- a/src/search.json +++ b/src/search.json @@ -79,7 +79,8 @@ "/hooks/custom-curve" ], "fee": [ - "/fees/fixed-hook-fee" + "/fees/fixed-hook-fee", + "/fees/dynamic-fee" ], "static fee": [ "/fees/fixed-hook-fee" @@ -87,6 +88,18 @@ "hook fee": [ "/fees/fixed-hook-fee" ], + "fees": [ + "/fees/dynamic-fee" + ], + "dynamic fee": [ + "/fees/dynamic-fee" + ], + "dynamic": [ + "/fees/dynamic-fee" + ], + "poke": [ + "/fees/dynamic-fee" + ], "liquidity": [ "/create-liquidity" ],