diff --git a/src/Doppler.sol b/src/Doppler.sol index b7c3c09a..b5efdbd1 100644 --- a/src/Doppler.sol +++ b/src/Doppler.sol @@ -61,12 +61,14 @@ contract Doppler is BaseHook { // TODO: consider if we can use smaller uints // TODO: consider whether these need to be public - bool public insufficientProceeds; // triggers if the pool matures and targetProceeds is not met + bool public insufficientProceeds; // triggers if the pool matures and minimumProceeds is not met + bool public earlyExit; // triggers if the pool ever reaches or exceeds maximumProceeds State public state; mapping(bytes32 salt => Position) public positions; uint256 immutable numTokensToSell; // total amount of tokens to be sold - uint256 immutable targetProceeds; // target proceeds to be sold + uint256 immutable minimumProceeds; // minimum proceeds required to avoid refund phase + uint256 immutable maximumProceeds; // proceeds amount that will trigger early exit condition uint256 immutable startingTime; // sale start time uint256 immutable endingTime; // sale end time int24 immutable startingTick; // dutch auction starting tick @@ -80,7 +82,8 @@ contract Doppler is BaseHook { IPoolManager _poolManager, PoolKey memory _poolKey, uint256 _numTokensToSell, - uint256 _targetProceeds, + uint256 _minimumProceeds, + uint256 _maximumProceeds, uint256 _startingTime, uint256 _endingTime, int24 _startingTick, @@ -123,9 +126,13 @@ contract Doppler is BaseHook { /* Num price discovery slug checks */ if (_numPDSlugs == 0) revert InvalidNumPDSlugs(); if (_numPDSlugs > MAX_PRICE_DISCOVERY_SLUGS) revert InvalidNumPDSlugs(); + + // These can both be zero + if (_minimumProceeds > _maximumProceeds) revert InvalidProceedLimits(); numTokensToSell = _numTokensToSell; - targetProceeds = _targetProceeds; + minimumProceeds = _minimumProceeds; + maximumProceeds = _maximumProceeds; startingTime = _startingTime; endingTime = _endingTime; startingTick = _startingTick; @@ -153,10 +160,11 @@ contract Doppler is BaseHook { onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) { + if (earlyExit) revert MaximumProceedsReached(); if (block.timestamp < startingTime) revert InvalidTime(); // only check proceeds if we're after maturity and we haven't already triggered insufficient proceeds if (block.timestamp > endingTime && !insufficientProceeds) { - if (state.totalProceeds < targetProceeds) { + if (state.totalProceeds < minimumProceeds) { insufficientProceeds = true; PoolId poolId = key.toId(); @@ -265,6 +273,11 @@ contract Doppler is BaseHook { state.totalProceeds += proceedsLessFee; } } + + // if we reach or exceed the maximumProceeds, we trigger the early exit condition + if (state.totalProceeds >= maximumProceeds) { + earlyExit = true; + } return (BaseHook.afterSwap.selector, 0); } @@ -916,6 +929,8 @@ error InvalidTickSpacing(); error InvalidEpochLength(); error InvalidTickDelta(); error InvalidSwap(); +error InvalidProceedLimits(); error InvalidNumPDSlugs(); error InvalidSwapAfterMaturitySufficientProceeds(); error InvalidSwapAfterMaturityInsufficientProceeds(); +error MaximumProceedsReached(); diff --git a/test/shared/BaseTest.sol b/test/shared/BaseTest.sol index a9a3ea2d..8a1c574c 100644 --- a/test/shared/BaseTest.sol +++ b/test/shared/BaseTest.sol @@ -23,7 +23,8 @@ contract BaseTest is Test, Deployers { // TODO: Maybe add the start and end ticks to the config? struct DopplerConfig { uint256 numTokensToSell; - uint256 targetProceeds; + uint256 minimumProceeds; + uint256 maximumProceeds; uint256 startingTime; uint256 endingTime; int24 gamma; @@ -36,15 +37,20 @@ contract BaseTest is Test, Deployers { // Constants uint256 constant DEFAULT_NUM_TOKENS_TO_SELL = 100_000e18; - uint256 constant DEFAULT_TARGET_PROCEEDS = 100e18; + uint256 constant DEFAULT_MINIMUM_PROCEEDS = 100e18; + uint256 constant DEFAULT_MAXIMUM_PROCEEDS = 10_000e18; uint256 constant DEFAULT_STARTING_TIME = 1 days; uint256 constant DEFAULT_ENDING_TIME = 2 days; int24 constant DEFAULT_GAMMA = 800; uint256 constant DEFAULT_EPOCH_LENGTH = 400 seconds; + // default to feeless case for now uint24 constant DEFAULT_FEE = 0; int24 constant DEFAULT_TICK_SPACING = 8; - uint256 constant DEFAULT_NUM_PD_SLUGS = 1; + uint256 constant DEFAULT_NUM_PD_SLUGS = 3; + + int24 constant DEFAULT_START_TICK = 1600; + int24 constant DEFAULT_END_TICK = 171_200; address constant TOKEN_A = address(0x8888); address constant TOKEN_B = address(0x9999); @@ -53,7 +59,8 @@ contract BaseTest is Test, Deployers { DopplerConfig DEFAULT_DOPPLER_CONFIG = DopplerConfig({ numTokensToSell: DEFAULT_NUM_TOKENS_TO_SELL, - targetProceeds: DEFAULT_TARGET_PROCEEDS, + minimumProceeds: DEFAULT_MINIMUM_PROCEEDS, + maximumProceeds: DEFAULT_MAXIMUM_PROCEEDS, startingTime: DEFAULT_STARTING_TIME, endingTime: DEFAULT_ENDING_TIME, gamma: DEFAULT_GAMMA, @@ -145,8 +152,8 @@ contract BaseTest is Test, Deployers { // isToken0 ? startTick > endTick : endTick > startTick // In both cases, price(startTick) > price(endTick) - startTick = isToken0 ? int24(1600) : int24(-1600); - endTick = isToken0 ? int24(-171_200) : int24(171_200); + startTick = isToken0 ? DEFAULT_START_TICK : -DEFAULT_START_TICK; + endTick = isToken0 ? -DEFAULT_END_TICK : DEFAULT_END_TICK; // Default to feeless case because it's easier to reason about config.fee = uint24(vm.envOr("FEE", uint24(0))); @@ -165,7 +172,8 @@ contract BaseTest is Test, Deployers { manager, key, config.numTokensToSell, - config.targetProceeds, + config.minimumProceeds, + config.maximumProceeds, config.startingTime, config.endingTime, startTick, @@ -173,7 +181,7 @@ contract BaseTest is Test, Deployers { config.epochLength, config.gamma, isToken0, - 3, + config.numPDSlugs, hook ), address(hook) diff --git a/test/shared/DopplerImplementation.sol b/test/shared/DopplerImplementation.sol index 274896bb..a1f637b0 100644 --- a/test/shared/DopplerImplementation.sol +++ b/test/shared/DopplerImplementation.sol @@ -17,7 +17,8 @@ contract DopplerImplementation is Doppler { address _poolManager, PoolKey memory _poolKey, uint256 _numTokensToSell, - uint256 _targetProceeds, + uint256 _minimumProceeds, + uint256 _maximumProceeds, uint256 _startingTime, uint256 _endingTime, int24 _startingTick, @@ -32,7 +33,8 @@ contract DopplerImplementation is Doppler { IPoolManager(_poolManager), _poolKey, _numTokensToSell, - _targetProceeds, + _minimumProceeds, + _maximumProceeds, _startingTime, _endingTime, _startingTick, @@ -69,8 +71,12 @@ contract DopplerImplementation is Doppler { return numTokensToSell; } - function getTargetProceeds() public view returns (uint256) { - return targetProceeds; + function getMinimumProceeds() public view returns (uint256) { + return minimumProceeds; + } + + function getMaximumProceeds() public view returns (uint256) { + return maximumProceeds; } function getStartingTick() public view returns (int24) { diff --git a/test/unit/BeforeSwap.t.sol b/test/unit/BeforeSwap.t.sol index 2ff7f662..2c8ce38c 100644 --- a/test/unit/BeforeSwap.t.sol +++ b/test/unit/BeforeSwap.t.sol @@ -25,60 +25,4 @@ contract BeforeSwapTest is BaseTest { "" ); } - - // TODO: get this test to trigger the case in `_rebalance` where `requiredProceeds > totalProceeds_`. - // TODO: Doppler.sol#L122 is using `amount1` instead of `amount0`. - function testBeforeSwap_RebalanceToken1() public { - // Deploy a new Doppler with `isToken0 = false` - - TestERC20 asset_ = new TestERC20(2 ** 128); - TestERC20 numeraire_ = new TestERC20(2 ** 128); - - // Reorg the asset and the numeraire so the asset will be the token1 - (asset_, numeraire_) = address(asset_) > address(numeraire_) ? (asset_, numeraire_) : (numeraire_, asset_); - - _deploy( - asset_, - numeraire_, - DopplerConfig({ - numTokensToSell: DEFAULT_NUM_TOKENS_TO_SELL, - targetProceeds: DEFAULT_TARGET_PROCEEDS, - startingTime: DEFAULT_STARTING_TIME, - endingTime: DEFAULT_ENDING_TIME, - gamma: DEFAULT_GAMMA, - epochLength: 1 days, - fee: DEFAULT_FEE, - tickSpacing: DEFAULT_TICK_SPACING, - numPDSlugs: DEFAULT_NUM_PD_SLUGS - }) - ); - - vm.warp(hook.getStartingTime()); - - vm.prank(address(manager)); - (bytes4 selector0, int128 hookDelta) = hook.afterSwap( - address(this), - key, - IPoolManager.SwapParams({zeroForOne: true, amountSpecified: 1e2, sqrtPriceLimitX96: SQRT_RATIO_2_1}), - toBalanceDelta(-1e2, 10e18), - "" - ); - - assertEq(selector0, BaseHook.afterSwap.selector); - assertEq(hookDelta, 0); - - vm.warp(hook.getStartingTime() + hook.getEpochLength()); - - vm.prank(address(manager)); - (bytes4 selector, BeforeSwapDelta delta, uint24 fee) = hook.beforeSwap( - address(this), - key, - IPoolManager.SwapParams({zeroForOne: false, amountSpecified: 100e18, sqrtPriceLimitX96: SQRT_RATIO_2_1}), - "" - ); - - assertEq(selector, BaseHook.beforeSwap.selector); - assertEq(BeforeSwapDelta.unwrap(delta), 0); - assertEq(fee, 0); - } } diff --git a/test/unit/Constructor.t.sol b/test/unit/Constructor.t.sol index 4768d8a6..754e23c1 100644 --- a/test/unit/Constructor.t.sol +++ b/test/unit/Constructor.t.sol @@ -10,7 +10,8 @@ import { InvalidEpochLength, InvalidTimeRange, InvalidTickSpacing, - InvalidNumPDSlugs + InvalidNumPDSlugs, + InvalidProceedLimits } from "src/Doppler.sol"; import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; @@ -59,7 +60,8 @@ contract ConstructorTest is BaseTest { manager, key, config.numTokensToSell, - config.targetProceeds, + config.minimumProceeds, + config.maximumProceeds, config.startingTime, config.endingTime, _startTick, @@ -190,6 +192,14 @@ contract ConstructorTest is BaseTest { deployDoppler(InvalidNumPDSlugs.selector, config, 0, -172_800, true); } + function testConstructor_RevertsInvalidProceedLimits_WhenMinimumProceedsGreaterThanMaximumProceeds() public { + DopplerConfig memory config = DEFAULT_DOPPLER_CONFIG; + config.minimumProceeds = 100; + config.maximumProceeds = 0; + + deployDoppler(InvalidProceedLimits.selector, config, 0, -172_800, true); + } + function testConstructor_Succeeds_WithValidParameters() public { bool _isToken0 = true; int24 _startTick = 0; diff --git a/test/unit/EarlyExit.t.sol b/test/unit/EarlyExit.t.sol new file mode 100644 index 00000000..408f5b39 --- /dev/null +++ b/test/unit/EarlyExit.t.sol @@ -0,0 +1,113 @@ +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; + +import {BaseTest} from "test/shared/BaseTest.sol"; +import {DopplerImplementation} from "test/shared/DopplerImplementation.sol"; +import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; +import {PoolKey} from "v4-periphery/lib/v4-core/src/types/PoolKey.sol"; +import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; +import {Hooks} from "v4-core/src/libraries/Hooks.sol"; +import {Currency} from "v4-periphery/lib/v4-core/src/types/Currency.sol"; +import {TickMath} from "v4-core/src/libraries/TickMath.sol"; +import {PoolManager} from "v4-core/src/PoolManager.sol"; +import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; +import {MaximumProceedsReached} from "src/Doppler.sol"; +import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol"; +import "forge-std/console.sol"; + +using PoolIdLibrary for PoolKey; + +contract EarlyExitTest is BaseTest { + function setUp() public override { + manager = new PoolManager(); + _deployTokens(); + } + + function deployDoppler( + DopplerConfig memory config + ) internal { + (token0, token1) = isToken0 ? (asset, numeraire) : (numeraire, asset); + (isToken0 ? token0 : token1).transfer(address(hook), config.numTokensToSell); + vm.label(address(token0), "Token0"); + vm.label(address(token1), "Token1"); + + int24 _startTick = isToken0 ? DEFAULT_START_TICK : -DEFAULT_START_TICK; + int24 _endTick = isToken0 ? -DEFAULT_END_TICK : DEFAULT_END_TICK; + + key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: config.tickSpacing, + hooks: IHooks(address(hook)) + }); + deployCodeTo( + "DopplerImplementation.sol:DopplerImplementation", + abi.encode( + manager, + key, + config.numTokensToSell, + config.minimumProceeds, + config.maximumProceeds, + config.startingTime, + config.endingTime, + _startTick, + _endTick, + config.epochLength, + config.gamma, + isToken0, + config.numPDSlugs, + hook + ), + address(hook) + ); + manager.initialize(key, TickMath.getSqrtPriceAtTick(startTick), new bytes(0)); + + // Deploy swapRouter + swapRouter = new PoolSwapTest(manager); + + // Deploy modifyLiquidityRouter + // Note: Only used to validate that liquidity can't be manually modified + modifyLiquidityRouter = new PoolModifyLiquidityTest(manager); + + // Approve the router to spend tokens on behalf of the test contract + token0.approve(address(swapRouter), type(uint256).max); + token1.approve(address(swapRouter), type(uint256).max); + token0.approve(address(modifyLiquidityRouter), type(uint256).max); + token1.approve(address(modifyLiquidityRouter), type(uint256).max); + } + + function test_swap_RevertsIfMaximumProceedsReached() public { + DopplerConfig memory config = DEFAULT_DOPPLER_CONFIG; + config.maximumProceeds = 500e18; + + deployDoppler(config); + + vm.warp(hook.getStartingTime()); + + int256 maximumProceeds = int256(hook.getMaximumProceeds()); + + swapRouter.swap( + key, + IPoolManager.SwapParams(!isToken0, -maximumProceeds, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); + + vm.warp(hook.getStartingTime() + hook.getEpochLength()); // Next epoch + + vm.expectRevert( + abi.encodeWithSelector( + Hooks.Wrap__FailedHookCall.selector, hook, abi.encodeWithSelector(MaximumProceedsReached.selector) + ) + ); + swapRouter.swap( + key, + IPoolManager.SwapParams(isToken0, -1 ether, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + PoolSwapTest.TestSettings(true, false), + "" + ); + } +} \ No newline at end of file diff --git a/test/unit/Swap.sol b/test/unit/Swap.sol index f9f33361..231ae36c 100644 --- a/test/unit/Swap.sol +++ b/test/unit/Swap.sol @@ -4,7 +4,6 @@ import {Test} from "forge-std/Test.sol"; import {MAX_SWAP_FEE} from "src/Doppler.sol"; import {IPoolManager} from "v4-periphery/lib/v4-core/src/interfaces/IPoolManager.sol"; -import {Hooks} from "v4-core/src/libraries/Hooks.sol"; import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol"; import {Hooks} from "v4-core/src/libraries/Hooks.sol"; import {PoolId, PoolIdLibrary} from "v4-periphery/lib/v4-core/src/types/PoolId.sol"; @@ -47,13 +46,13 @@ contract SwapTest is BaseTest { function test_swap_RevertsAfterEndTimeInsufficientProceedsAssetBuy() public { vm.warp(hook.getStartingTime()); // 1 second after the end time - int256 targetProceeds = int256(hook.getTargetProceeds()); + int256 minimumProceeds = int256(hook.getMinimumProceeds()); swapRouter.swap( // Swap numeraire to asset // If zeroForOne, we use max price limit (else vice versa) key, - IPoolManager.SwapParams(!isToken0, -(targetProceeds / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + IPoolManager.SwapParams(!isToken0, -minimumProceeds / 2, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), PoolSwapTest.TestSettings(true, false), "" ); @@ -79,13 +78,13 @@ contract SwapTest is BaseTest { function test_swap_CanRepurchaseNumeraireAfterEndTimeInsufficientProceeds() public { vm.warp(hook.getStartingTime()); // 1 second after the end time - int256 targetProceeds = int256(hook.getTargetProceeds()); + int256 minimumProceeds = int256(hook.getMinimumProceeds()); swapRouter.swap( // Swap numeraire to asset // If zeroForOne, we use max price limit (else vice versa) key, - IPoolManager.SwapParams(!isToken0, -(targetProceeds / 2), !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + IPoolManager.SwapParams(!isToken0, -minimumProceeds / 2, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), PoolSwapTest.TestSettings(true, false), "" ); @@ -116,13 +115,13 @@ contract SwapTest is BaseTest { function test_swap_RevertsAfterEndTimeSufficientProceeds() public { vm.warp(hook.getStartingTime()); // 1 second after the end time - int256 targetProceeds = int256(hook.getTargetProceeds()); + int256 minimumProceeds = int256(hook.getMinimumProceeds()); swapRouter.swap( // Swap numeraire to asset // If zeroForOne, we use max price limit (else vice versa) key, - IPoolManager.SwapParams(!isToken0, targetProceeds, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), + IPoolManager.SwapParams(!isToken0, minimumProceeds, !isToken0 ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT), PoolSwapTest.TestSettings(true, false), "" );