Skip to content

Commit

Permalink
Early Exit (#140)
Browse files Browse the repository at this point in the history
* fix: rename targetProceeds to minimumProceeds, add maximumProceeds immutable

* feat: early exit toggle in afterSwap

* test: early exit swap test
  • Loading branch information
kinrezC authored Oct 18, 2024
1 parent a57ddde commit 5be2476
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 82 deletions.
25 changes: 20 additions & 5 deletions src/Doppler.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -916,6 +929,8 @@ error InvalidTickSpacing();
error InvalidEpochLength();
error InvalidTickDelta();
error InvalidSwap();
error InvalidProceedLimits();
error InvalidNumPDSlugs();
error InvalidSwapAfterMaturitySufficientProceeds();
error InvalidSwapAfterMaturityInsufficientProceeds();
error MaximumProceedsReached();
24 changes: 16 additions & 8 deletions test/shared/BaseTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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)));
Expand All @@ -165,15 +172,16 @@ contract BaseTest is Test, Deployers {
manager,
key,
config.numTokensToSell,
config.targetProceeds,
config.minimumProceeds,
config.maximumProceeds,
config.startingTime,
config.endingTime,
startTick,
endTick,
config.epochLength,
config.gamma,
isToken0,
3,
config.numPDSlugs,
hook
),
address(hook)
Expand Down
14 changes: 10 additions & 4 deletions test/shared/DopplerImplementation.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,7 +33,8 @@ contract DopplerImplementation is Doppler {
IPoolManager(_poolManager),
_poolKey,
_numTokensToSell,
_targetProceeds,
_minimumProceeds,
_maximumProceeds,
_startingTime,
_endingTime,
_startingTick,
Expand Down Expand Up @@ -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) {
Expand Down
56 changes: 0 additions & 56 deletions test/unit/BeforeSwap.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
14 changes: 12 additions & 2 deletions test/unit/Constructor.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -59,7 +60,8 @@ contract ConstructorTest is BaseTest {
manager,
key,
config.numTokensToSell,
config.targetProceeds,
config.minimumProceeds,
config.maximumProceeds,
config.startingTime,
config.endingTime,
_startTick,
Expand Down Expand Up @@ -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;
Expand Down
113 changes: 113 additions & 0 deletions test/unit/EarlyExit.t.sol
Original file line number Diff line number Diff line change
@@ -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),
""
);
}
}
Loading

0 comments on commit 5be2476

Please sign in to comment.