diff --git a/src/dopplerHooks/TwapSellExecutorHook.sol b/src/dopplerHooks/TwapSellExecutorHook.sol new file mode 100644 index 00000000..dc932d94 --- /dev/null +++ b/src/dopplerHooks/TwapSellExecutorHook.sol @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import { IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { StateLibrary } from "@v4-core/libraries/StateLibrary.sol"; +import { TickMath } from "@v4-core/libraries/TickMath.sol"; +import { BalanceDelta } from "@v4-core/types/BalanceDelta.sol"; +import { Currency, CurrencyLibrary } from "@v4-core/types/Currency.sol"; +import { PoolId } from "@v4-core/types/PoolId.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; + +import { Quoter } from "@quoter/Quoter.sol"; + +import { BaseDopplerHook } from "src/base/BaseDopplerHook.sol"; +import { DopplerHookInitializer } from "src/initializers/DopplerHookInitializer.sol"; +import { V4QuoteMath } from "src/libraries/V4QuoteMath.sol"; + +/// ----------------------------------------------------------------------- +/// Interfaces +/// ----------------------------------------------------------------------- + +interface ITwapVault { + function registerPool(PoolId poolId, address asset, address numeraire, address buybackDst) external; + function inventory(PoolId poolId, address token) external view returns (uint256); + function debitToExecutor(PoolId poolId, address token, uint256 amount, address to) external; + function creditFromExecutor(PoolId poolId, address token, uint256 amount) external; +} + +/// ----------------------------------------------------------------------- +/// Errors / Events +/// ----------------------------------------------------------------------- + +error InvalidTwapSchedule(); + +event TwapScheduleInitialized( + PoolId indexed poolId, + uint32 startTs, + uint32 endTs, + uint256 rateValuePerSec, + uint256 maxValuePerExecute, + uint256 maxAccumulatorValue +); + +event TwapSellExecuted( + PoolId indexed poolId, + uint256 assetInUsed, + uint256 numeraireOut, + uint256 accumulatorAfter +); + +/// ----------------------------------------------------------------------- +/// Hook: TWAP sell executor (vault-backed) +/// ----------------------------------------------------------------------- + +/** + * @title TwapSellExecutorHook + * @notice A minimal TWAP sell executor that: + * - tracks a buffered value-rate accumulator in **numeraire units/sec** + * - on execution, sells **asset -> numeraire** via Uniswap v4 + * - uses a middleware vault (TwapVault) for custody and accounting + * + * Key property: + * - This hook executes TWAP in `_onSwap()` (swap-driven execution). + * - The vault is custody + accounting; this hook debits/credits vault inventory during swap. + * + * TODO: Execute TWAP earlier in the swap lifecycle ("before swap"). + * Today, Doppler calls this hook via `DopplerHookInitializer.afterSwap` -> `IDopplerHook.onSwap`. + * Supporting a true pre-swap TWAP requires wiring a pre-swap callback through the initializer and/or + * using Uniswap v4 hook permissions beyond the current Doppler hook interface. + */ +contract TwapSellExecutorHook is BaseDopplerHook { + using StateLibrary for IPoolManager; + using CurrencyLibrary for Currency; + + // "No-op" return for Doppler hook callbacks. + // Doppler ignores the return value today; we keep this explicit to avoid repeating magic literals. + Currency internal constant NOOP = Currency.wrap(address(0)); + int128 internal constant NOOP_DELTA = 0; + + uint256 internal constant MAX_TWAP_SEARCH_ITERATIONS = 15; + + IPoolManager public immutable poolManager; + ITwapVault public immutable vault; + Quoter public immutable quoter; + + struct PoolInfo { + address asset; + address numeraire; + address buybackDst; + } + + /// @notice Immutable linear TWAP schedule. + /// @dev Budget accrues in numeraire value units over [startTs, endTs). + /// - `rateValuePerSec`: value units per second (0 disables selling) + /// - `maxValuePerExecute`: cap budget per execution (0 uncapped) + /// - `maxAccumulatorValue`: cap total buffered budget (0 uncapped) + struct TwapSellSchedule { + uint32 startTs; + uint32 endTs; + uint256 rateValuePerSec; + uint256 maxValuePerExecute; + uint256 maxAccumulatorValue; + } + + struct TwapSellState { + uint256 accumulatorValue; // numeraire units + uint32 lastTs; // last accumulator update timestamp + } + + mapping(PoolId poolId => PoolInfo info) public getPoolInfo; + mapping(PoolId poolId => TwapSellSchedule schedule) public getTwapSellSchedule; + mapping(PoolId poolId => TwapSellState st) public getTwapSellState; + + /// @notice Last block number when TWAP was executed for a pool (to execute at most once per block). + mapping(PoolId poolId => uint256 lastBlock) public lastTwapExecBlock; + + receive() external payable { } + + constructor(address initializer, IPoolManager poolManager_, ITwapVault vault_) BaseDopplerHook(initializer) { + poolManager = poolManager_; + vault = vault_; + quoter = new Quoter(poolManager_); + } + + // --------------------------------------------------------------------- + // BaseDopplerHook: initialization/swap/graduation + // --------------------------------------------------------------------- + + function _onInitialization(address asset, PoolKey calldata key, bytes calldata data) internal override { + ( + address numeraire, + address buybackDst, + uint32 startTs, + uint32 endTs, + uint256 rateValuePerSec, + uint256 maxValuePerExecute, + uint256 maxAccumulatorValue + ) = abi.decode(data, (address, address, uint32, uint32, uint256, uint256, uint256)); + + if (endTs <= startTs) revert InvalidTwapSchedule(); + + PoolId poolId = key.toId(); + getPoolInfo[poolId] = PoolInfo({ asset: asset, numeraire: numeraire, buybackDst: buybackDst }); + + getTwapSellSchedule[poolId] = TwapSellSchedule({ + startTs: startTs, + endTs: endTs, + rateValuePerSec: rateValuePerSec, + maxValuePerExecute: maxValuePerExecute, + maxAccumulatorValue: maxAccumulatorValue + }); + + getTwapSellState[poolId].lastTs = uint32(block.timestamp); + + emit TwapScheduleInitialized(poolId, startTs, endTs, rateValuePerSec, maxValuePerExecute, maxAccumulatorValue); + + // Register pool in the vault so it can enforce access control and maintain accounting. + // NOTE: requires TwapVault.executor == address(this). + vault.registerPool(poolId, asset, numeraire, buybackDst); + } + + function _onSwap( + address sender, + PoolKey calldata key, + IPoolManager.SwapParams calldata, + BalanceDelta, + bytes calldata + ) internal override returns (Currency, int128) { + // Recursion guard: when this hook executes its own swap, do not re-enter TWAP. + if (sender == address(this)) { + return (NOOP, NOOP_DELTA); + } + + PoolId poolId = key.toId(); + + TwapSellState storage st = getTwapSellState[poolId]; + + // Execute at most once per block ("start of block" semantics). + if (lastTwapExecBlock[poolId] == block.number) { + return (NOOP, NOOP_DELTA); + } + + TwapSellSchedule memory sched = getTwapSellSchedule[poolId]; + if (sched.rateValuePerSec == 0) { + return (NOOP, NOOP_DELTA); + } + + // Accrue budget in-memory first; commit to storage only once per path. + (uint32 lastAfterAccrue, uint256 accAfterAccrue) = _computeAccrual(sched, st.lastTs, uint32(block.timestamp), st.accumulatorValue); + + uint256 accValue = accAfterAccrue; + if (accValue == 0) { + _commitState(st, lastAfterAccrue, accAfterAccrue); + return (NOOP, NOOP_DELTA); + } + + PoolInfo memory p = getPoolInfo[poolId]; + address asset = p.asset; + + // Resolve tokens/direction. + bool assetIsToken0 = key.currency0 == Currency.wrap(asset); + bool zeroForOne = assetIsToken0; // asset -> numeraire + + uint256 valueBudget = accValue; + if (sched.maxValuePerExecute != 0 && valueBudget > sched.maxValuePerExecute) { + valueBudget = sched.maxValuePerExecute; + } + if (valueBudget == 0) { + return (NOOP, NOOP_DELTA); + } + + uint256 availableAsset = vault.inventory(poolId, asset); + if (availableAsset == 0) { + _commitState(st, lastAfterAccrue, accAfterAccrue); + return (NOOP, NOOP_DELTA); + } + + // Convert a numeraire value budget into an (amountIn -> amountOut) swap plan. + // This is a bounded binary-search using the v4 Quoter, which accounts for price impact + // and avoids relying on spot-price math. + (uint256 amountIn, uint256 expectedOut) = V4QuoteMath.findAmountInForOutBudget( + quoter, key, zeroForOne, valueBudget, availableAsset, MAX_TWAP_SEARCH_ITERATIONS + ); + if (amountIn == 0 || expectedOut == 0) { + _commitState(st, lastAfterAccrue, accAfterAccrue); + return (NOOP, NOOP_DELTA); + } + + // Debit from vault into executor. + vault.debitToExecutor(poolId, asset, amountIn, address(this)); + + // Execute the swap directly (we are already in the PoolManager's swap lifecycle). + // If this swap reverts, the entire call stack reverts, including the prior vault debit. + BalanceDelta swapDelta = poolManager.swap( + key, + IPoolManager.SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: -int256(amountIn), + sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }), + new bytes(0) + ); + + _settleDelta(key, swapDelta); + _collectDelta(key, swapDelta); + + uint256 assetInUsed = zeroForOne ? _abs(swapDelta.amount0()) : _abs(swapDelta.amount1()); + uint256 numeraireOut = zeroForOne ? _abs(swapDelta.amount1()) : _abs(swapDelta.amount0()); + + if (assetInUsed == 0 || numeraireOut == 0) { + // If we somehow used no input, refund any remaining balance. + uint256 bal = Currency.wrap(asset).balanceOf(address(this)); + if (bal > 0) { + _transferToken(asset, address(vault), bal); + vault.creditFromExecutor(poolId, asset, bal); + } + + _commitState(st, lastAfterAccrue, accAfterAccrue); + return (NOOP, NOOP_DELTA); + } + + // Refund any unused input. + if (assetInUsed < amountIn) { + uint256 refund = amountIn - assetInUsed; + _transferToken(asset, address(vault), refund); + vault.creditFromExecutor(poolId, asset, refund); + } + + // Forward proceeds to vault. + address numeraire = p.numeraire; + _transferToken(numeraire, address(vault), numeraireOut); + vault.creditFromExecutor(poolId, numeraire, numeraireOut); + + // Update accumulator (saturating). Commit lastTs + accumulator in one storage write for accumulator. + uint256 accAfter = numeraireOut >= accValue ? 0 : (accValue - numeraireOut); + _commitState(st, lastAfterAccrue, accAfter); + lastTwapExecBlock[poolId] = block.number; + + emit TwapSellExecuted(poolId, assetInUsed, numeraireOut, accAfter); + return (NOOP, NOOP_DELTA); + } + + function _onGraduation(address, PoolKey calldata, bytes calldata) internal pure override { } + + // --------------------------------------------------------------------- + // Accumulator (bounded linear stream over [startTs, endTs)) + // --------------------------------------------------------------------- + + function _computeAccrual( + TwapSellSchedule memory sched, + uint32 last, + uint32 nowTs, + uint256 oldAcc + ) internal pure returns (uint32 lastAfter, uint256 accAfter) { + // Default: no change. + lastAfter = last; + accAfter = oldAcc; + + if (last == 0) { + // Initialize lastTs on first touch. + lastAfter = nowTs; + return (lastAfter, accAfter); + } + if (nowTs <= last) return (lastAfter, accAfter); + + if (sched.rateValuePerSec == 0) return (lastAfter, accAfter); + + uint32 from = last < sched.startTs ? sched.startTs : last; + uint32 to = nowTs < sched.endTs ? nowTs : sched.endTs; + if (to <= from) return (lastAfter, accAfter); + + lastAfter = to; + + uint256 dt = uint256(to - from); + uint256 add = sched.rateValuePerSec * dt; + if (add == 0) return (lastAfter, accAfter); + + uint256 newAcc = oldAcc + add; + if (sched.maxAccumulatorValue != 0 && newAcc > sched.maxAccumulatorValue) { + newAcc = sched.maxAccumulatorValue; + } + + accAfter = newAcc; + } + + function _commitState(TwapSellState storage st, uint32 lastAfter, uint256 accAfter) internal { + if (lastAfter != 0 && lastAfter != st.lastTs) { + st.lastTs = lastAfter; + } + if (accAfter != st.accumulatorValue) { + st.accumulatorValue = accAfter; + } + } + + // NOTE: Quoting + inversion helpers live in `src/libraries/V4QuoteMath.sol`. + + function _settleDelta(PoolKey memory key, BalanceDelta delta) internal { + if (delta.amount0() < 0) _pay(key.currency0, uint256(uint128(-delta.amount0()))); + if (delta.amount1() < 0) _pay(key.currency1, uint256(uint128(-delta.amount1()))); + } + + function _collectDelta(PoolKey memory key, BalanceDelta delta) internal { + if (delta.amount0() > 0) { + poolManager.take(key.currency0, address(this), uint128(delta.amount0())); + } + if (delta.amount1() > 0) { + poolManager.take(key.currency1, address(this), uint128(delta.amount1())); + } + } + + function _pay(Currency currency, uint256 amount) internal { + if (amount == 0) return; + poolManager.sync(currency); + if (currency.isAddressZero()) { + poolManager.settle{ value: amount }(); + } else { + currency.transfer(address(poolManager), amount); + poolManager.settle(); + } + } + + function _abs(int256 value) internal pure returns (uint256) { + return value < 0 ? uint256(-value) : uint256(value); + } + + // --------------------------------------------------------------------- + // Token transfer helper + // --------------------------------------------------------------------- + + function _transferToken(address token, address to, uint256 amount) internal { + if (amount == 0) return; + + if (token == address(0)) { + (bool ok,) = to.call{ value: amount }(""); + require(ok, "ETH_TRANSFER_FAILED"); + } else { + Currency.wrap(token).transfer(to, amount); + } + } +} diff --git a/src/interfaces/IDERC20.sol b/src/interfaces/IDERC20.sol new file mode 100644 index 00000000..dbb97aff --- /dev/null +++ b/src/interfaces/IDERC20.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +/// @notice Minimal interface for Doppler's DERC20 vesting token. +interface IDERC20 { + function vestingDuration() external view returns (uint256); + function release() external; +} diff --git a/src/libraries/V4QuoteMath.sol b/src/libraries/V4QuoteMath.sol new file mode 100644 index 00000000..5b94f562 --- /dev/null +++ b/src/libraries/V4QuoteMath.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { TickMath } from "@v4-core/libraries/TickMath.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; +import { Quoter } from "@quoter/Quoter.sol"; + +/// @notice Quote + inversion helpers for Uniswap v4 concentrated liquidity. +/// @dev This is intentionally a "source of truth" for: +/// - Normalizing `Quoter.quoteSingle` signed amounts into (amountInUsed, amountOut) +/// - Finding an amountIn that yields amountOut <= budget (output-budgeted execution) +/// +/// Why inversion is hard: +/// - With concentrated liquidity, amountOut(amountIn) is monotonic but not linear. +/// - Closed-form inversion is not practical on-chain; the safest approach is quoting + search. +library V4QuoteMath { + uint256 internal constant DEFAULT_MAX_ITERATIONS = 15; + + function quoteExactIn(Quoter quoter, PoolKey memory key, bool zeroForOne, uint256 amountIn) + internal + view + returns (bool ok, uint256 amountOut, uint256 amountInUsed) + { + if (amountIn == 0) return (false, 0, 0); + + // We intentionally do not wrap this in try/catch. + // In this codebase, `@quoter/Quoter.sol` maps to the view-quoter implementation + // (lib/view-quoter-v4) which is expected to return zero values on non-quotable swaps + // rather than reverting. + (int256 amount0, int256 amount1,,) = quoter.quoteSingle( + key, + IPoolManager.SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: -int256(amountIn), + sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }) + ); + + if (zeroForOne) { + if (amount0 >= 0 || amount1 <= 0) return (false, 0, 0); + amountInUsed = uint256(-amount0); + amountOut = uint256(amount1); + } else { + if (amount1 >= 0 || amount0 <= 0) return (false, 0, 0); + amountInUsed = uint256(-amount1); + amountOut = uint256(amount0); + } + + if (amountInUsed == 0 || amountOut == 0 || amountInUsed > amountIn) return (false, 0, 0); + ok = true; + } + + /// @notice Find the largest `amountIn` such that the quoted output is <= `outBudget`. + /// @dev Bounded binary search. This is the robust way to handle concentrated liquidity slippage. + function findAmountInForOutBudget( + Quoter quoter, + PoolKey memory key, + bool zeroForOne, + uint256 outBudget, + uint256 maxAmountIn, + uint256 maxIterations + ) internal view returns (uint256 amountIn, uint256 expectedOut) { + if (outBudget == 0 || maxAmountIn == 0) return (0, 0); + if (maxIterations == 0) maxIterations = DEFAULT_MAX_ITERATIONS; + + uint256 low; + uint256 high = maxAmountIn; + + for (uint256 i; i < maxIterations && high > 0; i++) { + uint256 guess = (low + high) / 2; + if (guess == 0) guess = 1; + + (bool ok, uint256 out,) = quoteExactIn(quoter, key, zeroForOne, guess); + if (!ok || out == 0) { + if (high <= 1) break; + high = guess > 0 ? guess - 1 : 0; + continue; + } + + if (out > outBudget) { + if (guess <= 1) break; + high = guess - 1; + continue; + } + + // out <= budget is feasible + amountIn = guess; + expectedOut = out; + if (out == outBudget) break; + if (low == guess) { + if (high <= guess + 1) break; + } else { + low = guess; + } + } + } +} diff --git a/src/twap/TwapVault.sol b/src/twap/TwapVault.sol new file mode 100644 index 00000000..01a2d57d --- /dev/null +++ b/src/twap/TwapVault.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import { Ownable } from "@openzeppelin/access/Ownable.sol"; +import { SafeTransferLib } from "@solady/utils/SafeTransferLib.sol"; +import { PoolId } from "@v4-core/types/PoolId.sol"; + + +error SenderNotExecutor(); +error SenderNotAuthorized(); +error PoolNotRegistered(); +error PoolRegistrationMismatch(); +error InsufficientInventory(); +error InvalidDeposit(); + +/** + * @title TwapVault + * @notice Middleware vault sitting between token supply (manual deposits + pull adapters) + * and the TWAP swap executor. + * + * Design goals: + * - Hold custody of both asset inventory and numeraire proceeds. + * - Support "releaseable" tokens AND normal ERC20 / native ETH. + * - Maintain accurate per-pool accounting via `inventory[poolId][token]`. + * - No permissionless pull entrypoint; inventory must be deposited explicitly. + * + * Notes: + * - Proceeds remain vaulted. buybackDst may withdraw later. + * - TWAP execution is swap-driven via the Doppler hook; the vault never triggers swaps. + */ +contract TwapVault is Ownable { + using SafeTransferLib for address; + + /// @notice TWAP executor (hook) that is allowed to debit/credit during unlockCallback. + address public executor; + + struct PoolInfo { + address asset; + address numeraire; + address buybackDst; + } + + /// @notice Per-pool static info. + mapping(PoolId poolId => PoolInfo info) public poolInfo; + + /// @notice Resolves poolId by asset address (since Doppler pools are identified by asset). + mapping(address asset => PoolId poolId) public poolIdOfAsset; + + /// @notice Per-pool inventory accounting (token -> amount reserved for that pool). + mapping(PoolId poolId => mapping(address token => uint256 amount)) public inventory; + + event ExecutorSet(address indexed executor); + event PoolRegistered(PoolId indexed poolId, address indexed asset, address indexed numeraire, address buybackDst); + + event InventoryDeposited(PoolId indexed poolId, address indexed token, address indexed from, uint256 amount); + event InventoryWithdrawn(PoolId indexed poolId, address indexed token, address indexed to, uint256 amount); + + receive() external payable { } + + constructor(address owner_) Ownable(owner_) { } + + // --------------------------------------------------------------------- + // Admin wiring + // --------------------------------------------------------------------- + + function setExecutor(address executor_) external onlyOwner { + executor = executor_; + emit ExecutorSet(executor_); + } + + modifier onlyExecutor() { + if (msg.sender != executor) revert SenderNotExecutor(); + _; + } + + function _requireRegistered(PoolId poolId) internal view { + if (poolInfo[poolId].asset == address(0)) revert PoolNotRegistered(); + } + + function _onlyBuybackDst(PoolId poolId) internal view { + if (msg.sender != poolInfo[poolId].buybackDst) revert SenderNotAuthorized(); + } + + // --------------------------------------------------------------------- + // Pool registration (called by the executor hook during onInitialization) + // --------------------------------------------------------------------- + + function registerPool(PoolId poolId, address asset, address numeraire, address buybackDst) external onlyExecutor { + PoolInfo storage info = poolInfo[poolId]; + + // First registration. + if (info.asset == address(0)) { + info.asset = asset; + info.numeraire = numeraire; + info.buybackDst = buybackDst; + poolIdOfAsset[asset] = poolId; + emit PoolRegistered(poolId, asset, numeraire, buybackDst); + return; + } + + // Idempotent registration (exact match). + if (info.asset != asset || info.numeraire != numeraire || info.buybackDst != buybackDst) { + revert PoolRegistrationMismatch(); + } + } + + // --------------------------------------------------------------------- + // Deposits / withdrawals (authority only; inventory must always be accurate) + // --------------------------------------------------------------------- + + function deposit(PoolId poolId, address token, uint256 amount) external payable { + _deposit(poolId, token, amount); + } + + /// @notice Convenience overload keyed by `asset`. + function deposit(address asset, address token, uint256 amount) external payable { + PoolId poolId = poolIdOfAsset[asset]; + if (PoolId.unwrap(poolId) == bytes32(0)) revert PoolNotRegistered(); + _deposit(poolId, token, amount); + } + + function _deposit(PoolId poolId, address token, uint256 amount) internal { + _requireRegistered(poolId); + _onlyBuybackDst(poolId); + if (amount == 0) return; + + if (token == address(0)) { + // Native ETH deposit. + if (msg.value != amount) revert InvalidDeposit(); + } else { + if (msg.value != 0) revert InvalidDeposit(); + token.safeTransferFrom(msg.sender, address(this), amount); + } + + inventory[poolId][token] += amount; + emit InventoryDeposited(poolId, token, msg.sender, amount); + } + + function withdraw(PoolId poolId, address token, uint256 amount, address to) external { + _withdraw(poolId, token, amount, to); + } + + /// @notice Convenience overload keyed by `asset`. + function withdraw(address asset, address token, uint256 amount, address to) external { + PoolId poolId = poolIdOfAsset[asset]; + if (PoolId.unwrap(poolId) == bytes32(0)) revert PoolNotRegistered(); + _withdraw(poolId, token, amount, to); + } + + function _withdraw(PoolId poolId, address token, uint256 amount, address to) internal { + _requireRegistered(poolId); + _onlyBuybackDst(poolId); + if (amount == 0) return; + + uint256 inv = inventory[poolId][token]; + if (inv < amount) revert InsufficientInventory(); + inventory[poolId][token] = inv - amount; + + if (token == address(0)) { + SafeTransferLib.safeTransferETH(to, amount); + } else { + token.safeTransfer(to, amount); + } + + emit InventoryWithdrawn(poolId, token, to, amount); + } + + // --------------------------------------------------------------------- + // Executor-only accounting hooks (called inside PoolManager.unlockCallback) + // --------------------------------------------------------------------- + + function debitToExecutor(PoolId poolId, address token, uint256 amount, address to) external onlyExecutor { + if (amount == 0) return; + + uint256 inv = inventory[poolId][token]; + if (inv < amount) revert InsufficientInventory(); + inventory[poolId][token] = inv - amount; + + if (token == address(0)) { + SafeTransferLib.safeTransferETH(to, amount); + } else { + token.safeTransfer(to, amount); + } + } + + function creditFromExecutor(PoolId poolId, address token, uint256 amount) external onlyExecutor { + if (amount == 0) return; + inventory[poolId][token] += amount; + } + + // --------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------- + + function _balanceOf(address token) internal view returns (uint256) { + if (token == address(0)) return address(this).balance; + return SafeTransferLib.balanceOf(token, address(this)); + } +} diff --git a/test/integration/TwapSellExecutorHook.t.sol b/test/integration/TwapSellExecutorHook.t.sol new file mode 100644 index 00000000..b445aaa6 --- /dev/null +++ b/test/integration/TwapSellExecutorHook.t.sol @@ -0,0 +1,316 @@ +pragma solidity ^0.8.13; + +import { Deployers } from "@uniswap/v4-core/test/utils/Deployers.sol"; +import { IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { Hooks } from "@v4-core/libraries/Hooks.sol"; +import { TickMath } from "@v4-core/libraries/TickMath.sol"; +import { PoolSwapTest } from "@v4-core/test/PoolSwapTest.sol"; +import { TestERC20 } from "@v4-core/test/TestERC20.sol"; +import { Currency, greaterThan } from "@v4-core/types/Currency.sol"; +import { PoolId } from "@v4-core/types/PoolId.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; + +import { Airlock, CreateParams, ModuleState } from "src/Airlock.sol"; +import { ON_INITIALIZATION_FLAG, ON_SWAP_FLAG } from "src/base/BaseDopplerHook.sol"; +import { DopplerHookInitializer, InitData } from "src/initializers/DopplerHookInitializer.sol"; +import { GovernanceFactory } from "src/governance/GovernanceFactory.sol"; +import { ITokenFactory } from "src/interfaces/ITokenFactory.sol"; +import { IGovernanceFactory } from "src/interfaces/IGovernanceFactory.sol"; +import { ILiquidityMigrator } from "src/interfaces/ILiquidityMigrator.sol"; +import { IPoolInitializer } from "src/interfaces/IPoolInitializer.sol"; +import { Curve } from "src/libraries/Multicurve.sol"; +import { DERC20 } from "src/tokens/DERC20.sol"; +import { TokenFactory } from "src/tokens/TokenFactory.sol"; +import { BeneficiaryData } from "src/types/BeneficiaryData.sol"; +import { WAD } from "src/types/Wad.sol"; + +import { TwapSellExecutorHook, ITwapVault } from "src/dopplerHooks/TwapSellExecutorHook.sol"; +import { TwapVault } from "src/twap/TwapVault.sol"; + +contract LiquidityMigratorMock is ILiquidityMigrator { + function initialize(address, address, bytes memory) external pure override returns (address) { + return address(0xdeadbeef); + } + + function migrate(uint160, address, address, address) external payable override returns (uint256) { + return 0; + } +} + +contract TwapSellExecutorHookIntegrationTest is Deployers { + address public airlockOwner = makeAddr("AirlockOwner"); + address public buybackDst; + + Airlock public airlock; + DopplerHookInitializer public initializer; + TokenFactory public tokenFactory; + GovernanceFactory public governanceFactory; + LiquidityMigratorMock public mockLiquidityMigrator; + TestERC20 public numeraire; + + TwapVault public vault; + TwapSellExecutorHook public twapHook; + + PoolKey public poolKey; + PoolId public poolId; + + function setUp() public { + deployFreshManagerAndRouters(); + buybackDst = address(this); + numeraire = new TestERC20(1e48); + + airlock = new Airlock(airlockOwner); + tokenFactory = new TokenFactory(address(airlock)); + governanceFactory = new GovernanceFactory(address(airlock)); + + initializer = DopplerHookInitializer( + payable(address( + uint160( + Hooks.BEFORE_INITIALIZE_FLAG | Hooks.AFTER_ADD_LIQUIDITY_FLAG | Hooks.AFTER_REMOVE_LIQUIDITY_FLAG + | Hooks.AFTER_SWAP_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG + ) ^ (0x4444 << 144) + )) + ); + deployCodeTo("DopplerHookInitializer", abi.encode(address(airlock), address(manager)), address(initializer)); + + vault = new TwapVault(airlockOwner); + twapHook = new TwapSellExecutorHook(address(initializer), manager, ITwapVault(address(vault))); + + vm.startPrank(airlockOwner); + vault.setExecutor(address(twapHook)); + + mockLiquidityMigrator = new LiquidityMigratorMock(); + + address[] memory modules = new address[](4); + modules[0] = address(tokenFactory); + modules[1] = address(governanceFactory); + modules[2] = address(initializer); + modules[3] = address(mockLiquidityMigrator); + + ModuleState[] memory states = new ModuleState[](4); + states[0] = ModuleState.TokenFactory; + states[1] = ModuleState.GovernanceFactory; + states[2] = ModuleState.PoolInitializer; + states[3] = ModuleState.LiquidityMigrator; + + airlock.setModuleState(modules, states); + + address[] memory dopplerHooks = new address[](1); + dopplerHooks[0] = address(twapHook); + uint256[] memory flags = new uint256[](1); + flags[0] = ON_INITIALIZATION_FLAG | ON_SWAP_FLAG; + initializer.setDopplerHookState(dopplerHooks, flags); + vm.stopPrank(); + } + + function test_twap_AccruesWhenNoInventory_ThenSellsAfterDeposit() public { + bytes32 salt = bytes32(uint256(101)); + (bool isToken0, address asset) = _createToken(salt); + + // Warp forward so the schedule accrues budget. + vm.warp(block.timestamp + 1 hours); + + // Swap once with zero vault inventory: should accrue accumulator but not sell. + _swapNumeraireToAsset(isToken0, 1 ether); + + (uint256 acc1,) = twapHook.getTwapSellState(poolId); + assertGt(acc1, 0, "accumulator should accrue even when inventory is zero"); + + // Deposit inventory after accrual (use assets bought from the swap above). + _depositAssetToVault(asset); + + // Next block: TWAP should sell some inventory and reduce accumulator. + vm.roll(block.number + 1); + + uint256 invAssetBefore = vault.inventory(poolId, asset); + uint256 invNumBefore = vault.inventory(poolId, address(numeraire)); + + _swapNumeraireToAsset(isToken0, 0.5 ether); + + uint256 invAssetAfter = vault.inventory(poolId, asset); + uint256 invNumAfter = vault.inventory(poolId, address(numeraire)); + + assertLt(invAssetAfter, invAssetBefore, "asset inventory should decrease after TWAP sell"); + assertGt(invNumAfter, invNumBefore, "numeraire inventory should increase after TWAP sell"); + + (uint256 acc2,) = twapHook.getTwapSellState(poolId); + assertLt(acc2, acc1, "accumulator should be consumed by selling"); + } + + function test_twap_ExecutesAtMostOncePerBlock() public { + bytes32 salt = bytes32(uint256(102)); + (bool isToken0, address asset) = _createToken(salt); + + // Accrue budget and acquire some asset to deposit. + vm.warp(block.timestamp + 1 hours); + _swapNumeraireToAsset(isToken0, 1 ether); + vm.roll(block.number + 1); + _depositAssetToVault(asset); + + // First swap triggers TWAP. + _swapNumeraireToAsset(isToken0, 0.5 ether); + uint256 invAfterFirst = vault.inventory(poolId, asset); + + // Second swap in the same block should not execute TWAP again. + _swapNumeraireToAsset(isToken0, 0.5 ether); + uint256 invAfterSecond = vault.inventory(poolId, asset); + + assertEq(invAfterSecond, invAfterFirst, "TWAP should not execute twice in the same block"); + } + + function test_fullFlow_UserBuyTriggersTwapSell() public { + bytes32 salt = bytes32(uint256(103)); + (bool isToken0, address asset) = _createToken(salt); + + // Accrue budget. + vm.warp(block.timestamp + 2 hours); + + // Acquire asset so vault can TWAP-sell. + _swapNumeraireToAsset(isToken0, 2 ether); + vm.roll(block.number + 1); + _depositAssetToVault(asset); + + uint256 invAssetBefore = vault.inventory(poolId, asset); + uint256 invNumBefore = vault.inventory(poolId, address(numeraire)); + + // A user "buy" (numeraire -> asset) should trigger the hook, + // and the hook should TWAP-sell vault inventory asset -> numeraire. + vm.roll(block.number + 1); + _swapNumeraireToAsset(isToken0, 0.25 ether); + + uint256 invAssetAfter = vault.inventory(poolId, asset); + uint256 invNumAfter = vault.inventory(poolId, address(numeraire)); + + assertLt(invAssetAfter, invAssetBefore, "TWAP sell should reduce asset inventory"); + assertGt(invNumAfter, invNumBefore, "TWAP sell should increase numeraire inventory"); + + // TODO: Add an invariant-style test asserting `vault.inventory(poolId, token) <= token.balanceOf(address(vault))` + // for both tokens after TWAP execution (accounting should never exceed actual balances). + } + + function _swapNumeraireToAsset(bool isToken0, uint256 amountInNumeraire) internal { + // numeraire -> asset == !isToken0 + IPoolManager.SwapParams memory swapParams = IPoolManager.SwapParams({ + zeroForOne: !isToken0, + amountSpecified: -int256(amountInNumeraire), + sqrtPriceLimitX96: !isToken0 ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }); + swapRouter.swap(poolKey, swapParams, PoolSwapTest.TestSettings(false, false), new bytes(0)); + } + + function _depositAssetToVault(address asset) internal { + uint256 bal = TestERC20(asset).balanceOf(buybackDst); + assertGt(bal, 0, "need asset balance to deposit"); + + uint256 amount = bal / 2; + if (amount == 0) amount = bal; + + TestERC20(asset).approve(address(vault), amount); + vault.deposit(poolId, asset, amount); + } + + function _prepareInitData(address token) internal returns (InitData memory) { + Curve[] memory curves = new Curve[](10); + int24 tickSpacing = 8; + + for (uint256 i; i < 10; ++i) { + curves[i].tickLower = int24(uint24(0 + i * 16_000)); + curves[i].tickUpper = 240_000; + curves[i].numPositions = 10; + curves[i].shares = WAD / 10; + } + + Currency currency0 = Currency.wrap(address(numeraire)); + Currency currency1 = Currency.wrap(address(token)); + (currency0, currency1) = greaterThan(currency0, currency1) ? (currency1, currency0) : (currency0, currency1); + + poolKey = PoolKey({ currency0: currency0, currency1: currency1, tickSpacing: tickSpacing, fee: 0, hooks: initializer }); + poolId = poolKey.toId(); + + BeneficiaryData[] memory beneficiaries = new BeneficiaryData[](2); + beneficiaries[0] = BeneficiaryData({ beneficiary: address(0x07), shares: uint96(0.95e18) }); + beneficiaries[1] = BeneficiaryData({ beneficiary: airlockOwner, shares: uint96(0.05e18) }); + + uint32 startTs = uint32(block.timestamp); + uint32 endTs = startTs + 2 days; + uint256 rateValuePerSec = 1e12; + uint256 maxValuePerExecute = 0; + uint256 maxAccumulatorValue = 1e24; + + bytes memory twapData = abi.encode( + address(numeraire), + buybackDst, + startTs, + endTs, + rateValuePerSec, + maxValuePerExecute, + maxAccumulatorValue + ); + + return InitData({ + fee: 0, + tickSpacing: tickSpacing, + farTick: 200_000, + curves: curves, + beneficiaries: beneficiaries, + dopplerHook: address(twapHook), + onInitializationDopplerHookCalldata: twapData, + graduationDopplerHookCalldata: new bytes(0) + }); + } + + function _createToken(bytes32 salt) internal returns (bool isToken0, address asset) { + string memory name = "Test Token"; + string memory symbol = "TEST"; + uint256 initialSupply = 1e27; + + address tokenAddress = vm.computeCreate2Address( + salt, + keccak256( + abi.encodePacked( + type(DERC20).creationCode, + abi.encode( + name, + symbol, + initialSupply, + address(airlock), + address(airlock), + 0, + 0, + new address[](0), + new uint256[](0), + "TOKEN_URI" + ) + ) + ), + address(tokenFactory) + ); + + InitData memory initData = _prepareInitData(tokenAddress); + + CreateParams memory params = CreateParams({ + initialSupply: initialSupply, + numTokensToSell: initialSupply, + numeraire: address(numeraire), + tokenFactory: ITokenFactory(tokenFactory), + tokenFactoryData: abi.encode(name, symbol, 0, 0, new address[](0), new uint256[](0), "TOKEN_URI"), + governanceFactory: IGovernanceFactory(governanceFactory), + governanceFactoryData: abi.encode("Test Token", 7200, 50_400, 0), + poolInitializer: IPoolInitializer(initializer), + poolInitializerData: abi.encode(initData), + liquidityMigrator: ILiquidityMigrator(mockLiquidityMigrator), + liquidityMigratorData: new bytes(0), + integrator: address(0), + salt: salt + }); + + (asset,,,,) = airlock.create(params); + isToken0 = asset < address(numeraire); + + (,,,,, poolKey,) = initializer.getState(asset); + poolId = poolKey.toId(); + + numeraire.approve(address(swapRouter), type(uint256).max); + TestERC20(asset).approve(address(swapRouter), type(uint256).max); + } +} diff --git a/test/unit/dopplerHooks/TwapSellExecutorHook.t.sol b/test/unit/dopplerHooks/TwapSellExecutorHook.t.sol new file mode 100644 index 00000000..99c70207 --- /dev/null +++ b/test/unit/dopplerHooks/TwapSellExecutorHook.t.sol @@ -0,0 +1,89 @@ +pragma solidity ^0.8.13; + +import { Test } from "forge-std/Test.sol"; +import { TestERC20 } from "@v4-core/test/TestERC20.sol"; +import { IHooks } from "@v4-core/interfaces/IHooks.sol"; +import { IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { Currency, greaterThan } from "@v4-core/types/Currency.sol"; +import { PoolId, PoolIdLibrary } from "@v4-core/types/PoolId.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; + +import { TwapSellExecutorHook, ITwapVault, InvalidTwapSchedule } from "src/dopplerHooks/TwapSellExecutorHook.sol"; +import { TwapVault } from "src/twap/TwapVault.sol"; + +using PoolIdLibrary for PoolKey; + +contract TwapSellExecutorHookUnitTest is Test { + address initializer = makeAddr("initializer"); + address airlockOwner = makeAddr("airlockOwner"); + address buybackDst = makeAddr("buybackDst"); + + TestERC20 numeraire; + TestERC20 asset; + + TwapVault vault; + TwapSellExecutorHook hook; + + function setUp() public { + numeraire = new TestERC20(1e48); + asset = new TestERC20(1e48); + + vault = new TwapVault(airlockOwner); + hook = new TwapSellExecutorHook(initializer, IPoolManager(address(0x1111)), ITwapVault(address(vault))); + + vm.prank(airlockOwner); + vault.setExecutor(address(hook)); + } + + function test_onInitialization_StoresScheduleAndRegistersPool() public { + PoolKey memory key = _poolKey(); + PoolId poolId = key.toId(); + + uint32 startTs = uint32(block.timestamp); + uint32 endTs = startTs + 1 days; + + bytes memory data = abi.encode( + address(numeraire), + buybackDst, + startTs, + endTs, + uint256(123), + uint256(456), + uint256(789) + ); + + vm.prank(initializer); + hook.onInitialization(address(asset), key, data); + + (uint32 storedStart, uint32 storedEnd, uint256 rate, uint256 maxPerExec, uint256 maxAcc) = hook.getTwapSellSchedule(poolId); + assertEq(storedStart, startTs); + assertEq(storedEnd, endTs); + assertEq(rate, 123); + assertEq(maxPerExec, 456); + assertEq(maxAcc, 789); + + (address storedAsset, address storedNum, address storedDst) = vault.poolInfo(poolId); + assertEq(storedAsset, address(asset)); + assertEq(storedNum, address(numeraire)); + assertEq(storedDst, buybackDst); + } + + function test_onInitialization_RevertsOnInvalidSchedule() public { + PoolKey memory key = _poolKey(); + uint32 startTs = uint32(block.timestamp); + uint32 endTs = startTs; + + bytes memory data = abi.encode(address(numeraire), buybackDst, startTs, endTs, uint256(1), uint256(0), uint256(0)); + + vm.prank(initializer); + vm.expectRevert(InvalidTwapSchedule.selector); + hook.onInitialization(address(asset), key, data); + } + + function _poolKey() internal view returns (PoolKey memory key) { + Currency currency0 = Currency.wrap(address(numeraire)); + Currency currency1 = Currency.wrap(address(asset)); + (currency0, currency1) = greaterThan(currency0, currency1) ? (currency1, currency0) : (currency0, currency1); + key = PoolKey({ currency0: currency0, currency1: currency1, fee: 0, tickSpacing: 1, hooks: IHooks(initializer) }); + } +} diff --git a/test/unit/twap/TwapVault.t.sol b/test/unit/twap/TwapVault.t.sol new file mode 100644 index 00000000..be9b70da --- /dev/null +++ b/test/unit/twap/TwapVault.t.sol @@ -0,0 +1,97 @@ +pragma solidity ^0.8.13; + +import { Test } from "forge-std/Test.sol"; +import { TestERC20 } from "@v4-core/test/TestERC20.sol"; +import { PoolId } from "@v4-core/types/PoolId.sol"; + +import { + TwapVault, + PoolRegistrationMismatch, + SenderNotAuthorized, + InsufficientInventory, + SenderNotExecutor +} from "src/twap/TwapVault.sol"; + +contract TwapVaultUnitTest is Test { + TwapVault vault; + TestERC20 token; + + address owner = makeAddr("owner"); + address executor = makeAddr("executor"); + address buybackDst = makeAddr("buybackDst"); + address other = makeAddr("other"); + + PoolId poolId = PoolId.wrap(bytes32(uint256(1))); + + function setUp() public { + vault = new TwapVault(owner); + token = new TestERC20(1e48); + + vm.prank(owner); + vault.setExecutor(executor); + + vm.prank(executor); + vault.registerPool(poolId, address(token), address(0xBEEF), buybackDst); + + token.transfer(buybackDst, 1e24); + } + + function test_registerPool_Idempotent() public { + vm.prank(executor); + vault.registerPool(poolId, address(token), address(0xBEEF), buybackDst); + } + + function test_registerPool_RevertsOnMismatch() public { + vm.prank(executor); + vm.expectRevert(PoolRegistrationMismatch.selector); + vault.registerPool(poolId, address(token), address(0xCAFE), buybackDst); + } + + function test_deposit_IncrementsInventory() public { + uint256 amount = 123e18; + + vm.startPrank(buybackDst); + token.approve(address(vault), amount); + vault.deposit(poolId, address(token), amount); + vm.stopPrank(); + + assertEq(vault.inventory(poolId, address(token)), amount); + assertEq(token.balanceOf(address(vault)), amount); + } + + function test_deposit_RevertsWhenNotBuybackDst() public { + uint256 amount = 1e18; + + vm.startPrank(other); + token.approve(address(vault), amount); + vm.expectRevert(SenderNotAuthorized.selector); + vault.deposit(poolId, address(token), amount); + vm.stopPrank(); + } + + function test_withdraw_DecrementsInventory() public { + uint256 amount = 10e18; + + vm.startPrank(buybackDst); + token.approve(address(vault), amount); + vault.deposit(poolId, address(token), amount); + + vault.withdraw(poolId, address(token), 4e18, other); + vm.stopPrank(); + + assertEq(vault.inventory(poolId, address(token)), 6e18); + assertEq(token.balanceOf(other), 4e18); + } + + function test_withdraw_RevertsWhenInsufficientInventory() public { + vm.prank(buybackDst); + vm.expectRevert(InsufficientInventory.selector); + vault.withdraw(poolId, address(token), 1, other); + } + + function test_debitToExecutor_OnlyExecutor() public { + vm.prank(other); + vm.expectRevert(SenderNotExecutor.selector); + vault.debitToExecutor(poolId, address(token), 1, other); + } +}