From 981112342d9f6517e6c55769573daf8c8e5280f6 Mon Sep 17 00:00:00 2001 From: Zodomo Date: Thu, 13 Aug 2026 11:49:57 -0500 Subject: [PATCH 1/2] feat: support dev buys via rehype --- .github/workflows/test.yml | 30 +- docs/Bundler.md | 117 +++ docs/RehypeDopplerHookInitializer.md | 14 +- legacy/src/Bundler.sol | 197 +++++ .../test/unit/bundler}/Bundler.t.sol | 0 .../unit/bundler}/BundlerMulticurve.t.sol | 0 script/DeployBase.s.sol | 3 +- script/DeployDoppler.s.sol | 6 +- script/deploy/DeployBundler.s.sol | 21 +- .../DeployRehypeDopplerHookInitializer.s.sol | 18 +- script/utils/Versions.sol | 4 +- ...izerNoOpGovernanceFactoryNoOpMigrator.json | 2 +- snapshots/GasBenchmark.json | 66 +- snapshots/Multicurve.json | 2 +- src/Bundler.sol | 531 ++++++++---- .../RehypeDopplerHookInitializer.sol | 99 ++- .../RehypeDopplerHookMigrator.sol | 4 +- src/types/RehypeTypes.sol | 3 + test/integration/Bundler.t.sol | 769 ++++++++++++++++++ .../BundlerUnsupportedInitializers.t.sol | 299 +++++++ test/integration/BundlerVesting.t.sol | 443 ++++++++++ .../DopplerHookMigratorIntegration.t.sol | 2 +- .../RehypeDopplerHookInitializer.t.sol | 2 +- test/invariant/RehypeHandler.sol | 10 +- .../RehypeDopplerHookHarness.sol | 2 +- .../RehypeDopplerHookInitializer.t.sol | 29 +- 26 files changed, 2451 insertions(+), 222 deletions(-) create mode 100644 docs/Bundler.md create mode 100644 legacy/src/Bundler.sol rename {test/unit => legacy/test/unit/bundler}/Bundler.t.sol (100%) rename {test/unit => legacy/test/unit/bundler}/BundlerMulticurve.t.sol (100%) create mode 100644 test/integration/Bundler.t.sol create mode 100644 test/integration/BundlerUnsupportedInitializers.t.sol create mode 100644 test/integration/BundlerVesting.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 393fc07ea..977909fd2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,9 +10,8 @@ on: - synchronize env: - FOUNDRY_PROFILE: ci ETH_MAINNET_RPC_URL: ${{ secrets.ETH_MAINNET_RPC_URL }} - BASE_SEPOLIA_RPC_URL: https://sepolia.base.org + BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL || 'https://sepolia.base.org' }} UNICHAIN_SEPOLIA_RPC_URL: ${{ secrets.UNICHAIN_SEPOLIA_RPC_URL }} UNICHAIN_MAINNET_RPC_URL: ${{ secrets.UNICHAIN_MAINNET_RPC_URL }} @@ -50,6 +49,27 @@ jobs: run: forge build ./script --via-ir id: build-script - - name: Run Forge tests - run: forge test -vvv --via-ir --match-contract "DN404FactoryTest|DopplerDN404Test|BaseSepoliaDN404ForkTest" - id: test + - name: Run core protocol tests + run: >- + forge test -vvv --via-ir + --match-contract + "^(AirlockTest|DopplerCreateXDeployerTest|DopplerERC20V1FactoryTest|DopplerERC20V1Test|DopplerERC20V1MaxBalanceIntegrationTest|DN404FactoryTest|DopplerDN404Test|TopUpDistributorTest|TopUpDistributorInvariantTest|ProceedsSplitterTest|StreamableFeesLockerV2Test)$" + id: test-core + + - name: Run primary initializer and launch tests + run: >- + forge test -vvv --via-ir + --match-contract + "^(BaseDopplerHookTest|MiniV4ManagerTest|DopplerHookMulticurveInitializerTest|DopplerHookInitializerTest|LockableUniswapV3InitializerTest|UniswapV4InitializerTest|BundlerIntegrationTest|BundlerVestingIntegrationTest|BundlerUnsupportedInitializersIntegrationTest|SwapRestrictorDopplerHookTest|DopplerLensTest)$" + id: test-launch + + - name: Run Rehype and migrator tests + run: >- + forge test -vvv --via-ir + --match-contract + "^(BeneficiaryDataTest|FeesManagerTest|FeesManagerInvariants|CalculateExcessTest|RebalanceFeesTest|RehypeDopplerHookInitializerTest|RehypeDopplerHookIntegrationTest|RehyperInvariantTests|CalculateExcessMigratorTest|RebalanceFeesMigratorTest|RehypeDopplerHookMigratorTest|RehypeDopplerHookMigratorIntegrationTest|RehypeMigratorInvariantTests|DopplerHookMigratorTest|DopplerHookMigratorIntegrationTest)$" + id: test-rehype + + - name: Run Base Sepolia production-path fork test + run: forge test -vvv --via-ir --match-contract "^BaseSepoliaDN404ForkTest$" + id: test-base-sepolia diff --git a/docs/Bundler.md b/docs/Bundler.md new file mode 100644 index 000000000..2ce73d1a4 --- /dev/null +++ b/docs/Bundler.md @@ -0,0 +1,117 @@ +# Bundler + +## Overview + +`Bundler` atomically creates a Doppler multicurve market through `Airlock`, buys the newly created asset with an exact amount of its numeraire, and optionally vests the purchased asset for a recipient. The create and buy execute in one transaction: if initialization, the swap, settlement, or vesting setup fails, the complete launch reverts. + +The current Bundler supports pools created by `DopplerHookInitializer`, both with and without `RehypeDopplerHookInitializer`. `LockableUniswapV3Initializer` and `UniswapV4Initializer` use different state and execution interfaces and are not supported by this Bundler version. Attempting to bundle those initializer types reverts the entire launch. + +## Dependencies + +The constructor binds two immutable dependencies: + +- `airlock`: creates the asset, governance, timelock, and pool +- `poolManager`: executes and settles the Uniswap v4 purchase + +A `RehypeDopplerHookInitializer` that enables atomic dev buys is separately deployed with this Bundler's address as its immutable authorized `bundler`. The deployment scripts deploy Bundler before Rehype and verify that both contracts reference the expected Airlock, PoolManager, and initializer. + +## Creating and Buying + +Call: + +```solidity +bundle( + CreateParams createData, + VestingParams vestingData, + uint128 exactAmountIn, + address recipient +) +``` + +The function returns: + +- `asset`: created asset address +- `poolKey`: created Uniswap v4 pool key +- `governance`: created governance address +- `timelock`: created timelock address +- `amountOut`: net amount of the created asset purchased + +`exactAmountIn` must be non-zero and must be fully consumed by the pool. A swap that reaches its price limit or exhausts available liquidity before spending the full input reverts the entire launch. + +The initialized pool must contain exactly the created asset and `createData.numeraire`. Bundler derives the purchase direction from the currencies' canonical ordering; it does not assume that the asset is always `currency0` or `currency1`. + +### Funding + +For an ERC20 numeraire: + +- `msg.value` must be zero +- the caller must own at least `exactAmountIn` +- the caller must approve Bundler to transfer `exactAmountIn` + +For native ETH: + +- `createData.numeraire` is `address(0)` +- `msg.value` must equal `exactAmountIn` exactly + +Bundler settles the input directly with PoolManager and does not retain successful swap input. The purchased asset is transferred either to `recipient` or to Bundler custody when vesting is enabled. + +## Rehype Dev Buy + +When the created pool uses the authorized `RehypeDopplerHookInitializer`, its initialization opens a pool-specific transient exemption for the Bundler's first swap. That swap pays only the normal Airlock-owner cut of the otherwise assessed Rehype fee. The remaining beneficiary, buyback, and LP-reinvestment portions are not collected for the dev buy. + +The exemption is valid for one Bundler swap in the same transaction as `Airlock.create`. It cannot be consumed by a direct creator or ordinary swap router, and it disappears when the transaction completes. Every later swap uses the pool's ordinary Rehype fee schedule and routing configuration. + +Pools created through `DopplerHookInitializer` without Rehype still support the atomic create-and-buy flow but receive no Rehype exemption because they do not charge a Rehype fee. + +## Optional Vesting + +`VestingParams` contains: + +- `permissionlessClaim`: whether any address may trigger a claim for the recipient +- `vestingDuration`: seconds from creation until the full purchase is vested +- `cliffDuration`: seconds from creation before any vested amount is claimable + +A zero `vestingDuration` disables Bundler vesting and sends `amountOut` directly to `recipient`. Otherwise: + +- `cliffDuration` must not exceed `vestingDuration` +- Bundler holds exactly `amountOut` +- vesting begins at the successful bundle timestamp +- no tokens are claimable before the cliff +- after the cliff, cumulative vesting is linear from the start timestamp +- at `start + vestingDuration`, the entire remaining amount is claimable + +The cumulative vested amount before completion is: + +```text +floor(totalAmount * (block.timestamp - start) / vestingDuration) +``` + +Claims always transfer to the stored recipient. With `permissionlessClaim = true`, another address may trigger delivery but cannot redirect it. With `permissionlessClaim = false`, only the recipient may call `claim`. + +If the created token has an active recipient balance limit, include Bundler among the token factory's balance-limit exclusions when its expected custody balance may exceed that limit. Transfers from Bundler to the final recipient remain subject to the token's configured recipient limit. + +### Vesting Views and Claims + +- `vestingOf(asset)` returns the stored recipient, permissions, schedule, total amount, and claimed amount +- `claimable(asset)` returns the amount currently available +- `claim(asset)` transfers all currently claimable tokens to the stored recipient + +A claim reverts when the asset has no vesting position, nothing new has vested, or a restricted position is called by anyone other than its recipient. + +## Simulation + +Call: + +```solidity +simulateBundle(CreateParams createData, uint128 exactAmountIn) +``` + +`simulateBundle` executes the same create and swap path in a reverting call frame, then returns the predicted asset, pool key, governance, timelock, and net output. All deployments and state changes are rolled back. + +The function is intentionally not `view`, but it needs neither funds nor approval and is intended for offchain `eth_call`. A simulation and later transaction can differ if their underlying chain state differs. + +## Events + +- `Bundled(recipient, amountIn, amountOut, poolKey)`: emitted after a successful create and purchase +- `VestingCreated(asset, recipient, permissionlessClaim, totalAmount, start, cliffDuration, vestingDuration)`: emitted when custody vesting is configured +- `VestingReleased(asset, recipient, amount)`: emitted for each successful claim diff --git a/docs/RehypeDopplerHookInitializer.md b/docs/RehypeDopplerHookInitializer.md index 91a415a8f..35e1fa457 100644 --- a/docs/RehypeDopplerHookInitializer.md +++ b/docs/RehypeDopplerHookInitializer.md @@ -2,7 +2,7 @@ ## Overview -This page documents the initializer-side `RehypeDopplerHook` contract, which is the Doppler Hook designed to be attached to pools created by [`DopplerHookInitializer`](./DopplerHookInitializer.md). +This page documents the initializer-side `RehypeDopplerHookInitializer` contract, which is the Doppler Hook designed to be attached to pools created by [`DopplerHookInitializer`](./DopplerHookInitializer.md). Its authorized [`Bundler`](./Bundler.md) can atomically create a pool and execute its first asset purchase with a one-swap fee exemption. `RehypeDopplerHook` implements two pieces of hook logic: @@ -23,6 +23,7 @@ At a high level, `RehypeDopplerHook` adds a post-swap fee layer on top of a Dopp - reserve 5% of each gross hook fee for the current Airlock owner - split collected fees across buybacks, beneficiary accounting, and LP reinvestment - optionally split Rehype beneficiary fees among multiple pull-based recipients +- exempt the Bundler's atomic first buy from non-owner Rehype fees Important: this fee schedule controls the Rehype hook fee collected in `onSwap`. It does not update the Uniswap v4 LP fee for the pool. @@ -74,7 +75,7 @@ This makes the fee schedule lazy: it is evaluated when swaps happen, not by a ba All fee logic runs in `onSwap`. -For each external swap: +For each ordinary external swap: 1. The hook ignores internal self-swaps so it does not charge itself during its own rebalance or buyback operations. 2. It computes the current Rehype fee from the schedule. @@ -84,6 +85,14 @@ For each external swap: 6. It reserves `floor(grossFee * 500 / 10_000)` in the separate Airlock owner bucket, regardless of whether `feeBeneficiaries` is empty. 7. It accumulates exactly `grossFee - ownerCut` into the per-pool balances used by normal routing. +### Atomic Dev Buy + +`RehypeDopplerHookInitializer` stores an immutable authorized `bundler`. When `onInitialization` runs inside `Airlock.create`, the hook opens a transient, pool-specific exemption. The exemption can be consumed only by one swap whose PoolManager sender is that Bundler, and it expires at the end of the transaction. + +For the exempt swap, the hook still computes the normal gross Rehype fee and reserves the usual 5% Airlock-owner cut. It collects and returns only that owner cut as the hook delta; the remaining non-owner Rehype fee is zero, so the dev buy does not add ordinary routing, beneficiary, buyback, or LP-reinvestment fees. Any later swap uses the ordinary fee path above. + +The exemption is available only during the atomic create-and-buy flow. Direct creators and ordinary swap routers cannot consume it. A reverted create or buy rolls back both pool creation and transient exemption state. + If both accumulated fee balances are still below `EPSILON`, the hook stops there and waits for more fees to build up. Once enough fees have accumulated, the hook routes them according to `feeDistributionInfo`: @@ -157,5 +166,6 @@ The main per-pool views are: - `getPoolKey(poolId)` - `getShares(poolId, beneficiary)` - `getCumulatedFees0/1(poolId)` +- `bundler` Together they describe the configured fee schedule, the routing mode, the current fee balances, and the reinvested LP position state. diff --git a/legacy/src/Bundler.sol b/legacy/src/Bundler.sol new file mode 100644 index 000000000..1c1919357 --- /dev/null +++ b/legacy/src/Bundler.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import { SafeTransferLib } from "@solady/utils/SafeTransferLib.sol"; +import { UniversalRouter } from "@universal-router/UniversalRouter.sol"; +import { IQuoterV2 } from "@v3-periphery/interfaces/IQuoterV2.sol"; +import { Currency } from "@v4-core/types/Currency.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; +import { IV4Quoter } from "@v4-periphery/interfaces/IV4Quoter.sol"; +import { Airlock, CreateParams } from "src/Airlock.sol"; +import { DopplerHookInitializer } from "src/initializers/DopplerHookInitializer.sol"; + +/// @dev Thrown when an invalid address is passed as a contructor parameter +error InvalidAddresses(); + +/// @dev Thrown when the asset address doesn't match the predicted one +error InvalidOutputToken(); + +/// @dev Thrown when the amount to quote exceeds the uint128 limit +error ExactAmountTooLarge(); + +/// @dev Thrown when the asset is not part of the resulting pool +error AssetNotInPool(); + +/// @dev Thrown when the provided exact amount is zero +error ExactAmountZero(); + +/** + * @author Whetstone + * @custom:security-contact security@whetstone.cc + */ +contract Bundler { + /// @notice Address of the Airlock contract + Airlock public immutable airlock; + + /// @notice Address of the Universal Router contract + UniversalRouter public immutable router; + + /// @notice Address of the QuoterV2 contract + IQuoterV2 public immutable quoter; + + /// @notice Address of the Uniswap V4 Quoter contract + IV4Quoter public immutable v4Quoter; + + /** + * @param airlock_ Immutable address of the Airlock contract + * @param router_ Immutable address of the Universal Router contract + * @param quoter_ Immutable address of the QuoterV2 contract + */ + constructor(Airlock airlock_, UniversalRouter router_, IQuoterV2 quoter_, IV4Quoter v4Quoter_) { + if ( + address(airlock_) == address(0) || address(router_) == address(0) || address(quoter_) == address(0) + || address(v4Quoter_) == address(0) + ) { + revert InvalidAddresses(); + } + + airlock = Airlock(airlock_); + router = UniversalRouter(router_); + quoter = IQuoterV2(quoter_); + v4Quoter = IV4Quoter(v4Quoter_); + } + + /** + * @notice Simulates a bundle operation with an exact output amount + * @param createData Creation data to pass to the Airlock contract + * @param params Exact output parameters to pass to the QuoterV2 contract + * @return amountIn Amount of input token required to receive the exact output amount + */ + function simulateBundleExactOut( + CreateParams calldata createData, + IQuoterV2.QuoteExactOutputSingleParams calldata params + ) external returns (uint256 amountIn) { + (address asset,,,,) = airlock.create(createData); + if (asset != params.tokenOut) { + revert InvalidOutputToken(); + } + (amountIn,,,) = quoter.quoteExactOutputSingle(params); + } + + /** + * @notice Simulates a bundle operation with an exact input amount + * @param createData Creation data to pass to the Airlock contract + * @param params Exact input parameters to pass to the QuoterV2 contract + * @return amountOut Amount of output token received from the exact input amount + */ + function simulateBundleExactIn( + CreateParams calldata createData, + IQuoterV2.QuoteExactInputSingleParams calldata params + ) external returns (uint256 amountOut) { + (address asset,,,,) = airlock.create(createData); + if (asset != params.tokenOut) { + revert InvalidOutputToken(); + } + (amountOut,,,) = quoter.quoteExactInputSingle(params); + } + + /** + * @notice Simulates a multicurve bundle, returning the pool key and the quote to purchase the issued tokens + * @param createData Creation data to pass to the Airlock contract + * @return asset Address of the created asset token + * @return poolKey PoolKey associated with the initialized Uniswap V4 pool + * @return amountIn Numeraire required to receive the requested asset amount + * @return gasEstimate Estimated gas for the swap quote + */ + function simulateMulticurveBundleExactOut( + CreateParams calldata createData, + uint128 exactAmountOut, + bytes calldata hookData + ) external returns (address asset, PoolKey memory poolKey, uint256 amountIn, uint256 gasEstimate) { + bool zeroForOne; + (asset, poolKey, zeroForOne) = _prepareMulticurveQuote(createData); + + uint128 amount = _resolveExactOutAmount(createData, exactAmountOut); + + (amountIn, gasEstimate) = v4Quoter.quoteExactOutputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: poolKey, zeroForOne: zeroForOne, exactAmount: amount, hookData: hookData + }) + ); + } + + function simulateMulticurveBundleExactIn( + CreateParams calldata createData, + uint128 exactAmountIn, + bytes calldata hookData + ) external returns (address asset, PoolKey memory poolKey, uint256 amountOut, uint256 gasEstimate) { + if (exactAmountIn == 0) revert ExactAmountZero(); + + bool zeroForOne; + (asset, poolKey, zeroForOne) = _prepareMulticurveQuote(createData); + + (amountOut, gasEstimate) = v4Quoter.quoteExactInputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: poolKey, zeroForOne: zeroForOne, exactAmount: exactAmountIn, hookData: hookData + }) + ); + } + + function _prepareMulticurveQuote(CreateParams calldata createData) + private + returns (address asset, PoolKey memory poolKey, bool zeroForOne) + { + (asset,,,,) = airlock.create(createData); + (,,,,, poolKey,) = DopplerHookInitializer(payable(address(createData.poolInitializer))).getState(asset); + + address currency0 = Currency.unwrap(poolKey.currency0); + address currency1 = Currency.unwrap(poolKey.currency1); + + if (asset == currency0) { + zeroForOne = false; + } else if (asset == currency1) { + zeroForOne = true; + } else { + revert AssetNotInPool(); + } + } + + function _resolveExactOutAmount( + CreateParams calldata createData, + uint128 overrideAmount + ) private pure returns (uint128 amount) { + if (overrideAmount != 0) { + amount = overrideAmount; + } else { + uint256 numTokensToSell = createData.numTokensToSell; + if (numTokensToSell == 0) revert ExactAmountZero(); + if (numTokensToSell > type(uint128).max) revert ExactAmountTooLarge(); + amount = uint128(numTokensToSell); + } + } + + /** + * @notice Bundles the creation of an asset via the Airlock contract and a buy operation via the Universal Router + * @param createData Creation data to pass to the Airlock contract + * @param commands Encoded commands for the Universal Router + * @param inputs Encoded inputs for the Universal Router + */ + function bundle( + CreateParams calldata createData, + bytes calldata commands, + bytes[] calldata inputs + ) external payable { + (address asset,,,,) = airlock.create(createData); + uint256 balance = address(this).balance; + router.execute{ value: balance }(commands, inputs); + + uint256 ethBalance = address(this).balance; + if (ethBalance > 0) SafeTransferLib.safeTransferETH(msg.sender, ethBalance); + + uint256 assetBalance = SafeTransferLib.balanceOf(asset, address(this)); + if (assetBalance > 0) SafeTransferLib.safeTransfer(asset, msg.sender, assetBalance); + + uint256 numeraireBalance = SafeTransferLib.balanceOf(createData.numeraire, address(this)); + if (numeraireBalance > 0) SafeTransferLib.safeTransfer(createData.numeraire, msg.sender, numeraireBalance); + } +} diff --git a/test/unit/Bundler.t.sol b/legacy/test/unit/bundler/Bundler.t.sol similarity index 100% rename from test/unit/Bundler.t.sol rename to legacy/test/unit/bundler/Bundler.t.sol diff --git a/test/unit/BundlerMulticurve.t.sol b/legacy/test/unit/bundler/BundlerMulticurve.t.sol similarity index 100% rename from test/unit/BundlerMulticurve.t.sol rename to legacy/test/unit/bundler/BundlerMulticurve.t.sol diff --git a/script/DeployBase.s.sol b/script/DeployBase.s.sol index b4632ad4a..5cd54b111 100644 --- a/script/DeployBase.s.sol +++ b/script/DeployBase.s.sol @@ -15,10 +15,11 @@ abstract contract DeployBase is Script, Config, Versions { string internal constant IS_TESTNET_KEY = "is_testnet"; string internal constant PROTOCOL_DEPLOYER_KEY = "protocol_deployer"; + error InvalidContract(address expected, address actual); error Create2AddressMismatch(bytes32 salt, address expected, address computed); error Create3AddressMismatch(bytes32 salt, address expected, address computed); - error InvalidCreateXGuardedSalt(bytes32 salt); error BroadcastSenderMismatch(address expected, address actual); + error InvalidCreateXGuardedSalt(bytes32 salt); struct DeployContext { uint256 chainId; diff --git a/script/DeployDoppler.s.sol b/script/DeployDoppler.s.sol index 5962e4bb9..7d68be912 100644 --- a/script/DeployDoppler.s.sol +++ b/script/DeployDoppler.s.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import { DeployAirlock } from "script/deploy/DeployAirlock.s.sol"; import { DeployAirlockMultisigTestnet } from "script/deploy/DeployAirlockMultisigTestnet.s.sol"; +import { DeployBundler } from "script/deploy/DeployBundler.s.sol"; import { DeployDN404Factory } from "script/deploy/DeployDN404Factory.s.sol"; import { DeployDopplerERC20V1Factory } from "script/deploy/DeployDopplerERC20V1Factory.s.sol"; import { DeployDopplerHookInitializer } from "script/deploy/DeployDopplerHookInitializer.s.sol"; @@ -25,6 +26,7 @@ import { ChainIds } from "script/utils/ChainIds.sol"; contract DeployDopplerScript is DeployAirlock, DeployAirlockMultisigTestnet, + DeployBundler, DeployTopUpDistributor, DeployStreamableFeesLockerV2, DeployDopplerERC20V1Factory, @@ -48,6 +50,7 @@ contract DeployDopplerScript is struct DeployedAddresses { address airlockMultisig; address airlock; + address bundler; address topUpDistributor; address streamableFeesLockerV2; address dopplerHookInitializer; @@ -83,8 +86,9 @@ contract DeployDopplerScript is _deployLockableUniswapV3Initializer(context, deployed.airlock); _deployUniswapV4Initializer(context, deployed.airlock); deployed.dopplerHookInitializer = _deployDopplerHookInitializer(context, deployed.airlock); + deployed.bundler = _deployBundler(context, deployed.airlock); - _deployRehypeDopplerHookInitializer(context, deployed.dopplerHookInitializer); + _deployRehypeDopplerHookInitializer(context, deployed.dopplerHookInitializer, deployed.bundler); _deploySwapRestrictorDopplerHook(context, deployed.dopplerHookInitializer); _deployNoOpMigrator(context, deployed.airlock); diff --git a/script/deploy/DeployBundler.s.sol b/script/deploy/DeployBundler.s.sol index 8f44f985f..e97f30c2a 100644 --- a/script/deploy/DeployBundler.s.sol +++ b/script/deploy/DeployBundler.s.sol @@ -13,18 +13,15 @@ abstract contract DeployBundler is DeployBase { } function _deployBundler(DeployContext memory context, address airlock) internal returns (address bundler) { - address quoterV2 = context.config.get(context.chainId, "quoter_v2").toAddress(); - address quoterV4 = context.config.get(context.chainId, "quoter_v4").toAddress(); - address router = context.config.get(context.chainId, "universal_router").toAddress(); - bytes memory initCode = - abi.encodePacked(type(Bundler).creationCode, abi.encode(airlock, router, quoterV2, quoterV4)); + address poolManager = context.config.get(context.chainId, "uniswap_v4_pool_manager").toAddress(); + bytes memory initCode = abi.encodePacked(type(Bundler).creationCode, abi.encode(airlock, poolManager)); bool alreadyDeployed; (bundler, alreadyDeployed) = _deployOrUseExistingVersionedCreate3( context, bytes32(0), address(0), type(Bundler).name, BUNDLER_VERSION, initCode ); - _verifyBundlerDeployment(bundler, airlock, router, quoterV2, quoterV4); + _verifyBundlerDeployment(bundler, airlock, poolManager); _setConfigAddress(context, "bundler", bundler); if (alreadyDeployed) { @@ -34,18 +31,10 @@ abstract contract DeployBundler is DeployBase { } } - function _verifyBundlerDeployment( - address addr, - address airlock, - address router, - address quoterV2, - address quoterV4 - ) internal view { + function _verifyBundlerDeployment(address addr, address airlock, address poolManager) internal view { Bundler bundler = Bundler(addr); require(address(bundler.airlock()) == airlock, "Bundler airlock mismatch"); - require(address(bundler.router()) == router, "Bundler router mismatch"); - require(address(bundler.quoter()) == quoterV2, "Bundler quoter mismatch"); - require(address(bundler.v4Quoter()) == quoterV4, "Bundler v4 quoter mismatch"); + require(address(bundler.poolManager()) == poolManager, "Bundler pool manager mismatch"); } } diff --git a/script/deploy/DeployRehypeDopplerHookInitializer.s.sol b/script/deploy/DeployRehypeDopplerHookInitializer.s.sol index b759a3013..090b1ee5b 100644 --- a/script/deploy/DeployRehypeDopplerHookInitializer.s.sol +++ b/script/deploy/DeployRehypeDopplerHookInitializer.s.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.24; import { console } from "forge-std/console.sol"; import { DeployBase } from "script/DeployBase.s.sol"; import { ChainIds } from "script/utils/ChainIds.sol"; +import { Bundler } from "src/Bundler.sol"; import { RehypeDopplerHookInitializer } from "src/dopplerHooks/RehypeDopplerHookInitializer.sol"; abstract contract DeployRehypeDopplerHookInitializer is DeployBase { @@ -12,16 +13,21 @@ abstract contract DeployRehypeDopplerHookInitializer is DeployBase { returns (address rehypeDopplerHookInitializer) { address dopplerHookInitializer = context.config.get(context.chainId, "doppler_hook_initializer").toAddress(); - return _deployRehypeDopplerHookInitializer(context, dopplerHookInitializer); + address bundler = context.config.get(context.chainId, "bundler").toAddress(); + bytes32 bundlerSalt = context.protocolDeployer.generateSalt(type(Bundler).name, BUNDLER_VERSION); + address expectedBundler = _computeProtocolCreate3Address(context.protocolDeployer, bundlerSalt); + if (bundler != expectedBundler) revert InvalidContract(expectedBundler, bundler); + return _deployRehypeDopplerHookInitializer(context, dopplerHookInitializer, bundler); } function _deployRehypeDopplerHookInitializer( DeployContext memory context, - address dopplerHookInitializer + address dopplerHookInitializer, + address bundler ) internal returns (address rehypeDopplerHookInitializer) { address poolManager = context.config.get(context.chainId, "uniswap_v4_pool_manager").toAddress(); bytes memory initCode = abi.encodePacked( - type(RehypeDopplerHookInitializer).creationCode, abi.encode(dopplerHookInitializer, poolManager) + type(RehypeDopplerHookInitializer).creationCode, abi.encode(dopplerHookInitializer, poolManager, bundler) ); bool alreadyDeployed; @@ -35,7 +41,7 @@ abstract contract DeployRehypeDopplerHookInitializer is DeployBase { ); address quoter = _verifyRehypeDopplerHookInitializerDeployment( - rehypeDopplerHookInitializer, dopplerHookInitializer, poolManager + rehypeDopplerHookInitializer, dopplerHookInitializer, poolManager, bundler ); _setConfigAddress(context, "rehype_doppler_hook_initializer", rehypeDopplerHookInitializer); _setConfigAddress(context, "quoter", quoter); @@ -51,11 +57,13 @@ abstract contract DeployRehypeDopplerHookInitializer is DeployBase { function _verifyRehypeDopplerHookInitializerDeployment( address addr, address dopplerHookInitializer, - address poolManager + address poolManager, + address bundler ) internal view returns (address quoter) { RehypeDopplerHookInitializer hook = RehypeDopplerHookInitializer(payable(addr)); require(hook.INITIALIZER() == dopplerHookInitializer, "RehypeDopplerHookInitializer initializer mismatch"); require(address(hook.poolManager()) == poolManager, "RehypeDopplerHookInitializer pool manager mismatch"); + require(hook.bundler() == bundler, "RehypeDopplerHookInitializer bundler mismatch"); quoter = address(hook.quoter()); require(quoter != address(0) && quoter.code.length != 0, "RehypeDopplerHookInitializer quoter missing"); diff --git a/script/utils/Versions.sol b/script/utils/Versions.sol index ca61e2f3e..724e0f820 100644 --- a/script/utils/Versions.sol +++ b/script/utils/Versions.sol @@ -5,7 +5,7 @@ contract Versions { // --- Core --- uint8 public constant AIRLOCK_MULTISIG_VERSION = 0; uint8 public constant AIRLOCK_VERSION = 0; - uint8 public constant BUNDLER_VERSION = 0; + uint8 public constant BUNDLER_VERSION = 1; uint8 public constant TOP_UP_DISTRIBUTOR_VERSION = 0; // --- Lockers --- uint8 public constant STREAMABLE_FEES_LOCKER_VERSION = 1; @@ -26,7 +26,7 @@ contract Versions { uint8 public constant UNISWAP_V2_MIGRATOR_SPLIT_VERSION = 0; uint8 public constant NO_OP_MIGRATOR_VERSION = 0; // --- Doppler Hooks --- - uint8 public constant REHYPE_DOPPLER_HOOK_INITIALIZER_VERSION = 3; + uint8 public constant REHYPE_DOPPLER_HOOK_INITIALIZER_VERSION = 4; uint8 public constant REHYPE_DOPPLER_HOOK_MIGRATOR_VERSION = 2; uint8 public constant SWAP_RESTRICTOR_DOPPLER_HOOK_VERSION = 0; // --- Other --- diff --git a/snapshots/DopplerERC20V1FactoryDopplerHookInitializerNoOpGovernanceFactoryNoOpMigrator.json b/snapshots/DopplerERC20V1FactoryDopplerHookInitializerNoOpGovernanceFactoryNoOpMigrator.json index 284c2e392..721bf0622 100644 --- a/snapshots/DopplerERC20V1FactoryDopplerHookInitializerNoOpGovernanceFactoryNoOpMigrator.json +++ b/snapshots/DopplerERC20V1FactoryDopplerHookInitializerNoOpGovernanceFactoryNoOpMigrator.json @@ -1,3 +1,3 @@ { - "create": "9663877" + "create": "9663499" } \ No newline at end of file diff --git a/snapshots/GasBenchmark.json b/snapshots/GasBenchmark.json index 11c1f283d..9c11c2d0b 100644 --- a/snapshots/GasBenchmark.json +++ b/snapshots/GasBenchmark.json @@ -74,49 +74,49 @@ "Dynamic_UniswapV4Initializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "105362", "Dynamic_UniswapV4Initializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorBuy": "108176", "Dynamic_UniswapV4Initializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorSell": "106538", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "6495644", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "1057421", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131456", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "101080", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "6495398", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "1056511", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131331", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "100955", "Multicurve_DopplerHookInitializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorBuy": "152978", "Multicurve_DopplerHookInitializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorSell": "127032", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "2114467", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "1057421", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131475", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "101079", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "2114221", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "1056511", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131350", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "100954", "Multicurve_DopplerHookInitializer_DopplerHookMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorBuy": "152992", "Multicurve_DopplerHookInitializer_DopplerHookMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorSell": "127046", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "2114401", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "1057368", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131479", - "Multicurve_DopplerHookInitializer_DopplerHookMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "101079", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "2114155", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "1056458", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131354", + "Multicurve_DopplerHookInitializer_DopplerHookMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "100954", "Multicurve_DopplerHookInitializer_DopplerHookMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorBuy": "152992", "Multicurve_DopplerHookInitializer_DopplerHookMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorSell": "127045", - "Multicurve_DopplerHookInitializer_NoOpMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "6338199", - "Multicurve_DopplerHookInitializer_NoOpMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131490", - "Multicurve_DopplerHookInitializer_NoOpMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "101057", - "Multicurve_DopplerHookInitializer_NoOpMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "1957024", - "Multicurve_DopplerHookInitializer_NoOpMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131521", - "Multicurve_DopplerHookInitializer_NoOpMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "101075", - "Multicurve_DopplerHookInitializer_NoOpMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "1956955", - "Multicurve_DopplerHookInitializer_NoOpMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131508", - "Multicurve_DopplerHookInitializer_NoOpMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "101061", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "8828103", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "699276", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131504", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "101077", + "Multicurve_DopplerHookInitializer_NoOpMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "6337953", + "Multicurve_DopplerHookInitializer_NoOpMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131365", + "Multicurve_DopplerHookInitializer_NoOpMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "100932", + "Multicurve_DopplerHookInitializer_NoOpMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "1956778", + "Multicurve_DopplerHookInitializer_NoOpMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131396", + "Multicurve_DopplerHookInitializer_NoOpMigrator_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "100950", + "Multicurve_DopplerHookInitializer_NoOpMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "1956709", + "Multicurve_DopplerHookInitializer_NoOpMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131383", + "Multicurve_DopplerHookInitializer_NoOpMigrator_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "100936", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "8827857", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "698366", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131379", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "100952", "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorBuy": "77293", "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorSell": "71835", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "4446928", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "699276", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131522", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "101076", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "4446682", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "698366", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131397", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "100951", "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorBuy": "77310", "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_LaunchpadGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorSell": "71834", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "4446862", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "682176", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131522", - "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "101075", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "4446616", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.migrate": "681266", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerBuy": "131397", + "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/initializerSell": "100950", "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorBuy": "77315", "Multicurve_DopplerHookInitializer_UniswapV2MigratorSplit_NoOpGovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/migratorSell": "71833", "Static_LockableUniswapV3Initializer_DopplerHookMigrator_GovernanceFactory_BalanceLimitDisabled_ProceedsSplitDisabled/Airlock.create": "11627558", diff --git a/snapshots/Multicurve.json b/snapshots/Multicurve.json index c4d0b3675..9be96fe5c 100644 --- a/snapshots/Multicurve.json +++ b/snapshots/Multicurve.json @@ -1,5 +1,5 @@ { - "adjustCurves": "5810", + "adjustCurves": "5822", "calculateLogNormalDistribution": "32404", "calculatePositions": "355592" } \ No newline at end of file diff --git a/src/Bundler.sol b/src/Bundler.sol index 1c1919357..8964a42e6 100644 --- a/src/Bundler.sol +++ b/src/Bundler.sol @@ -2,196 +2,453 @@ pragma solidity ^0.8.24; import { SafeTransferLib } from "@solady/utils/SafeTransferLib.sol"; -import { UniversalRouter } from "@universal-router/UniversalRouter.sol"; -import { IQuoterV2 } from "@v3-periphery/interfaces/IQuoterV2.sol"; -import { Currency } from "@v4-core/types/Currency.sol"; +import { IHooks } from "@v4-core/interfaces/IHooks.sol"; +import { IPoolManager } from "@v4-core/interfaces/IPoolManager.sol"; +import { IUnlockCallback } from "@v4-core/interfaces/callback/IUnlockCallback.sol"; +import { TickMath } from "@v4-core/libraries/TickMath.sol"; +import { BalanceDelta, BalanceDeltaLibrary } from "@v4-core/types/BalanceDelta.sol"; +import { Currency, CurrencyLibrary } from "@v4-core/types/Currency.sol"; import { PoolKey } from "@v4-core/types/PoolKey.sol"; -import { IV4Quoter } from "@v4-periphery/interfaces/IV4Quoter.sol"; import { Airlock, CreateParams } from "src/Airlock.sol"; import { DopplerHookInitializer } from "src/initializers/DopplerHookInitializer.sol"; -/// @dev Thrown when an invalid address is passed as a contructor parameter -error InvalidAddresses(); +/// @notice Internal revert payload used to roll back a simulated swap. +error SwapQuote(uint128 amountOut); -/// @dev Thrown when the asset address doesn't match the predicted one -error InvalidOutputToken(); +/// @notice Internal revert payload used to roll back a simulated bundle and return its result. +error BundleQuote(address asset, PoolKey poolKey, address governance, address timelock, uint128 amountOut); -/// @dev Thrown when the amount to quote exceeds the uint128 limit -error ExactAmountTooLarge(); +/// @notice Thrown when the initialized pool does not contain the created asset and configured numeraire. +error InvalidPool(); -/// @dev Thrown when the asset is not part of the resulting pool -error AssetNotInPool(); +/// @notice Thrown when a simulation helper is called by any address other than this contract. +error SenderNotSelf(); -/// @dev Thrown when the provided exact amount is zero -error ExactAmountZero(); +/// @notice Thrown when a constructor address is zero. +error InvalidAddress(); + +/// @notice Thrown when the asset recipient is the zero address. +error InvalidRecipient(); + +/// @notice Thrown when an asset has no tokens available to claim. +error NoClaimableAmount(); + +/// @notice Thrown when a restricted vesting position is claimed by anyone other than its recipient. +error SenderNotRecipient(); + +/// @notice Thrown when native ETH is supplied for an ERC20 numeraire or does not match the exact input amount. +error InvalidNativeValue(); + +/// @notice Thrown when the PoolManager callback is invoked by any other address. +error SenderNotPoolManager(); + +/// @notice Thrown when the exact input amount is zero. +error ExactInputAmountZero(); + +/// @notice Thrown when a vesting position already exists for an asset. +error VestingAlreadyExists(address asset); + +/// @notice Thrown when the cliff exceeds the vesting duration. +error InvalidVestingSchedule(); + +/// @notice Thrown when the pool does not consume the requested exact input amount. +error ExactInputNotFullySpent(uint256 expected, uint256 actual); + +/// @notice Thrown when a simulation unexpectedly completes without returning a quote by reverting. +error UnexpectedSimulationSuccess(); + +/// @notice Emitted after a market is created and its initial asset purchase completes. +event Bundled(address indexed recipient, uint128 amountIn, uint128 amountOut, PoolKey poolKey); + +/// @notice Emitted when purchased assets are placed in vesting. +event VestingCreated( + address indexed asset, + address indexed recipient, + bool permissionlessClaim, + uint128 totalAmount, + uint64 start, + uint64 cliffDuration, + uint64 vestingDuration +); + +/// @notice Emitted when vested assets are claimed for their recipient. +event VestingReleased(address indexed asset, address indexed recipient, uint128 amount); /** + * @title Doppler Bundler * @author Whetstone * @custom:security-contact security@whetstone.cc + * @notice Atomically creates a Doppler market, buys its asset, and optionally vests the purchased amount. */ -contract Bundler { - /// @notice Address of the Airlock contract - Airlock public immutable airlock; +contract Bundler is IUnlockCallback { + using BalanceDeltaLibrary for BalanceDelta; + using CurrencyLibrary for Currency; - /// @notice Address of the Universal Router contract - UniversalRouter public immutable router; + /** + * @notice Data passed through the PoolManager unlock callback for a bundled swap. + * @param poolKey Key identifying the pool used for the swap. + * @param zeroForOne Whether the swap exchanges currency0 for currency1. + * @param exactAmountIn Exact amount of numeraire to spend. + * @param payer Address funding the swap input. + * @param recipient Address receiving the swap output. + * @param simulate Whether the callback should return its result by reverting. + */ + struct SwapCallbackData { + PoolKey poolKey; + bool zeroForOne; + uint128 exactAmountIn; + address payer; + address recipient; + bool simulate; + } - /// @notice Address of the QuoterV2 contract - IQuoterV2 public immutable quoter; + /** + * @notice Market creation data needed to execute the bundled swap. + * @param asset Address of the created asset. + * @param governance Address of the created governance contract. + * @param timelock Address of the created timelock contract. + * @param poolKey Key identifying the created pool. + * @param zeroForOne Whether buying the asset exchanges currency0 for currency1. + */ + struct BundleResult { + address asset; + address governance; + address timelock; + PoolKey poolKey; + bool zeroForOne; + } - /// @notice Address of the Uniswap V4 Quoter contract - IV4Quoter public immutable v4Quoter; + /** + * @notice Optional vesting configuration for a bundled asset purchase. + * @param permissionlessClaim Whether anyone may trigger claims for the recipient. + * @param vestingDuration Seconds from creation until the entire purchase is vested, or zero to disable vesting. + * @param cliffDuration Seconds from creation before vested assets become claimable. + */ + struct VestingParams { + bool permissionlessClaim; + uint64 vestingDuration; + uint64 cliffDuration; + } /** - * @param airlock_ Immutable address of the Airlock contract - * @param router_ Immutable address of the Universal Router contract - * @param quoter_ Immutable address of the QuoterV2 contract + * @notice Vesting position for an asset purchased through this Bundler. + * @param recipient Address that receives claimed assets. + * @param permissionlessClaim Whether anyone may trigger claims for the recipient. + * @param start Timestamp from which vesting accrues. + * @param cliffDuration Seconds after `start` before any assets are claimable. + * @param vestingDuration Seconds after `start` when all assets are vested. + * @param totalAmount Total amount of assets held in vesting. + * @param claimedAmount Amount already claimed for the recipient. */ - constructor(Airlock airlock_, UniversalRouter router_, IQuoterV2 quoter_, IV4Quoter v4Quoter_) { - if ( - address(airlock_) == address(0) || address(router_) == address(0) || address(quoter_) == address(0) - || address(v4Quoter_) == address(0) - ) { - revert InvalidAddresses(); - } + struct Vesting { + address recipient; + bool permissionlessClaim; + uint64 start; + uint64 cliffDuration; + uint64 vestingDuration; + uint128 totalAmount; + uint128 claimedAmount; + } + + /// @notice Airlock used to create markets. + Airlock public immutable airlock; + + /// @notice Uniswap V4 PoolManager used by Doppler pools. + IPoolManager public immutable poolManager; + + /// @notice Vesting position for each bundled asset. + mapping(address asset => Vesting vesting) public vestingOf; - airlock = Airlock(airlock_); - router = UniversalRouter(router_); - quoter = IQuoterV2(quoter_); - v4Quoter = IV4Quoter(v4Quoter_); + constructor(Airlock airlock_, IPoolManager poolManager_) { + if (address(airlock_) == address(0) || address(poolManager_) == address(0)) revert InvalidAddress(); + airlock = airlock_; + poolManager = poolManager_; } /** - * @notice Simulates a bundle operation with an exact output amount - * @param createData Creation data to pass to the Airlock contract - * @param params Exact output parameters to pass to the QuoterV2 contract - * @return amountIn Amount of input token required to receive the exact output amount + * @notice Creates a market and buys its asset with an exact amount of the configured numeraire. + * @dev Native numeraires require `msg.value == exactAmountIn`. ERC20 numeraires require an approval for this + * contract. A zero `vestingData.vestingDuration` transfers the purchased asset directly to `recipient`; + * otherwise this contract holds the asset until it is claimed according to the configured vesting schedule. + * @param createData Creation data passed to Airlock. + * @param vestingData Optional vesting configuration for the purchased asset. + * @param exactAmountIn Exact amount of numeraire spent on the asset purchase. + * @param recipient Address that receives the purchased asset directly or through vesting claims. + * @return asset Address of the created asset. + * @return poolKey Key identifying the created Uniswap V4 pool. + * @return governance Address of the created governance contract. + * @return timelock Address of the created timelock contract. + * @return amountOut Amount of the created asset purchased after hook fees. */ - function simulateBundleExactOut( + function bundle( CreateParams calldata createData, - IQuoterV2.QuoteExactOutputSingleParams calldata params - ) external returns (uint256 amountIn) { - (address asset,,,,) = airlock.create(createData); - if (asset != params.tokenOut) { - revert InvalidOutputToken(); + VestingParams calldata vestingData, + uint128 exactAmountIn, + address recipient + ) + external + payable + returns (address asset, PoolKey memory poolKey, address governance, address timelock, uint128 amountOut) + { + if (exactAmountIn == 0) { + revert ExactInputAmountZero(); } - (amountIn,,,) = quoter.quoteExactOutputSingle(params); + if (recipient == address(0)) revert InvalidRecipient(); + if (vestingData.cliffDuration > vestingData.vestingDuration) revert InvalidVestingSchedule(); + + bool nativeNumeraire = createData.numeraire == address(0); + if (nativeNumeraire ? msg.value != exactAmountIn : msg.value != 0) revert InvalidNativeValue(); + + BundleResult memory result = _createBundle(createData); + + bool vestingEnabled = vestingData.vestingDuration != 0; + if (vestingEnabled && vestingOf[result.asset].recipient != address(0)) { + revert VestingAlreadyExists(result.asset); + } + + bytes memory swapResult = poolManager.unlock( + abi.encode( + SwapCallbackData({ + poolKey: result.poolKey, + zeroForOne: result.zeroForOne, + exactAmountIn: exactAmountIn, + payer: msg.sender, + recipient: vestingEnabled ? address(this) : recipient, + simulate: false + }) + ) + ); + + amountOut = abi.decode(swapResult, (uint128)); + if (vestingEnabled) { + uint64 start = uint64(block.timestamp); + vestingOf[result.asset] = Vesting({ + recipient: recipient, + permissionlessClaim: vestingData.permissionlessClaim, + start: start, + cliffDuration: vestingData.cliffDuration, + vestingDuration: vestingData.vestingDuration, + totalAmount: amountOut, + claimedAmount: 0 + }); + emit VestingCreated( + result.asset, + recipient, + vestingData.permissionlessClaim, + amountOut, + start, + vestingData.cliffDuration, + vestingData.vestingDuration + ); + } + + emit Bundled(recipient, exactAmountIn, amountOut, result.poolKey); + return (result.asset, result.poolKey, result.governance, result.timelock, amountOut); } /** - * @notice Simulates a bundle operation with an exact input amount - * @param createData Creation data to pass to the Airlock contract - * @param params Exact input parameters to pass to the QuoterV2 contract - * @return amountOut Amount of output token received from the exact input amount + * @notice Returns the amount currently claimable from an asset's vesting position. + * @param asset Address of the vested asset. + * @return amount Amount currently claimable by the position's recipient. */ - function simulateBundleExactIn( - CreateParams calldata createData, - IQuoterV2.QuoteExactInputSingleParams calldata params - ) external returns (uint256 amountOut) { - (address asset,,,,) = airlock.create(createData); - if (asset != params.tokenOut) { - revert InvalidOutputToken(); - } - (amountOut,,,) = quoter.quoteExactInputSingle(params); + function claimable(address asset) public view returns (uint256 amount) { + Vesting memory vesting = vestingOf[asset]; + uint256 totalAmount = vesting.totalAmount; + if (totalAmount == 0) return 0; + + uint256 start = vesting.start; + uint256 timestamp = block.timestamp; + if (timestamp < start + vesting.cliffDuration) return 0; + + uint256 vestedAmount = timestamp >= start + vesting.vestingDuration + ? totalAmount + : totalAmount * (timestamp - start) / vesting.vestingDuration; + + return vestedAmount - vesting.claimedAmount; } /** - * @notice Simulates a multicurve bundle, returning the pool key and the quote to purchase the issued tokens - * @param createData Creation data to pass to the Airlock contract - * @return asset Address of the created asset token - * @return poolKey PoolKey associated with the initialized Uniswap V4 pool - * @return amountIn Numeraire required to receive the requested asset amount - * @return gasEstimate Estimated gas for the swap quote + * @notice Claims all currently vested assets for a position's recipient. + * @dev If permissionless claims are disabled, only the recipient may call this function. Assets are always sent + * to the stored recipient. + * @param asset Address of the vested asset. + * @return amount Amount transferred to the recipient. */ - function simulateMulticurveBundleExactOut( - CreateParams calldata createData, - uint128 exactAmountOut, - bytes calldata hookData - ) external returns (address asset, PoolKey memory poolKey, uint256 amountIn, uint256 gasEstimate) { - bool zeroForOne; - (asset, poolKey, zeroForOne) = _prepareMulticurveQuote(createData); + function claim(address asset) external returns (uint256 amount) { + Vesting memory vesting = vestingOf[asset]; + if (vesting.totalAmount == 0) revert NoClaimableAmount(); + if (!vesting.permissionlessClaim && msg.sender != vesting.recipient) revert SenderNotRecipient(); - uint128 amount = _resolveExactOutAmount(createData, exactAmountOut); + amount = claimable(asset); + if (amount == 0) revert NoClaimableAmount(); - (amountIn, gasEstimate) = v4Quoter.quoteExactOutputSingle( - IV4Quoter.QuoteExactSingleParams({ - poolKey: poolKey, zeroForOne: zeroForOne, exactAmount: amount, hookData: hookData - }) - ); + vestingOf[asset].claimedAmount += uint128(amount); + SafeTransferLib.safeTransfer(asset, vesting.recipient, amount); + emit VestingReleased(asset, vesting.recipient, uint128(amount)); } - function simulateMulticurveBundleExactIn( + /** + * @notice Simulates creating a market and buying its asset without retaining any state changes. + * @dev This function is not `view` because it executes the creation and swap before reverting them. + * It does not require numeraire funds or approval and should be called offchain with `eth_call`. + * @param createData Creation data passed to Airlock. + * @param exactAmountIn Exact amount of numeraire to simulate spending on the asset purchase. + * @return asset Address of the asset that would be created. + * @return poolKey Key identifying the created Uniswap V4 pool. + * @return governance Address of the governance contract that would be created. + * @return timelock Address of the timelock contract that would be created. + * @return amountOut Amount of the created asset that the bundle would purchase after hook fees. + */ + function simulateBundle( CreateParams calldata createData, - uint128 exactAmountIn, - bytes calldata hookData - ) external returns (address asset, PoolKey memory poolKey, uint256 amountOut, uint256 gasEstimate) { - if (exactAmountIn == 0) revert ExactAmountZero(); - - bool zeroForOne; - (asset, poolKey, zeroForOne) = _prepareMulticurveQuote(createData); + uint128 exactAmountIn + ) + external + returns (address asset, PoolKey memory poolKey, address governance, address timelock, uint128 amountOut) + { + if (exactAmountIn == 0) { + revert ExactInputAmountZero(); + } - (amountOut, gasEstimate) = v4Quoter.quoteExactInputSingle( - IV4Quoter.QuoteExactSingleParams({ - poolKey: poolKey, zeroForOne: zeroForOne, exactAmount: exactAmountIn, hookData: hookData - }) - ); + try this._simulateBundle(createData, exactAmountIn) { + revert UnexpectedSimulationSuccess(); + } catch (bytes memory reason) { + return _parseBundleQuote(reason); + } } - function _prepareMulticurveQuote(CreateParams calldata createData) - private - returns (address asset, PoolKey memory poolKey, bool zeroForOne) - { - (asset,,,,) = airlock.create(createData); - (,,,,, poolKey,) = DopplerHookInitializer(payable(address(createData.poolInitializer))).getState(asset); + /// @inheritdoc IUnlockCallback + function unlockCallback(bytes calldata data) external returns (bytes memory) { + if (msg.sender != address(poolManager)) revert SenderNotPoolManager(); + + SwapCallbackData memory callbackData = abi.decode(data, (SwapCallbackData)); + IPoolManager.SwapParams memory params = IPoolManager.SwapParams({ + zeroForOne: callbackData.zeroForOne, + amountSpecified: -int256(uint256(callbackData.exactAmountIn)), + sqrtPriceLimitX96: callbackData.zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }); - address currency0 = Currency.unwrap(poolKey.currency0); - address currency1 = Currency.unwrap(poolKey.currency1); + BalanceDelta delta = poolManager.swap(callbackData.poolKey, params, bytes("")); + int128 inputDelta = callbackData.zeroForOne ? delta.amount0() : delta.amount1(); + int128 outputDelta = callbackData.zeroForOne ? delta.amount1() : delta.amount0(); + uint256 amountSpent = uint256(-int256(inputDelta)); - if (asset == currency0) { - zeroForOne = false; - } else if (asset == currency1) { - zeroForOne = true; + if (inputDelta >= 0 || outputDelta <= 0 || amountSpent != callbackData.exactAmountIn) { + revert ExactInputNotFullySpent(callbackData.exactAmountIn, amountSpent); + } + uint128 amountOut = uint128(outputDelta); + if (callbackData.simulate) revert SwapQuote(amountOut); + + Currency inputCurrency = + callbackData.zeroForOne ? callbackData.poolKey.currency0 : callbackData.poolKey.currency1; + Currency outputCurrency = + callbackData.zeroForOne ? callbackData.poolKey.currency1 : callbackData.poolKey.currency0; + + poolManager.sync(inputCurrency); + if (inputCurrency.isAddressZero()) { + poolManager.settle{ value: amountSpent }(); } else { - revert AssetNotInPool(); + SafeTransferLib.safeTransferFrom( + Currency.unwrap(inputCurrency), callbackData.payer, address(poolManager), amountSpent + ); + poolManager.settle(); } + + poolManager.take(outputCurrency, callbackData.recipient, amountOut); + return abi.encode(amountOut); } - function _resolveExactOutAmount( - CreateParams calldata createData, - uint128 overrideAmount - ) private pure returns (uint128 amount) { - if (overrideAmount != 0) { - amount = overrideAmount; - } else { - uint256 numTokensToSell = createData.numTokensToSell; - if (numTokensToSell == 0) revert ExactAmountZero(); - if (numTokensToSell > type(uint128).max) revert ExactAmountTooLarge(); - amount = uint128(numTokensToSell); + /// @dev Executes a complete simulation in a call frame that always reverts. + function _simulateBundle(CreateParams calldata createData, uint128 exactAmountIn) external { + if (msg.sender != address(this)) revert SenderNotSelf(); + + BundleResult memory result = _createBundle(createData); + try poolManager.unlock( + abi.encode( + SwapCallbackData({ + poolKey: result.poolKey, + zeroForOne: result.zeroForOne, + exactAmountIn: exactAmountIn, + payer: address(0), + recipient: address(0), + simulate: true + }) + ) + ) { + revert UnexpectedSimulationSuccess(); + } catch (bytes memory reason) { + uint128 quotedAmountOut = _parseSwapQuote(reason); + revert BundleQuote(result.asset, result.poolKey, result.governance, result.timelock, quotedAmountOut); } } - /** - * @notice Bundles the creation of an asset via the Airlock contract and a buy operation via the Universal Router - * @param createData Creation data to pass to the Airlock contract - * @param commands Encoded commands for the Universal Router - * @param inputs Encoded inputs for the Universal Router - */ - function bundle( - CreateParams calldata createData, - bytes calldata commands, - bytes[] calldata inputs - ) external payable { - (address asset,,,,) = airlock.create(createData); - uint256 balance = address(this).balance; - router.execute{ value: balance }(commands, inputs); + function _createBundle(CreateParams calldata createData) private returns (BundleResult memory result) { + (result.asset,, result.governance, result.timelock,) = airlock.create(createData); + + (,,,,, result.poolKey,) = + DopplerHookInitializer(payable(address(createData.poolInitializer))).getState(result.asset); + + address currency0 = Currency.unwrap(result.poolKey.currency0); + address currency1 = Currency.unwrap(result.poolKey.currency1); - uint256 ethBalance = address(this).balance; - if (ethBalance > 0) SafeTransferLib.safeTransferETH(msg.sender, ethBalance); + if (currency0 == createData.numeraire && currency1 == result.asset) { + result.zeroForOne = true; + } else if (currency1 != createData.numeraire || currency0 != result.asset) { + revert InvalidPool(); + } + } - uint256 assetBalance = SafeTransferLib.balanceOf(asset, address(this)); - if (assetBalance > 0) SafeTransferLib.safeTransfer(asset, msg.sender, assetBalance); + function _parseSwapQuote(bytes memory reason) private pure returns (uint128 amountOut) { + bytes4 selector; + assembly ("memory-safe") { + selector := mload(add(reason, 0x20)) + } + if (reason.length != 36 || selector != SwapQuote.selector) _revert(reason); - uint256 numeraireBalance = SafeTransferLib.balanceOf(createData.numeraire, address(this)); - if (numeraireBalance > 0) SafeTransferLib.safeTransfer(createData.numeraire, msg.sender, numeraireBalance); + assembly ("memory-safe") { + amountOut := mload(add(reason, 0x24)) + } + } + + function _parseBundleQuote(bytes memory reason) + private + pure + returns (address asset, PoolKey memory poolKey, address governance, address timelock, uint128 amountOut) + { + bytes4 selector; + address currency0; + address currency1; + uint24 fee; + int24 tickSpacing; + address hooks; + assembly ("memory-safe") { + selector := mload(add(reason, 0x20)) + } + if (reason.length != 292 || selector != BundleQuote.selector) _revert(reason); + + assembly ("memory-safe") { + asset := mload(add(reason, 0x24)) + currency0 := mload(add(reason, 0x44)) + currency1 := mload(add(reason, 0x64)) + fee := mload(add(reason, 0x84)) + tickSpacing := mload(add(reason, 0xa4)) + hooks := mload(add(reason, 0xc4)) + governance := mload(add(reason, 0xe4)) + timelock := mload(add(reason, 0x104)) + amountOut := mload(add(reason, 0x124)) + } + poolKey = PoolKey({ + currency0: Currency.wrap(currency0), + currency1: Currency.wrap(currency1), + fee: fee, + tickSpacing: tickSpacing, + hooks: IHooks(hooks) + }); + } + + function _revert(bytes memory reason) private pure { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } } } diff --git a/src/dopplerHooks/RehypeDopplerHookInitializer.sol b/src/dopplerHooks/RehypeDopplerHookInitializer.sol index 520022966..daef96505 100644 --- a/src/dopplerHooks/RehypeDopplerHookInitializer.sol +++ b/src/dopplerHooks/RehypeDopplerHookInitializer.sol @@ -21,6 +21,7 @@ import { AIRLOCK_OWNER_FEE_BPS, AirlockOwnerFeesClaimed, BPS_DENOMINATOR, + DEV_BUY_EXEMPTION_SLOT, EPSILON, FeeBeneficiariesNotConfigured, FeeBeneficiariesNotSupportedInDirectBuyback, @@ -64,6 +65,9 @@ contract RehypeDopplerHookInitializer is BaseDopplerHookInitializer, FeesManager /// @notice Quoter contract for simulating swaps Quoter public immutable quoter; + /// @notice Bundler authorized to consume the one-swap dev buy exemption. + address public immutable bundler; + /// @notice Position data for each pool mapping(PoolId poolId => Position position) public getPosition; @@ -87,10 +91,16 @@ contract RehypeDopplerHookInitializer is BaseDopplerHookInitializer, FeesManager /** * @param initializer Address of the DopplerHookInitializer contract * @param poolManager_ Address of the Uniswap V4 Pool Manager + * @param bundler_ Address of the authorized dev buy Bundler */ - constructor(address initializer, IPoolManager poolManager_) BaseDopplerHookInitializer(initializer) { + constructor( + address initializer, + IPoolManager poolManager_, + address bundler_ + ) BaseDopplerHookInitializer(initializer) { poolManager = poolManager_; quoter = new Quoter(poolManager_); + bundler = bundler_; } /// @inheritdoc BaseDopplerHookInitializer @@ -103,6 +113,12 @@ contract RehypeDopplerHookInitializer is BaseDopplerHookInitializer, FeesManager // Naive reinitialization would lead to overallocation of beneficiary fees and overlapping claims. require(getPoolInfo[poolId].asset == address(0), PoolAlreadyInitialized()); + // If _onInitialization is called by create (and not on hook reinitialization), open a temporary dev buy + // non-protocol fee exemption for one swap only. + if (_isAirlockCreate(asset)) { + _setDevBuyExemption(poolId); + } + getPoolInfo[poolId] = PoolInfo({ asset: asset, numeraire: initData.numeraire, buybackDst: initData.buybackDst }); _validateFeeDistribution(initData.feeDistributionInfo); @@ -164,7 +180,7 @@ contract RehypeDopplerHookInitializer is BaseDopplerHookInitializer, FeesManager PoolId poolId = key.toId(); - (Currency feeCurrency, int128 hookDelta) = _collectSwapFees(params, delta, key, poolId); + (Currency feeCurrency, int128 hookDelta) = _collectSwapFees(sender, params, delta, key, poolId); uint256 balance0 = getHookFees[poolId].fees0; uint256 balance1 = getHookFees[poolId].fees1; @@ -847,6 +863,7 @@ contract RehypeDopplerHookInitializer is BaseDopplerHookInitializer, FeesManager /** * @dev Collects swap fees from a swap and updates hook fee tracking + * @param sender Address that called PoolManager.swap * @param params Parameters of the swap * @param delta BalanceDelta of the swap * @param key Uniswap V4 pool key @@ -855,6 +872,7 @@ contract RehypeDopplerHookInitializer is BaseDopplerHookInitializer, FeesManager * @return feeDelta Amount of fee collected in feeCurrency */ function _collectSwapFees( + address sender, IPoolManager.SwapParams memory params, BalanceDelta delta, PoolKey memory key, @@ -882,19 +900,27 @@ contract RehypeDopplerHookInitializer is BaseDopplerHookInitializer, FeesManager feeBase = uint256(-inputAmount); } + // If a swap is occurring within the same call frame as create, then one swap is exempted from + // non-protocol fees. Only our Bundler is allowed to trigger this exemption. + bool devBuyExempt = _checkDevBuyExemption(poolId, sender); + if (devBuyExempt) { + _clearDevBuyExemption(poolId); + } + uint24 currentFee = _getCurrentFee(poolId); uint256 feeAmount = FullMath.mulDiv(feeBase, currentFee, SWAP_FEE_DENOMINATOR); + + // Calculate airlock owner fee (5% of total fee), and whether the remaining fees will be assessed. + uint256 airlockOwnerFee = FullMath.mulDiv(feeAmount, AIRLOCK_OWNER_FEE_BPS, BPS_DENOMINATOR); + uint256 remainingFee = devBuyExempt ? 0 : feeAmount - airlockOwnerFee; + uint256 collectedFee = devBuyExempt ? airlockOwnerFee : feeAmount; uint256 balanceOfFeeCurrency = feeCurrency.balanceOf(address(poolManager)); - if (balanceOfFeeCurrency < feeAmount) { + if (balanceOfFeeCurrency < collectedFee) { revert InsufficientFeeCurrency(); } - poolManager.take(feeCurrency, address(this), feeAmount); - - // Calculate airlock owner fee (5% of total fee) - uint256 airlockOwnerFee = FullMath.mulDiv(feeAmount, AIRLOCK_OWNER_FEE_BPS, BPS_DENOMINATOR); - uint256 remainingFee = feeAmount - airlockOwnerFee; + poolManager.take(feeCurrency, address(this), collectedFee); if (feeCurrency == key.currency0) { getHookFees[poolId].airlockOwnerFees0 += uint128(airlockOwnerFee); @@ -904,6 +930,61 @@ contract RehypeDopplerHookInitializer is BaseDopplerHookInitializer, FeesManager getHookFees[poolId].fees1 += uint128(remainingFee); } - return (feeCurrency, int128(uint128(feeAmount))); + return (feeCurrency, int128(uint128(collectedFee))); + } + + /// @dev Checks if the call is from Airlock.create, which is possible by checking if the poolInitializer + /// has been set yet. It is only configured once the initial call into the pool initializer is complete. + function _isAirlockCreate(address asset) internal view returns (bool) { + IAirlock airlock = IAirlock(address(DopplerHookInitializer(payable(INITIALIZER)).airlock())); + (,,,, address poolInitializer,,,,,) = airlock.getAssetData(asset); + return poolInitializer == address(0); + } + + function _setDevBuyExemption(PoolId poolId) internal { + bytes32 slot = _devBuyExemptionSlot(poolId); + assembly ("memory-safe") { + tstore(slot, 1) + } + } + + function _clearDevBuyExemption(PoolId poolId) internal { + bytes32 slot = _devBuyExemptionSlot(poolId); + assembly ("memory-safe") { + tstore(slot, 0) + } + } + + /// @dev Checks if the sender is our Bundler, and checks transient storage to confirm if this swap is + /// occurring within the Airlock.create call frame. + function _checkDevBuyExemption(PoolId poolId, address sender) internal view returns (bool exempt) { + if (sender != bundler) return false; + + bytes32 slot = _devBuyExemptionSlot(poolId); + assembly ("memory-safe") { + exempt := tload(slot) + } + } + + function _devBuyExemptionSlot(PoolId poolId) internal pure returns (bytes32) { + return keccak256(abi.encode(DEV_BUY_EXEMPTION_SLOT, PoolId.unwrap(poolId))); } } + +interface IAirlock { + function getAssetData(address asset) + external + view + returns ( + address numeraire, + address timelock, + address governance, + address liquidityMigrator, + address poolInitializer, + address pool, + address migrationPool, + uint256 numTokensToSell, + uint256 totalSupply, + address integrator + ); +} diff --git a/src/dopplerHooks/RehypeDopplerHookMigrator.sol b/src/dopplerHooks/RehypeDopplerHookMigrator.sol index 545fa2fef..c1e09a91f 100644 --- a/src/dopplerHooks/RehypeDopplerHookMigrator.sol +++ b/src/dopplerHooks/RehypeDopplerHookMigrator.sol @@ -430,7 +430,9 @@ contract RehypeDopplerHookMigrator is BaseDopplerHookMigrator, ReentrancyGuard { salt: position.salt }), new bytes(0) - ) returns (BalanceDelta delta, BalanceDelta) { + ) returns ( + BalanceDelta delta, BalanceDelta + ) { callerDelta = delta; } catch { return toBalanceDelta(0, 0); diff --git a/src/types/RehypeTypes.sol b/src/types/RehypeTypes.sol index 173ac98c9..80cd09ea7 100644 --- a/src/types/RehypeTypes.sol +++ b/src/types/RehypeTypes.sol @@ -60,6 +60,9 @@ uint256 constant AIRLOCK_OWNER_FEE_BPS = 500; /// @dev Basis points denominator uint256 constant BPS_DENOMINATOR = 10_000; +/// @dev Storage slot for temporary dev buy fee exemption flag +bytes32 constant DEV_BUY_EXEMPTION_SLOT = keccak256("doppler.rehype.devBuyExemption"); + /// @notice Thrown when a fee exceeds the maximum swap fee error FeeTooHigh(uint24 fee); diff --git a/test/integration/Bundler.t.sol b/test/integration/Bundler.t.sol new file mode 100644 index 000000000..283b5a9d7 --- /dev/null +++ b/test/integration/Bundler.t.sol @@ -0,0 +1,769 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { SafeTransferLib } from "@solady/utils/SafeTransferLib.sol"; +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 { LPFeeLibrary } from "@v4-core/libraries/LPFeeLibrary.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 } from "@v4-core/types/Currency.sol"; +import { PoolId, PoolIdLibrary } from "@v4-core/types/PoolId.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; +import { Airlock, CreateParams, ModuleState } from "src/Airlock.sol"; +import { + Bundled, + Bundler, + ExactInputAmountZero, + ExactInputNotFullySpent, + InvalidAddress, + InvalidNativeValue, + InvalidPool, + InvalidRecipient, + SenderNotPoolManager, + SenderNotSelf +} from "src/Bundler.sol"; +import { ON_INITIALIZATION_FLAG, ON_SWAP_FLAG } from "src/base/BaseDopplerHookInitializer.sol"; +import { RehypeDopplerHookInitializer } from "src/dopplerHooks/RehypeDopplerHookInitializer.sol"; +import { GovernanceFactory } from "src/governance/GovernanceFactory.sol"; +import { DopplerHookInitializer, InitData, PoolStatus } from "src/initializers/DopplerHookInitializer.sol"; +import { IGovernanceFactory } from "src/interfaces/IGovernanceFactory.sol"; +import { ILiquidityMigrator } from "src/interfaces/ILiquidityMigrator.sol"; +import { IPoolInitializer } from "src/interfaces/IPoolInitializer.sol"; +import { ITokenFactory } from "src/interfaces/ITokenFactory.sol"; +import { Curve } from "src/libraries/Multicurve.sol"; +import { DopplerERC20V1Factory } from "src/tokens/DopplerERC20V1Factory.sol"; +import { BeneficiaryData } from "src/types/BeneficiaryData.sol"; +import { + AIRLOCK_OWNER_FEE_BPS, + BPS_DENOMINATOR, + FeeDistributionInfo, + FeeRoutingMode, + InitData as RehypeInitData, + SWAP_FEE_DENOMINATOR +} from "src/types/RehypeTypes.sol"; +import { WAD } from "src/types/Wad.sol"; +import { dopplerERC20V1FactoryData, predictDopplerERC20V1Address } from "test/shared/DopplerERC20V1FactoryHelper.sol"; + +contract BundlerLiquidityMigratorMock is ILiquidityMigrator { + function initialize(address, address, bytes calldata) external pure returns (address) { + return address(0xdeadbeef); + } + + function migrate(uint160, address, address, address) external payable returns (uint256) { + return 0; + } +} + +contract InvalidPoolInitializerMock is IPoolInitializer { + function initialize(address asset, address, uint256, bytes32, bytes calldata) external pure returns (address) { + return asset; + } + + function exitLiquidity(address) + external + pure + returns (uint160, address, uint128, uint128, address, uint128, uint128) + { + revert(); + } + + function getState(address) + external + pure + returns (address, uint256, address, bytes memory, PoolStatus, PoolKey memory, int24) + { + PoolKey memory poolKey; + poolKey.currency0 = Currency.wrap(address(1)); + poolKey.currency1 = Currency.wrap(address(2)); + return (address(0), 0, address(0), bytes(""), PoolStatus.Initialized, poolKey, 0); + } +} + +contract BundlerIntegrationTest is Deployers { + using PoolIdLibrary for PoolKey; + + uint24 internal constant START_FEE = 800_000; + uint128 internal constant DEV_BUY_AMOUNT = 1 ether; + uint256 internal constant INITIAL_SUPPLY = 1e27; + + address internal airlockOwner = makeAddr("airlockOwner"); + address internal buybackDst = makeAddr("buybackDst"); + address internal payer = makeAddr("payer"); + address internal recipient = makeAddr("recipient"); + address internal attacker = makeAddr("attacker"); + + Airlock internal airlock; + Bundler internal bundler; + DopplerHookInitializer internal initializer; + DopplerERC20V1Factory internal tokenFactory; + GovernanceFactory internal governanceFactory; + BundlerLiquidityMigratorMock internal liquidityMigrator; + RehypeDopplerHookInitializer internal rehype; + TestERC20 internal erc20Numeraire; + + function setUp() public { + deployFreshManagerAndRouters(); + + airlock = new Airlock(airlockOwner); + tokenFactory = new DopplerERC20V1Factory(address(airlock)); + governanceFactory = new GovernanceFactory(address(airlock)); + liquidityMigrator = new BundlerLiquidityMigratorMock(); + erc20Numeraire = new TestERC20(1e48); + + 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)); + + bundler = new Bundler(airlock, manager); + rehype = new RehypeDopplerHookInitializer(address(initializer), manager, address(bundler)); + + address[] memory modules = new address[](4); + modules[0] = address(tokenFactory); + modules[1] = address(governanceFactory); + modules[2] = address(initializer); + modules[3] = address(liquidityMigrator); + + ModuleState[] memory states = new ModuleState[](4); + states[0] = ModuleState.TokenFactory; + states[1] = ModuleState.GovernanceFactory; + states[2] = ModuleState.PoolInitializer; + states[3] = ModuleState.LiquidityMigrator; + + vm.startPrank(airlockOwner); + airlock.setModuleState(modules, states); + + address[] memory hooks = new address[](1); + hooks[0] = address(rehype); + uint256[] memory flags = new uint256[](1); + flags[0] = ON_INITIALIZATION_FLAG | ON_SWAP_FLAG; + initializer.setDopplerHookState(hooks, flags); + vm.stopPrank(); + } + + function test_bundle_ERC20Numeraire_AssetCurrency0_PaysOnlyOwnerFeeAndUsesRecipient() public { + bytes32 salt = _saltForAssetOrientation(address(erc20Numeraire), true, 1); + _assertERC20Bundle(salt, true); + } + + function test_bundle_ERC20Numeraire_AssetCurrency1_PaysOnlyOwnerFeeAndUsesRecipient() public { + bytes32 salt = _saltForAssetOrientation(address(erc20Numeraire), false, 1000); + _assertERC20Bundle(salt, false); + } + + function test_bundle_NativeNumeraire_SpendsAllValueAndPaysOnlyOwnerFee() public { + bytes32 salt = bytes32(uint256(2000)); + (CreateParams memory params, address predictedAsset) = _createParams(address(0), salt, START_FEE); + + vm.deal(payer, DEV_BUY_AMOUNT); + vm.prank(payer); + (address asset, PoolKey memory poolKey, address governance, address timelock, uint128 amountOut) = + bundler.bundle{ value: DEV_BUY_AMOUNT }(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + + _assertCreationOutputs(asset, poolKey, governance, timelock, predictedAsset, address(0)); + _assertGovernanceDeployed(governance, timelock); + assertGt(amountOut, 0); + assertEq(asset.balance, 0, "asset must be an ERC20, not native output"); + assertEq(TestERC20(asset).balanceOf(recipient), amountOut); + assertEq(TestERC20(asset).balanceOf(payer), 0); + assertEq(payer.balance, 0, "successful exact-input dev buy must consume all supplied ETH"); + assertEq(address(bundler).balance, 0, "Bundler must not retain native numeraire"); + + (,,,,, PoolKey memory key,) = initializer.getState(asset); + assertEq(Currency.unwrap(key.currency0), address(0)); + assertEq(Currency.unwrap(key.currency1), asset); + assertEq(PoolId.unwrap(poolKey.toId()), PoolId.unwrap(key.toId())); + _assertOwnerOnlyDevBuyFee(key.toId(), key, amountOut); + } + + function test_simulateBundle_ERC20Numeraire_ReturnsExactBundleResultAndRevertsState() public { + bytes32 salt = bytes32(uint256(2500)); + (CreateParams memory params, address predictedAsset) = _createParams(address(erc20Numeraire), salt, START_FEE); + + vm.prank(payer); + ( + address quotedAsset, + PoolKey memory quotedPoolKey, + address quotedGovernance, + address quotedTimelock, + uint128 quotedAmountOut + ) = bundler.simulateBundle(params, DEV_BUY_AMOUNT); + + _assertCreationOutputs( + quotedAsset, quotedPoolKey, quotedGovernance, quotedTimelock, predictedAsset, address(erc20Numeraire) + ); + assertGt(quotedAmountOut, 0); + assertEq(predictedAsset.code.length, 0, "simulation must revert asset deployment"); + assertEq(quotedGovernance.code.length, 0, "simulation must revert governance deployment"); + assertEq(quotedTimelock.code.length, 0, "simulation must revert timelock deployment"); + assertEq(erc20Numeraire.balanceOf(payer), 0, "simulation must not require or spend numeraire"); + + erc20Numeraire.transfer(payer, DEV_BUY_AMOUNT); + vm.prank(payer); + erc20Numeraire.approve(address(bundler), DEV_BUY_AMOUNT); + vm.expectEmit(true, false, false, true, address(bundler)); + emit Bundled(recipient, DEV_BUY_AMOUNT, quotedAmountOut, quotedPoolKey); + vm.prank(payer); + (address asset, PoolKey memory poolKey, address governance, address timelock, uint128 amountOut) = + bundler.bundle(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + + assertEq(asset, quotedAsset); + assertEq(PoolId.unwrap(poolKey.toId()), PoolId.unwrap(quotedPoolKey.toId())); + assertEq(governance, quotedGovernance, "simulation must return exact governance address"); + assertEq(timelock, quotedTimelock, "simulation must return exact timelock address"); + _assertGovernanceDeployed(governance, timelock); + assertEq(amountOut, quotedAmountOut, "simulation must return the exact bundle output"); + } + + function test_simulateBundle_NativeNumeraire_DoesNotRequireValueAndReturnsExactOutput() public { + bytes32 salt = bytes32(uint256(2600)); + (CreateParams memory params, address predictedAsset) = _createParams(address(0), salt, START_FEE); + + vm.prank(payer); + ( + address quotedAsset, + PoolKey memory quotedPoolKey, + address quotedGovernance, + address quotedTimelock, + uint128 quotedAmountOut + ) = bundler.simulateBundle(params, DEV_BUY_AMOUNT); + + _assertCreationOutputs(quotedAsset, quotedPoolKey, quotedGovernance, quotedTimelock, predictedAsset, address(0)); + assertEq(payer.balance, 0, "simulation must not require native value"); + assertEq(predictedAsset.code.length, 0, "simulation must revert asset deployment"); + + vm.deal(payer, DEV_BUY_AMOUNT); + vm.prank(payer); + (address asset, PoolKey memory poolKey,,, uint128 amountOut) = + bundler.bundle{ value: DEV_BUY_AMOUNT }(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + + assertEq(asset, quotedAsset); + assertEq(PoolId.unwrap(poolKey.toId()), PoolId.unwrap(quotedPoolKey.toId())); + assertEq(amountOut, quotedAmountOut, "simulation must return the exact bundle output"); + } + + function test_bundle_PlainDopplerHookInitializer_SpendsExactInputAndTransfersOutputToRecipient() public { + (CreateParams memory params, address predictedAsset) = + _createPlainDopplerParams(address(erc20Numeraire), bytes32(uint256(2700))); + + erc20Numeraire.transfer(payer, DEV_BUY_AMOUNT); + vm.prank(payer); + erc20Numeraire.approve(address(bundler), DEV_BUY_AMOUNT); + + vm.prank(payer); + (address asset, PoolKey memory poolKey, address governance, address timelock, uint128 amountOut) = + bundler.bundle(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + + _assertPlainDopplerCreationOutputs( + asset, poolKey, governance, timelock, predictedAsset, address(erc20Numeraire) + ); + _assertGovernanceDeployed(governance, timelock); + assertGt(amountOut, 0, "exact-input buy must return asset tokens"); + assertEq(erc20Numeraire.balanceOf(payer), 0, "exact-input buy must consume the full payer input"); + assertEq(erc20Numeraire.balanceOf(address(bundler)), 0, "Bundler must not retain numeraire"); + assertEq(TestERC20(asset).balanceOf(recipient), amountOut, "recipient must receive the quoted output"); + assertEq(TestERC20(asset).balanceOf(payer), 0, "payer must not receive output sent to the recipient"); + _assertPlainDopplerPool(asset, poolKey); + } + + function test_simulateBundle_PlainDopplerHookInitializer_RevertsStateAndMatchesExecution() public { + (CreateParams memory params, address predictedAsset) = + _createPlainDopplerParams(address(erc20Numeraire), bytes32(uint256(2800))); + + vm.prank(payer); + ( + address quotedAsset, + PoolKey memory quotedPoolKey, + address quotedGovernance, + address quotedTimelock, + uint128 quotedAmountOut + ) = bundler.simulateBundle(params, DEV_BUY_AMOUNT); + + _assertPlainDopplerCreationOutputs( + quotedAsset, quotedPoolKey, quotedGovernance, quotedTimelock, predictedAsset, address(erc20Numeraire) + ); + assertGt(quotedAmountOut, 0, "simulation must quote real output"); + assertEq(predictedAsset.code.length, 0, "simulation must revert asset deployment"); + assertEq(quotedGovernance.code.length, 0, "simulation must revert governance deployment"); + assertEq(quotedTimelock.code.length, 0, "simulation must revert timelock deployment"); + (,,,, PoolStatus statusAfterSimulation,,) = initializer.getState(predictedAsset); + assertEq( + uint8(statusAfterSimulation), + uint8(PoolStatus.Uninitialized), + "simulation must revert initializer launch state" + ); + assertEq(erc20Numeraire.balanceOf(payer), 0, "simulation must not require or spend numeraire"); + + erc20Numeraire.transfer(payer, DEV_BUY_AMOUNT); + vm.prank(payer); + erc20Numeraire.approve(address(bundler), DEV_BUY_AMOUNT); + vm.prank(payer); + (address asset, PoolKey memory poolKey, address governance, address timelock, uint128 amountOut) = + bundler.bundle(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + + assertEq(asset, quotedAsset, "simulation must return the exact asset address"); + assertEq(PoolId.unwrap(poolKey.toId()), PoolId.unwrap(quotedPoolKey.toId()), "simulation pool must match"); + assertEq(governance, quotedGovernance, "simulation must return the exact governance address"); + assertEq(timelock, quotedTimelock, "simulation must return the exact timelock address"); + assertEq(amountOut, quotedAmountOut, "simulation must return the exact bundle output"); + assertEq(erc20Numeraire.balanceOf(payer), 0, "executed exact-input buy must consume the full payer input"); + assertEq(erc20Numeraire.balanceOf(address(bundler)), 0, "Bundler must not retain numeraire"); + assertEq(TestERC20(asset).balanceOf(recipient), amountOut, "recipient must receive the quoted output"); + assertEq(TestERC20(asset).balanceOf(payer), 0, "payer must not receive output sent to the recipient"); + _assertGovernanceDeployed(governance, timelock); + _assertPlainDopplerPool(asset, poolKey); + } + + function test_bundle_NextSwapPaysNormalRehypeFee() public { + bytes32 salt = bytes32(uint256(3000)); + (CreateParams memory params, address predictedAsset) = _createParams(address(erc20Numeraire), salt, START_FEE); + + erc20Numeraire.transfer(payer, 2 * DEV_BUY_AMOUNT); + vm.prank(payer); + erc20Numeraire.approve(address(bundler), DEV_BUY_AMOUNT); + vm.prank(payer); + (address asset,,,,) = bundler.bundle(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + assertEq(asset, predictedAsset); + + (,,,,, PoolKey memory key,) = initializer.getState(asset); + PoolId id = key.toId(); + (, uint256 beneficiaryBefore,) = _assetFeeBuckets(id, key, asset); + assertEq(beneficiaryBefore, 0, "dev buy must not accrue ordinary Rehype fees"); + + erc20Numeraire.transfer(attacker, DEV_BUY_AMOUNT); + vm.startPrank(attacker); + erc20Numeraire.approve(address(swapRouter), DEV_BUY_AMOUNT); + swapRouter.swap( + key, + _buyParams(key, address(erc20Numeraire), DEV_BUY_AMOUNT), + PoolSwapTest.TestSettings(false, false), + bytes("") + ); + vm.stopPrank(); + + (, uint256 beneficiaryAfter,) = _assetFeeBuckets(id, key, asset); + assertGt(beneficiaryAfter, beneficiaryBefore, "post-dev-buy swap must pay ordinary Rehype fees"); + } + + function test_directCreate_ThirdPartyCannotStealDevBuyExemption() public { + bytes32 salt = bytes32(uint256(4000)); + (CreateParams memory params,) = _createParams(address(erc20Numeraire), salt, START_FEE); + (address asset,,,,) = airlock.create(params); + (,,,,, PoolKey memory key,) = initializer.getState(asset); + PoolId id = key.toId(); + + erc20Numeraire.transfer(attacker, 2 * DEV_BUY_AMOUNT); + vm.startPrank(attacker); + erc20Numeraire.approve(address(swapRouter), 2 * DEV_BUY_AMOUNT); + + swapRouter.swap( + key, + _buyParams(key, address(erc20Numeraire), DEV_BUY_AMOUNT), + PoolSwapTest.TestSettings(false, false), + bytes("") + ); + (, uint256 beneficiaryAfterFirst,) = _assetFeeBuckets(id, key, asset); + assertGt(beneficiaryAfterFirst, 0, "first swap must pay ordinary Rehype fee"); + + swapRouter.swap( + key, + _buyParams(key, address(erc20Numeraire), DEV_BUY_AMOUNT), + PoolSwapTest.TestSettings(false, false), + bytes("") + ); + vm.stopPrank(); + + (, uint256 beneficiaryAfterSecond,) = _assetFeeBuckets(id, key, asset); + assertGt(beneficiaryAfterSecond, beneficiaryAfterFirst, "external sender must never consume the exemption"); + } + + function test_bundle_RevertsWhenExactInputIsZero() public { + (CreateParams memory params,) = _createParams(address(erc20Numeraire), bytes32(uint256(5000)), START_FEE); + vm.expectRevert(ExactInputAmountZero.selector); + bundler.bundle(params, _noVesting(), 0, recipient); + } + + function test_bundle_RevertsWhenRecipientIsZero() public { + (CreateParams memory params,) = _createParams(address(erc20Numeraire), bytes32(uint256(5001)), START_FEE); + vm.expectRevert(InvalidRecipient.selector); + bundler.bundle(params, _noVesting(), DEV_BUY_AMOUNT, address(0)); + } + + function test_bundle_RevertsWhenERC20BuySendsNativeValue() public { + (CreateParams memory params,) = _createParams(address(erc20Numeraire), bytes32(uint256(5002)), START_FEE); + vm.deal(address(this), 1); + vm.expectRevert(InvalidNativeValue.selector); + bundler.bundle{ value: 1 }(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + } + + function test_bundle_RevertsWhenNativeValueDoesNotEqualExactInput() public { + (CreateParams memory params,) = _createParams(address(0), bytes32(uint256(5003)), START_FEE); + vm.deal(address(this), DEV_BUY_AMOUNT - 1); + vm.expectRevert(InvalidNativeValue.selector); + bundler.bundle{ value: DEV_BUY_AMOUNT - 1 }(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + } + + function test_bundle_RevertsWhenNativeValueExceedsExactInput() public { + (CreateParams memory params,) = _createParams(address(0), bytes32(uint256(5007)), START_FEE); + vm.deal(address(this), DEV_BUY_AMOUNT + 1); + + vm.expectRevert(InvalidNativeValue.selector); + bundler.bundle{ value: DEV_BUY_AMOUNT + 1 }(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + } + + function test_bundle_RevertsWhenERC20AllowanceIsMissing() public { + (CreateParams memory params,) = _createParams(address(erc20Numeraire), bytes32(uint256(5004)), START_FEE); + erc20Numeraire.transfer(payer, DEV_BUY_AMOUNT); + + vm.prank(payer); + vm.expectRevert(SafeTransferLib.TransferFromFailed.selector); + bundler.bundle(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + } + + function test_bundle_RevertsWhenPoolCannotSpendFullExactInput() public { + (CreateParams memory params,) = _createParams(address(erc20Numeraire), bytes32(uint256(5005)), START_FEE); + uint128 excessiveInput = type(uint128).max; + erc20Numeraire.transfer(payer, excessiveInput); + vm.prank(payer); + erc20Numeraire.approve(address(bundler), excessiveInput); + + vm.prank(payer); + vm.expectPartialRevert(ExactInputNotFullySpent.selector); + bundler.bundle(params, _noVesting(), excessiveInput, recipient); + } + + function test_bundle_RevertsWhenInitializerReturnsDifferentPair() public { + InvalidPoolInitializerMock invalidInitializer = new InvalidPoolInitializerMock(); + address[] memory modules = new address[](1); + modules[0] = address(invalidInitializer); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.PoolInitializer; + vm.prank(airlockOwner); + airlock.setModuleState(modules, states); + + CreateParams memory params = CreateParams({ + initialSupply: INITIAL_SUPPLY, + numTokensToSell: INITIAL_SUPPLY, + numeraire: address(erc20Numeraire), + tokenFactory: ITokenFactory(tokenFactory), + tokenFactoryData: dopplerERC20V1FactoryData( + "Invalid Pool", "BAD", "TOKEN_URI", 0, 0, address(0), new address[](0) + ), + governanceFactory: IGovernanceFactory(governanceFactory), + governanceFactoryData: _governanceFactoryData(), + poolInitializer: IPoolInitializer(address(invalidInitializer)), + poolInitializerData: bytes(""), + liquidityMigrator: ILiquidityMigrator(liquidityMigrator), + liquidityMigratorData: bytes(""), + integrator: address(0), + salt: bytes32(uint256(5006)) + }); + address predictedAsset = predictDopplerERC20V1Address(tokenFactory, params.salt); + vm.expectRevert(InvalidPool.selector); + bundler.simulateBundle(params, DEV_BUY_AMOUNT); + assertEq(predictedAsset.code.length, 0, "failed simulation must revert asset deployment"); + + vm.expectRevert(InvalidPool.selector); + bundler.bundle(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + } + + function testFuzz_simulateBundle_ReturnsExactExecutionResult(uint128 fuzzedAmountIn) public { + uint128 amountIn = uint128(bound(fuzzedAmountIn, 1e6, 100 ether)); + bytes32 salt = keccak256(abi.encode("fuzz bundle", fuzzedAmountIn)); + (CreateParams memory params,) = _createParams(address(erc20Numeraire), salt, START_FEE); + + ( + address quotedAsset, + PoolKey memory quotedPoolKey, + address quotedGovernance, + address quotedTimelock, + uint128 quotedAmountOut + ) = bundler.simulateBundle(params, amountIn); + + erc20Numeraire.transfer(payer, amountIn); + vm.startPrank(payer); + erc20Numeraire.approve(address(bundler), amountIn); + (address asset, PoolKey memory poolKey, address governance, address timelock, uint128 amountOut) = + bundler.bundle(params, _noVesting(), amountIn, recipient); + vm.stopPrank(); + + assertEq(asset, quotedAsset); + assertEq(PoolId.unwrap(poolKey.toId()), PoolId.unwrap(quotedPoolKey.toId())); + assertEq(governance, quotedGovernance); + assertEq(timelock, quotedTimelock); + assertEq(amountOut, quotedAmountOut); + assertEq(TestERC20(asset).balanceOf(recipient), amountOut); + } + + function test_simulateBundle_RevertsWhenExactInputIsZero() public { + (CreateParams memory params,) = _createParams(address(erc20Numeraire), bytes32(uint256(5100)), START_FEE); + + vm.expectRevert(ExactInputAmountZero.selector); + bundler.simulateBundle(params, 0); + } + + function test_simulateBundle_BubblesSwapFailureAndRevertsCreation() public { + (CreateParams memory params, address predictedAsset) = + _createParams(address(erc20Numeraire), bytes32(uint256(5101)), START_FEE); + + vm.expectPartialRevert(ExactInputNotFullySpent.selector); + bundler.simulateBundle(params, type(uint128).max); + + assertEq(predictedAsset.code.length, 0, "failed simulation must revert asset deployment"); + } + + function test_simulateBundleHelper_RevertsForExternalCaller() public { + (CreateParams memory params,) = _createParams(address(erc20Numeraire), bytes32(uint256(5102)), START_FEE); + + vm.expectRevert(SenderNotSelf.selector); + bundler._simulateBundle(params, DEV_BUY_AMOUNT); + } + + function test_unlockCallback_RevertsForExternalCaller() public { + vm.expectRevert(SenderNotPoolManager.selector); + bundler.unlockCallback(bytes("")); + } + + function test_constructor_RevertsForZeroDependencies() public { + vm.expectRevert(InvalidAddress.selector); + new Bundler(Airlock(payable(address(0))), manager); + + vm.expectRevert(InvalidAddress.selector); + new Bundler(airlock, IPoolManager(address(0))); + } + + function _assertERC20Bundle(bytes32 salt, bool assetIsCurrency0) internal { + (CreateParams memory params, address predictedAsset) = _createParams(address(erc20Numeraire), salt, START_FEE); + assertEq(predictedAsset < address(erc20Numeraire), assetIsCurrency0, "unexpected test orientation"); + + erc20Numeraire.transfer(payer, DEV_BUY_AMOUNT); + vm.prank(payer); + erc20Numeraire.approve(address(bundler), DEV_BUY_AMOUNT); + + uint256 payerNumeraireBefore = erc20Numeraire.balanceOf(payer); + vm.prank(payer); + (address asset, PoolKey memory poolKey, address governance, address timelock, uint128 amountOut) = + bundler.bundle(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + + _assertCreationOutputs(asset, poolKey, governance, timelock, predictedAsset, address(erc20Numeraire)); + _assertGovernanceDeployed(governance, timelock); + assertGt(amountOut, 0); + assertEq(erc20Numeraire.balanceOf(payer), payerNumeraireBefore - DEV_BUY_AMOUNT); + assertEq(erc20Numeraire.balanceOf(address(bundler)), 0); + assertEq(TestERC20(asset).balanceOf(recipient), amountOut); + assertEq(TestERC20(asset).balanceOf(payer), 0); + + (,,,,, PoolKey memory key,) = initializer.getState(asset); + assertEq(Currency.unwrap(assetIsCurrency0 ? key.currency0 : key.currency1), asset); + assertEq(Currency.unwrap(assetIsCurrency0 ? key.currency1 : key.currency0), address(erc20Numeraire)); + assertEq(PoolId.unwrap(poolKey.toId()), PoolId.unwrap(key.toId())); + _assertOwnerOnlyDevBuyFee(key.toId(), key, amountOut); + } + + function _assertCreationOutputs( + address asset, + PoolKey memory poolKey, + address governance, + address timelock, + address predictedAsset, + address numeraire + ) internal view { + assertEq(asset, predictedAsset); + assertEq(Currency.unwrap(poolKey.currency0), asset < numeraire ? asset : numeraire); + assertEq(Currency.unwrap(poolKey.currency1), asset < numeraire ? numeraire : asset); + assertEq(poolKey.fee, LPFeeLibrary.DYNAMIC_FEE_FLAG); + assertEq(poolKey.tickSpacing, 8); + assertEq(address(poolKey.hooks), address(initializer)); + assertNotEq(governance, timelock, "governance and timelock must be distinct"); + } + + function _assertPlainDopplerCreationOutputs( + address asset, + PoolKey memory poolKey, + address governance, + address timelock, + address predictedAsset, + address numeraire + ) internal view { + assertEq(asset, predictedAsset); + assertEq(Currency.unwrap(poolKey.currency0), asset < numeraire ? asset : numeraire); + assertEq(Currency.unwrap(poolKey.currency1), asset < numeraire ? numeraire : asset); + assertEq(poolKey.fee, 0, "plain Doppler pool must use its configured static fee"); + assertEq(poolKey.tickSpacing, 8); + assertEq(address(poolKey.hooks), address(initializer)); + assertNotEq(governance, timelock, "governance and timelock must be distinct"); + } + + function _assertPlainDopplerPool(address asset, PoolKey memory bundledPoolKey) internal view { + (,, address dopplerHook,,, PoolKey memory initializedPoolKey,) = initializer.getState(asset); + assertEq(dopplerHook, address(0), "plain Doppler pool must not configure a Rehype hook"); + assertEq( + PoolId.unwrap(initializedPoolKey.toId()), + PoolId.unwrap(bundledPoolKey.toId()), + "Bundler must return the initialized pool" + ); + assertEq(_poolAsset(initializedPoolKey.toId()), address(0), "plain pool must not invoke Rehype initialization"); + } + + function _assertGovernanceDeployed(address governance, address timelock) internal view { + assertTrue(governance.code.length > 0, "governance must be deployed"); + assertTrue(timelock.code.length > 0, "timelock must be deployed"); + } + + function _assertOwnerOnlyDevBuyFee(PoolId id, PoolKey memory key, uint256 netAmountOut) internal view { + address asset = _poolAsset(id); + (uint256 ownerFee, uint256 beneficiaryFee, uint256 pendingFee) = _assetFeeBuckets(id, key, asset); + + assertGt(ownerFee, 0, "dev buy must accrue the Airlock owner share"); + assertEq(beneficiaryFee, 0, "dev buy must exempt ordinary Rehype fees"); + assertEq(pendingFee, 0, "dev buy must not leave distributable fees pending"); + uint256 grossOutput = netAmountOut + ownerFee; + uint256 assessedFee = grossOutput * START_FEE / SWAP_FEE_DENOMINATOR; + uint256 expectedOwnerFee = assessedFee * AIRLOCK_OWNER_FEE_BPS / BPS_DENOMINATOR; + assertEq(ownerFee, expectedOwnerFee, "owner must receive 5% of the otherwise assessed Rehype fee"); + } + + function _assetFeeBuckets( + PoolId id, + PoolKey memory key, + address asset + ) internal view returns (uint256 ownerFee, uint256 beneficiaryFee, uint256 pendingFee) { + ( + uint128 fees0, + uint128 fees1, + uint128 beneficiaryFees0, + uint128 beneficiaryFees1, + uint128 ownerFees0, + uint128 ownerFees1, + ) = rehype.getHookFees(id); + + if (Currency.unwrap(key.currency0) == asset) { + return (ownerFees0, beneficiaryFees0, fees0); + } + return (ownerFees1, beneficiaryFees1, fees1); + } + + function _poolAsset(PoolId id) internal view returns (address asset) { + (asset,,) = rehype.getPoolInfo(id); + } + + function _buyParams( + PoolKey memory key, + address numeraire, + uint128 amountIn + ) internal pure returns (IPoolManager.SwapParams memory) { + bool zeroForOne = Currency.unwrap(key.currency0) == numeraire; + return IPoolManager.SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: -int256(uint256(amountIn)), + sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }); + } + + function _createParams( + address numeraire, + bytes32 salt, + uint24 startFee + ) internal view returns (CreateParams memory params, address predictedAsset) { + predictedAsset = predictDopplerERC20V1Address(tokenFactory, salt); + Curve[] memory curves = new Curve[](10); + for (uint256 i; i < curves.length; ++i) { + curves[i] = + Curve({ tickLower: int24(uint24(i * 16_000)), tickUpper: 240_000, numPositions: 10, shares: WAD / 10 }); + } + + FeeDistributionInfo memory distribution = FeeDistributionInfo({ + assetFeesToAssetBuybackWad: 0, + assetFeesToNumeraireBuybackWad: 0, + assetFeesToBeneficiaryWad: WAD, + assetFeesToLpWad: 0, + numeraireFeesToAssetBuybackWad: 0, + numeraireFeesToNumeraireBuybackWad: 0, + numeraireFeesToBeneficiaryWad: WAD, + numeraireFeesToLpWad: 0 + }); + + RehypeInitData memory rehypeData = RehypeInitData({ + numeraire: numeraire, + buybackDst: buybackDst, + startFee: startFee, + endFee: startFee, + durationSeconds: 0, + startingTime: 0, + feeRoutingMode: FeeRoutingMode.DirectBuyback, + feeDistributionInfo: distribution, + feeBeneficiaries: new BeneficiaryData[](0) + }); + + InitData memory initData = InitData({ + fee: 0, + tickSpacing: 8, + farTick: 200_000, + curves: curves, + beneficiaries: new BeneficiaryData[](0), + dopplerHook: address(rehype), + onInitializationDopplerHookCalldata: abi.encode(rehypeData), + graduationDopplerHookCalldata: bytes("") + }); + + params = CreateParams({ + initialSupply: INITIAL_SUPPLY, + numTokensToSell: INITIAL_SUPPLY, + numeraire: numeraire, + tokenFactory: ITokenFactory(tokenFactory), + tokenFactoryData: dopplerERC20V1FactoryData( + "Bundler Test", "BUNDLE", "TOKEN_URI", 0, 0, address(0), new address[](0) + ), + governanceFactory: IGovernanceFactory(governanceFactory), + governanceFactoryData: _governanceFactoryData(), + poolInitializer: IPoolInitializer(initializer), + poolInitializerData: abi.encode(initData), + liquidityMigrator: ILiquidityMigrator(liquidityMigrator), + liquidityMigratorData: bytes(""), + integrator: address(0), + salt: salt + }); + } + + function _createPlainDopplerParams( + address numeraire, + bytes32 salt + ) internal view returns (CreateParams memory params, address predictedAsset) { + (params, predictedAsset) = _createParams(numeraire, salt, 0); + InitData memory initData = abi.decode(params.poolInitializerData, (InitData)); + initData.fee = 0; + initData.dopplerHook = address(0); + initData.onInitializationDopplerHookCalldata = bytes(""); + initData.graduationDopplerHookCalldata = bytes(""); + params.poolInitializerData = abi.encode(initData); + } + + function _saltForAssetOrientation( + address numeraire, + bool assetIsCurrency0, + uint256 seed + ) internal view returns (bytes32) { + for (uint256 i; i < 512; ++i) { + bytes32 salt = bytes32(seed + i); + if ((predictDopplerERC20V1Address(tokenFactory, salt) < numeraire) == assetIsCurrency0) return salt; + } + revert("orientation not found"); + } + + function _noVesting() internal pure returns (Bundler.VestingParams memory) { + return Bundler.VestingParams({ permissionlessClaim: false, vestingDuration: 0, cliffDuration: 0 }); + } + + function _governanceFactoryData() internal pure returns (bytes memory) { + return abi.encode("Bundler Test", uint48(7200), uint32(50_400), uint256(0)); + } +} diff --git a/test/integration/BundlerUnsupportedInitializers.t.sol b/test/integration/BundlerUnsupportedInitializers.t.sol new file mode 100644 index 000000000..657a37aa5 --- /dev/null +++ b/test/integration/BundlerUnsupportedInitializers.t.sol @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Deployers } from "@uniswap/v4-core/test/utils/Deployers.sol"; +import { IUniswapV3Factory } from "@v3-core/interfaces/IUniswapV3Factory.sol"; +import { TestERC20 } from "@v4-core/test/TestERC20.sol"; +import { Airlock, CreateParams, ModuleState } from "src/Airlock.sol"; +import { Bundler } from "src/Bundler.sol"; +import { GovernanceFactory } from "src/governance/GovernanceFactory.sol"; +import { + InitData as LockableUniswapV3InitData, + LockableUniswapV3Initializer +} from "src/initializers/LockableUniswapV3Initializer.sol"; +import { DopplerDeployer, UniswapV4Initializer } from "src/initializers/UniswapV4Initializer.sol"; +import { IGovernanceFactory } from "src/interfaces/IGovernanceFactory.sol"; +import { ILiquidityMigrator } from "src/interfaces/ILiquidityMigrator.sol"; +import { IPoolInitializer } from "src/interfaces/IPoolInitializer.sol"; +import { ITokenFactory } from "src/interfaces/ITokenFactory.sol"; +import { DopplerERC20V1Factory } from "src/tokens/DopplerERC20V1Factory.sol"; +import { BeneficiaryData } from "src/types/BeneficiaryData.sol"; +import { MineV4Params, mineV4 } from "test/shared/AirlockMiner.sol"; +import { dopplerERC20V1FactoryData, predictDopplerERC20V1Address } from "test/shared/DopplerERC20V1FactoryHelper.sol"; + +contract UnsupportedInitializerLiquidityMigratorMock is ILiquidityMigrator { + function initialize(address, address, bytes calldata) external pure returns (address) { + return address(0xdeadbeef); + } + + function migrate(uint160, address, address, address) external payable returns (uint256) { + return 0; + } +} + +contract BundlerUnsupportedInitializersIntegrationTest is Deployers { + uint128 internal constant DEV_BUY_AMOUNT = 1 ether; + address internal constant UNISWAP_V3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; + + address internal airlockOwner = makeAddr("unsupportedAirlockOwner"); + address internal payer = makeAddr("unsupportedPayer"); + address internal recipient = makeAddr("unsupportedRecipient"); + + Airlock internal airlock; + Bundler internal bundler; + DopplerERC20V1Factory internal tokenFactory; + GovernanceFactory internal governanceFactory; + UnsupportedInitializerLiquidityMigratorMock internal liquidityMigrator; + TestERC20 internal erc20Numeraire; + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_MAINNET_RPC_URL"), 21_093_509); + + deployFreshManagerAndRouters(); + + airlock = new Airlock(airlockOwner); + tokenFactory = new DopplerERC20V1Factory(address(airlock)); + governanceFactory = new GovernanceFactory(address(airlock)); + liquidityMigrator = new UnsupportedInitializerLiquidityMigratorMock(); + erc20Numeraire = new TestERC20(1e48); + bundler = new Bundler(airlock, manager); + + address[] memory modules = new address[](3); + modules[0] = address(tokenFactory); + modules[1] = address(governanceFactory); + modules[2] = address(liquidityMigrator); + + ModuleState[] memory states = new ModuleState[](3); + states[0] = ModuleState.TokenFactory; + states[1] = ModuleState.GovernanceFactory; + states[2] = ModuleState.LiquidityMigrator; + + vm.prank(airlockOwner); + airlock.setModuleState(modules, states); + } + + function test_bundleAndSimulateBundle_LockableUniswapV3Initializer_RevertAtomically() public { + (CreateParams memory params, address predictedAsset, IUniswapV3Factory v3Factory) = + _lockableUniswapV3CreateParams(); + + _expectUnsupportedInitializerBoundary(params, predictedAsset); + vm.expectRevert(); + bundler.simulateBundle(params, DEV_BUY_AMOUNT); + + _assertLaunchRolledBack(predictedAsset); + assertEq( + v3Factory.getPool(predictedAsset, address(erc20Numeraire), 3000), + address(0), + "simulation must revert the V3 pool" + ); + + erc20Numeraire.transfer(payer, DEV_BUY_AMOUNT); + vm.prank(payer); + erc20Numeraire.approve(address(bundler), DEV_BUY_AMOUNT); + uint256 payerBalanceBefore = erc20Numeraire.balanceOf(payer); + + _expectUnsupportedInitializerBoundary(params, predictedAsset); + vm.prank(payer); + vm.expectRevert(); + bundler.bundle(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + + _assertLaunchRolledBack(predictedAsset); + assertEq( + v3Factory.getPool(predictedAsset, address(erc20Numeraire), 3000), + address(0), + "bundle must revert the V3 pool" + ); + assertEq(erc20Numeraire.balanceOf(payer), payerBalanceBefore, "failed bundle must not spend payer funds"); + assertEq(erc20Numeraire.balanceOf(address(bundler)), 0, "failed bundle must not retain payer funds"); + assertEq(erc20Numeraire.balanceOf(address(airlock)), 0, "failed launch must not retain numeraire"); + } + + function test_bundleAndSimulateBundle_UniswapV4Initializer_RevertAtomically() public { + (CreateParams memory params, address predictedAsset, address predictedHook) = _uniswapV4CreateParams(); + + _expectUnsupportedInitializerBoundary(params, predictedAsset); + vm.expectRevert(); + bundler.simulateBundle(params, DEV_BUY_AMOUNT); + + _assertLaunchRolledBack(predictedAsset); + assertEq(predictedHook.code.length, 0, "simulation must revert the V4 hook"); + + vm.deal(payer, DEV_BUY_AMOUNT); + uint256 payerBalanceBefore = payer.balance; + + _expectUnsupportedInitializerBoundary(params, predictedAsset); + vm.prank(payer); + vm.expectRevert(); + bundler.bundle{ value: DEV_BUY_AMOUNT }(params, _noVesting(), DEV_BUY_AMOUNT, recipient); + + _assertLaunchRolledBack(predictedAsset); + assertEq(predictedHook.code.length, 0, "bundle must revert the V4 hook"); + assertEq(payer.balance, payerBalanceBefore, "failed bundle must not spend payer funds"); + assertEq(address(bundler).balance, 0, "failed bundle must not retain payer funds"); + assertEq(address(airlock).balance, 0, "failed launch must not retain native numeraire"); + } + + function _lockableUniswapV3CreateParams() + internal + returns (CreateParams memory params, address predictedAsset, IUniswapV3Factory v3Factory) + { + v3Factory = IUniswapV3Factory(UNISWAP_V3_FACTORY); + + LockableUniswapV3Initializer lockableInitializer = new LockableUniswapV3Initializer(address(airlock), v3Factory); + _registerPoolInitializer(IPoolInitializer(address(lockableInitializer))); + + bytes32 salt = keccak256("Bundler LockableUniswapV3Initializer regression"); + predictedAsset = predictDopplerERC20V1Address(tokenFactory, salt); + bool assetIsToken0 = predictedAsset < address(erc20Numeraire); + bytes memory initializerData = abi.encode( + LockableUniswapV3InitData({ + fee: 3000, + tickLower: assetIsToken0 ? int24(-200_040) : int24(167_520), + tickUpper: assetIsToken0 ? int24(-167_520) : int24(200_040), + numPositions: 10, + maxShareToBeSold: 0.9 ether, + beneficiaries: new BeneficiaryData[](0) + }) + ); + + params = _unsupportedInitializerCreateParams( + address(erc20Numeraire), + 1e23, + salt, + IPoolInitializer(address(lockableInitializer)), + initializerData, + _unsupportedInitializerTokenFactoryData() + ); + } + + function _uniswapV4CreateParams() + internal + returns (CreateParams memory params, address predictedAsset, address predictedHook) + { + DopplerDeployer deployer = new DopplerDeployer(manager); + UniswapV4Initializer uniswapV4Initializer = new UniswapV4Initializer(address(airlock), manager, deployer); + _registerPoolInitializer(IPoolInitializer(address(uniswapV4Initializer))); + + uint256 launchSupply = 1e23; + bytes memory tokenFactoryData = _unsupportedInitializerTokenFactoryData(); + bytes memory initializerData = abi.encode( + 0.01 ether, + 10 ether, + block.timestamp, + block.timestamp + 1 days, + int24(6000), + int24(60_000), + uint256(200), + int24(800), + false, + uint256(10), + uint24(200), + int24(2) + ); + MineV4Params memory miningParams = MineV4Params({ + airlock: address(airlock), + poolManager: address(manager), + initialSupply: launchSupply, + numTokensToSell: launchSupply, + numeraire: address(0), + tokenFactory: ITokenFactory(tokenFactory), + tokenFactoryData: tokenFactoryData, + poolInitializer: uniswapV4Initializer, + poolInitializerData: initializerData + }); + bytes32 salt; + (salt, predictedHook, predictedAsset) = mineV4(miningParams); + + params = _unsupportedInitializerCreateParams( + address(0), + launchSupply, + salt, + IPoolInitializer(address(uniswapV4Initializer)), + initializerData, + tokenFactoryData + ); + } + + function _unsupportedInitializerCreateParams( + address numeraire, + uint256 launchSupply, + bytes32 salt, + IPoolInitializer poolInitializer, + bytes memory poolInitializerData, + bytes memory tokenFactoryData + ) internal view returns (CreateParams memory params) { + params = CreateParams({ + initialSupply: launchSupply, + numTokensToSell: launchSupply, + numeraire: numeraire, + tokenFactory: ITokenFactory(tokenFactory), + tokenFactoryData: tokenFactoryData, + governanceFactory: IGovernanceFactory(governanceFactory), + governanceFactoryData: _governanceFactoryData(), + poolInitializer: poolInitializer, + poolInitializerData: poolInitializerData, + liquidityMigrator: ILiquidityMigrator(liquidityMigrator), + liquidityMigratorData: bytes(""), + integrator: address(0), + salt: salt + }); + } + + function _unsupportedInitializerTokenFactoryData() internal pure returns (bytes memory) { + return dopplerERC20V1FactoryData( + "Unsupported Bundler Test", "UNSUPPORTED", "TOKEN_URI", 0, 0, address(0), new address[](0) + ); + } + + function _registerPoolInitializer(IPoolInitializer poolInitializer) internal { + address[] memory modules = new address[](1); + modules[0] = address(poolInitializer); + ModuleState[] memory states = new ModuleState[](1); + states[0] = ModuleState.PoolInitializer; + vm.prank(airlockOwner); + airlock.setModuleState(modules, states); + } + + function _expectUnsupportedInitializerBoundary(CreateParams memory params, address predictedAsset) internal { + vm.expectCall( + address(params.poolInitializer), + abi.encodeCall( + IPoolInitializer.initialize, + (predictedAsset, params.numeraire, params.numTokensToSell, params.salt, params.poolInitializerData) + ) + ); + vm.expectCall( + address(params.poolInitializer), + abi.encodeWithSelector(bytes4(keccak256("getState(address)")), predictedAsset) + ); + } + + function _assertLaunchRolledBack(address predictedAsset) internal view { + assertEq(predictedAsset.code.length, 0, "failed launch must revert asset deployment"); + ( + , + address storedTimelock, + address storedGovernance,, + IPoolInitializer storedInitializer, + address storedPool,, + uint256 storedNumTokensToSell, + uint256 storedTotalSupply, + address storedIntegrator + ) = airlock.getAssetData(predictedAsset); + assertEq(storedTimelock, address(0), "failed launch must not retain a timelock"); + assertEq(storedGovernance, address(0), "failed launch must not retain governance"); + assertEq(address(storedInitializer), address(0), "failed launch must not retain its initializer"); + assertEq(storedPool, address(0), "failed launch must not retain its pool"); + assertEq(storedNumTokensToSell, 0, "failed launch must not retain sale state"); + assertEq(storedTotalSupply, 0, "failed launch must not retain supply state"); + assertEq(storedIntegrator, address(0), "failed launch must not retain an integrator"); + } + + function _noVesting() internal pure returns (Bundler.VestingParams memory) { + return Bundler.VestingParams({ permissionlessClaim: false, vestingDuration: 0, cliffDuration: 0 }); + } + + function _governanceFactoryData() internal pure returns (bytes memory) { + return abi.encode("Bundler Test", uint48(7200), uint32(50_400), uint256(0)); + } +} diff --git a/test/integration/BundlerVesting.t.sol b/test/integration/BundlerVesting.t.sol new file mode 100644 index 000000000..23d8aa7be --- /dev/null +++ b/test/integration/BundlerVesting.t.sol @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import { Deployers } from "@uniswap/v4-core/test/utils/Deployers.sol"; +import { Hooks } from "@v4-core/libraries/Hooks.sol"; +import { TestERC20 } from "@v4-core/test/TestERC20.sol"; +import { Currency } from "@v4-core/types/Currency.sol"; +import { PoolId, PoolIdLibrary } from "@v4-core/types/PoolId.sol"; +import { PoolKey } from "@v4-core/types/PoolKey.sol"; +import { Airlock, CreateParams, ModuleState } from "src/Airlock.sol"; +import { + Bundler, + InvalidVestingSchedule, + NoClaimableAmount, + SenderNotRecipient, + VestingCreated, + VestingReleased +} from "src/Bundler.sol"; +import { ON_INITIALIZATION_FLAG, ON_SWAP_FLAG } from "src/base/BaseDopplerHookInitializer.sol"; +import { RehypeDopplerHookInitializer } from "src/dopplerHooks/RehypeDopplerHookInitializer.sol"; +import { GovernanceFactory } from "src/governance/GovernanceFactory.sol"; +import { DopplerHookInitializer, InitData } from "src/initializers/DopplerHookInitializer.sol"; +import { IGovernanceFactory } from "src/interfaces/IGovernanceFactory.sol"; +import { ILiquidityMigrator } from "src/interfaces/ILiquidityMigrator.sol"; +import { IPoolInitializer } from "src/interfaces/IPoolInitializer.sol"; +import { ITokenFactory } from "src/interfaces/ITokenFactory.sol"; +import { Curve } from "src/libraries/Multicurve.sol"; +import { DopplerERC20V1Factory } from "src/tokens/DopplerERC20V1Factory.sol"; +import { BeneficiaryData } from "src/types/BeneficiaryData.sol"; +import { + AIRLOCK_OWNER_FEE_BPS, + BPS_DENOMINATOR, + FeeDistributionInfo, + FeeRoutingMode, + InitData as RehypeInitData, + SWAP_FEE_DENOMINATOR +} from "src/types/RehypeTypes.sol"; +import { WAD } from "src/types/Wad.sol"; +import { dopplerERC20V1FactoryData, predictDopplerERC20V1Address } from "test/shared/DopplerERC20V1FactoryHelper.sol"; + +contract BundlerVestingLiquidityMigratorMock is ILiquidityMigrator { + function initialize(address, address, bytes calldata) external pure returns (address) { + return address(0xdeadbeef); + } + + function migrate(uint160, address, address, address) external payable returns (uint256) { + return 0; + } +} + +contract BundlerVestingIntegrationTest is Deployers { + using PoolIdLibrary for PoolKey; + + uint24 internal constant START_FEE = 800_000; + uint128 internal constant DEV_BUY_AMOUNT = 1 ether; + uint256 internal constant INITIAL_SUPPLY = 1e27; + uint64 internal constant VESTING_DURATION = 100 days; + uint64 internal constant CLIFF_DURATION = 20 days; + + address internal airlockOwner = makeAddr("vestingAirlockOwner"); + address internal buybackDst = makeAddr("vestingBuybackDst"); + address internal payer = makeAddr("vestingPayer"); + address internal recipient = makeAddr("vestingRecipient"); + address internal attacker = makeAddr("vestingAttacker"); + + Airlock internal airlock; + Bundler internal bundler; + DopplerHookInitializer internal initializer; + DopplerERC20V1Factory internal tokenFactory; + GovernanceFactory internal governanceFactory; + BundlerVestingLiquidityMigratorMock internal liquidityMigrator; + RehypeDopplerHookInitializer internal rehype; + TestERC20 internal erc20Numeraire; + + address internal vestedAsset; + PoolKey internal vestedPoolKey; + uint128 internal vestedAmountOut; + address internal quotedAsset; + uint128 internal quotedAmountOut; + + function setUp() public { + deployFreshManagerAndRouters(); + + airlock = new Airlock(airlockOwner); + tokenFactory = new DopplerERC20V1Factory(address(airlock)); + governanceFactory = new GovernanceFactory(address(airlock)); + liquidityMigrator = new BundlerVestingLiquidityMigratorMock(); + erc20Numeraire = new TestERC20(1e48); + + 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)); + + bundler = new Bundler(airlock, manager); + rehype = new RehypeDopplerHookInitializer(address(initializer), manager, address(bundler)); + + address[] memory modules = new address[](4); + modules[0] = address(tokenFactory); + modules[1] = address(governanceFactory); + modules[2] = address(initializer); + modules[3] = address(liquidityMigrator); + + ModuleState[] memory states = new ModuleState[](4); + states[0] = ModuleState.TokenFactory; + states[1] = ModuleState.GovernanceFactory; + states[2] = ModuleState.PoolInitializer; + states[3] = ModuleState.LiquidityMigrator; + + vm.startPrank(airlockOwner); + airlock.setModuleState(modules, states); + + address[] memory hooks = new address[](1); + hooks[0] = address(rehype); + uint256[] memory flags = new uint256[](1); + flags[0] = ON_INITIALIZATION_FLAG | ON_SWAP_FLAG; + initializer.setDopplerHookState(hooks, flags); + vm.stopPrank(); + } + + function test_bundle_ZeroVestingDurationTransfersDirectlyAndStoresNoPosition() public { + this.bundleERC20(bytes32(uint256(1)), true, 0, 0); + + assertEq(TestERC20(vestedAsset).balanceOf(recipient), vestedAmountOut); + assertEq(TestERC20(vestedAsset).balanceOf(address(bundler)), 0); + assertEq(bundler.claimable(vestedAsset), 0); + _assertVestingAmounts(vestedAsset, 0, 0); + } + + function test_bundle_VestingCustodiesOutputStoresScheduleAndMatchesSimulation() public { + bytes32 salt = bytes32(uint256(2)); + this.simulateERC20(salt); + + assertEq(quotedAsset.code.length, 0, "simulation must revert asset deployment"); + assertEq(bundler.claimable(quotedAsset), 0, "simulation must not create vesting state"); + + uint64 expectedStart = uint64(block.timestamp); + vm.expectEmit(true, true, false, true, address(bundler)); + emit VestingCreated( + quotedAsset, recipient, true, quotedAmountOut, expectedStart, CLIFF_DURATION, VESTING_DURATION + ); + this.bundleERC20(salt, true, VESTING_DURATION, CLIFF_DURATION); + + assertEq(vestedAsset, quotedAsset); + assertEq(vestedAmountOut, quotedAmountOut); + assertEq(TestERC20(vestedAsset).balanceOf(recipient), 0); + assertEq(TestERC20(vestedAsset).balanceOf(address(bundler)), vestedAmountOut); + assertEq(bundler.claimable(vestedAsset), 0); + _assertVestingConfig(vestedAsset, true, expectedStart, CLIFF_DURATION, VESTING_DURATION); + _assertVestingAmounts(vestedAsset, vestedAmountOut, 0); + _assertOwnerOnlyDevBuyFee(vestedPoolKey.toId(), vestedPoolKey, vestedAmountOut); + } + + function test_claim_PermissionlessClaimAccruesContinuouslyAndReleasesRemainderAtEnd() public { + this.bundleERC20(bytes32(uint256(3)), true, VESTING_DURATION, CLIFF_DURATION); + (,, uint64 start,,,,) = bundler.vestingOf(vestedAsset); + + vm.warp(uint256(start) + CLIFF_DURATION - 1); + assertEq(bundler.claimable(vestedAsset), 0); + vm.prank(attacker); + vm.expectRevert(NoClaimableAmount.selector); + bundler.claim(vestedAsset); + + vm.warp(uint256(start) + CLIFF_DURATION); + uint256 expectedAtCliff = uint256(vestedAmountOut) * CLIFF_DURATION / VESTING_DURATION; + assertEq(bundler.claimable(vestedAsset), expectedAtCliff); + + vm.expectEmit(true, true, false, true, address(bundler)); + emit VestingReleased(vestedAsset, recipient, uint128(expectedAtCliff)); + vm.prank(attacker); + assertEq(bundler.claim(vestedAsset), expectedAtCliff); + assertEq(TestERC20(vestedAsset).balanceOf(recipient), expectedAtCliff); + + vm.prank(attacker); + vm.expectRevert(NoClaimableAmount.selector); + bundler.claim(vestedAsset); + + uint256 midpointElapsed = (uint256(CLIFF_DURATION) + VESTING_DURATION) / 2; + vm.warp(uint256(start) + midpointElapsed); + uint256 expectedAtMidpoint = uint256(vestedAmountOut) * midpointElapsed / VESTING_DURATION; + uint256 midpointClaim = expectedAtMidpoint - expectedAtCliff; + assertEq(bundler.claimable(vestedAsset), midpointClaim); + + vm.prank(attacker); + assertEq(bundler.claim(vestedAsset), midpointClaim); + assertEq(TestERC20(vestedAsset).balanceOf(recipient), expectedAtMidpoint); + _assertVestingAmounts(vestedAsset, vestedAmountOut, uint128(expectedAtMidpoint)); + + vm.warp(uint256(start) + VESTING_DURATION); + uint256 remainder = uint256(vestedAmountOut) - expectedAtMidpoint; + assertEq(bundler.claimable(vestedAsset), remainder); + vm.prank(attacker); + assertEq(bundler.claim(vestedAsset), remainder); + + assertEq(TestERC20(vestedAsset).balanceOf(recipient), vestedAmountOut); + assertEq(TestERC20(vestedAsset).balanceOf(address(bundler)), 0); + assertEq(bundler.claimable(vestedAsset), 0); + _assertVestingAmounts(vestedAsset, vestedAmountOut, vestedAmountOut); + } + + function test_claim_RestrictedClaimRequiresRecipientAndCliffMayEqualDuration() public { + uint64 duration = 2 days; + this.bundleERC20(bytes32(uint256(4)), false, duration, duration); + (,, uint64 start,,,,) = bundler.vestingOf(vestedAsset); + + vm.warp(uint256(start) + duration - 1); + assertEq(bundler.claimable(vestedAsset), 0); + + vm.warp(uint256(start) + duration); + assertEq(bundler.claimable(vestedAsset), vestedAmountOut); + vm.prank(attacker); + vm.expectRevert(SenderNotRecipient.selector); + bundler.claim(vestedAsset); + + vm.prank(recipient); + assertEq(bundler.claim(vestedAsset), vestedAmountOut); + assertEq(TestERC20(vestedAsset).balanceOf(recipient), vestedAmountOut); + } + + function test_claim_OneSecondVestingWithNoCliff() public { + this.bundleERC20(bytes32(uint256(5)), true, 1, 0); + (,, uint64 start,,,,) = bundler.vestingOf(vestedAsset); + + assertEq(bundler.claimable(vestedAsset), 0); + vm.warp(uint256(start) + 1); + assertEq(bundler.claimable(vestedAsset), vestedAmountOut); + + vm.prank(attacker); + assertEq(bundler.claim(vestedAsset), vestedAmountOut); + } + + function test_bundle_NativeNumeraireVestingCustodiesOutput() public { + CreateParams memory params = this.createParams(address(0), bytes32(uint256(6))); + + vm.deal(payer, DEV_BUY_AMOUNT); + vm.prank(payer); + (address asset, PoolKey memory poolKey,,, uint128 amountOut) = bundler.bundle{ value: DEV_BUY_AMOUNT }( + params, _vestingParams(false, VESTING_DURATION, CLIFF_DURATION), DEV_BUY_AMOUNT, recipient + ); + + assertEq(TestERC20(asset).balanceOf(recipient), 0); + assertEq(TestERC20(asset).balanceOf(address(bundler)), amountOut); + assertEq(payer.balance, 0); + assertEq(address(bundler).balance, 0); + _assertOwnerOnlyDevBuyFee(poolKey.toId(), poolKey, amountOut); + } + + function test_bundle_RevertsWhenCliffExceedsVestingDuration() public { + CreateParams memory params = this.createParams(address(erc20Numeraire), bytes32(uint256(7))); + + vm.expectRevert(InvalidVestingSchedule.selector); + bundler.bundle(params, _vestingParams(true, 1, 2), DEV_BUY_AMOUNT, recipient); + } + + function test_bundle_RevertsWhenZeroDurationHasNonzeroCliff() public { + CreateParams memory params = this.createParams(address(erc20Numeraire), bytes32(uint256(8))); + + vm.expectRevert(InvalidVestingSchedule.selector); + bundler.bundle(params, _vestingParams(true, 0, 1), DEV_BUY_AMOUNT, recipient); + } + + function test_claim_UnknownAssetReverts() public { + assertEq(bundler.claimable(address(0xbeef)), 0); + vm.expectRevert(NoClaimableAmount.selector); + bundler.claim(address(0xbeef)); + } + + function bundleERC20( + bytes32 salt, + bool permissionlessClaim, + uint64 vestingDuration, + uint64 cliffDuration + ) external { + CreateParams memory params = this.createParams(address(erc20Numeraire), salt); + erc20Numeraire.transfer(payer, DEV_BUY_AMOUNT); + vm.prank(payer); + erc20Numeraire.approve(address(bundler), DEV_BUY_AMOUNT); + + vm.prank(payer); + (vestedAsset, vestedPoolKey,,, vestedAmountOut) = bundler.bundle( + params, _vestingParams(permissionlessClaim, vestingDuration, cliffDuration), DEV_BUY_AMOUNT, recipient + ); + } + + function _vestingParams( + bool permissionlessClaim, + uint64 vestingDuration, + uint64 cliffDuration + ) internal pure returns (Bundler.VestingParams memory) { + return Bundler.VestingParams({ + permissionlessClaim: permissionlessClaim, vestingDuration: vestingDuration, cliffDuration: cliffDuration + }); + } + + function simulateERC20(bytes32 salt) external { + CreateParams memory params = this.createParams(address(erc20Numeraire), salt); + vm.prank(payer); + (quotedAsset,,,, quotedAmountOut) = bundler.simulateBundle(params, DEV_BUY_AMOUNT); + } + + function _assertVestingConfig( + address asset, + bool expectedPermissionlessClaim, + uint64 expectedStart, + uint64 expectedCliffDuration, + uint64 expectedVestingDuration + ) internal view { + ( + address storedRecipient, + bool permissionlessClaim, + uint64 start, + uint64 cliffDuration, + uint64 vestingDuration,, + ) = bundler.vestingOf(asset); + + assertEq(storedRecipient, recipient); + assertEq(permissionlessClaim, expectedPermissionlessClaim); + assertEq(start, expectedStart); + assertEq(cliffDuration, expectedCliffDuration); + assertEq(vestingDuration, expectedVestingDuration); + } + + function _assertVestingAmounts( + address asset, + uint128 expectedTotalAmount, + uint128 expectedClaimedAmount + ) internal view { + (,,,,, uint128 totalAmount, uint128 claimedAmount) = bundler.vestingOf(asset); + assertEq(totalAmount, expectedTotalAmount); + assertEq(claimedAmount, expectedClaimedAmount); + } + + function _assertOwnerOnlyDevBuyFee(PoolId id, PoolKey memory key, uint256 netAmountOut) internal view { + address asset = _poolAsset(id); + (uint256 ownerFee, uint256 beneficiaryFee, uint256 pendingFee) = _assetFeeBuckets(id, key, asset); + + assertGt(ownerFee, 0, "dev buy must accrue the Airlock owner share"); + assertEq(beneficiaryFee, 0, "dev buy must exempt ordinary Rehype fees"); + assertEq(pendingFee, 0, "dev buy must not leave distributable fees pending"); + uint256 grossOutput = netAmountOut + ownerFee; + uint256 assessedFee = grossOutput * START_FEE / SWAP_FEE_DENOMINATOR; + uint256 expectedOwnerFee = assessedFee * AIRLOCK_OWNER_FEE_BPS / BPS_DENOMINATOR; + assertEq(ownerFee, expectedOwnerFee, "owner must receive 5% of the otherwise assessed Rehype fee"); + } + + function _assetFeeBuckets( + PoolId id, + PoolKey memory key, + address asset + ) internal view returns (uint256 ownerFee, uint256 beneficiaryFee, uint256 pendingFee) { + ( + uint128 fees0, + uint128 fees1, + uint128 beneficiaryFees0, + uint128 beneficiaryFees1, + uint128 ownerFees0, + uint128 ownerFees1, + ) = rehype.getHookFees(id); + + if (Currency.unwrap(key.currency0) == asset) { + return (ownerFees0, beneficiaryFees0, fees0); + } + return (ownerFees1, beneficiaryFees1, fees1); + } + + function _poolAsset(PoolId id) internal view returns (address asset) { + (asset,,) = rehype.getPoolInfo(id); + } + + function createParams(address numeraire, bytes32 salt) external view returns (CreateParams memory params) { + (params,) = _createParams(numeraire, salt); + } + + function _createParams( + address numeraire, + bytes32 salt + ) internal view returns (CreateParams memory params, address predictedAsset) { + predictedAsset = predictDopplerERC20V1Address(tokenFactory, salt); + Curve[] memory curves = new Curve[](10); + for (uint256 i; i < curves.length; ++i) { + curves[i] = + Curve({ tickLower: int24(uint24(i * 16_000)), tickUpper: 240_000, numPositions: 10, shares: WAD / 10 }); + } + + FeeDistributionInfo memory distribution = FeeDistributionInfo({ + assetFeesToAssetBuybackWad: 0, + assetFeesToNumeraireBuybackWad: 0, + assetFeesToBeneficiaryWad: WAD, + assetFeesToLpWad: 0, + numeraireFeesToAssetBuybackWad: 0, + numeraireFeesToNumeraireBuybackWad: 0, + numeraireFeesToBeneficiaryWad: WAD, + numeraireFeesToLpWad: 0 + }); + + RehypeInitData memory rehypeData = RehypeInitData({ + numeraire: numeraire, + buybackDst: buybackDst, + startFee: START_FEE, + endFee: START_FEE, + durationSeconds: 0, + startingTime: 0, + feeRoutingMode: FeeRoutingMode.DirectBuyback, + feeDistributionInfo: distribution, + feeBeneficiaries: new BeneficiaryData[](0) + }); + + InitData memory initData = InitData({ + fee: 0, + tickSpacing: 8, + farTick: 200_000, + curves: curves, + beneficiaries: new BeneficiaryData[](0), + dopplerHook: address(rehype), + onInitializationDopplerHookCalldata: abi.encode(rehypeData), + graduationDopplerHookCalldata: bytes("") + }); + + params = CreateParams({ + initialSupply: INITIAL_SUPPLY, + numTokensToSell: INITIAL_SUPPLY, + numeraire: numeraire, + tokenFactory: ITokenFactory(tokenFactory), + tokenFactoryData: dopplerERC20V1FactoryData( + "Bundler Vesting Test", "BVEST", "TOKEN_URI", 0, 0, address(0), new address[](0) + ), + governanceFactory: IGovernanceFactory(governanceFactory), + governanceFactoryData: abi.encode("Bundler Vesting Test", uint48(7200), uint32(50_400), uint256(0)), + poolInitializer: IPoolInitializer(initializer), + poolInitializerData: abi.encode(initData), + liquidityMigrator: ILiquidityMigrator(liquidityMigrator), + liquidityMigratorData: bytes(""), + integrator: address(0), + salt: salt + }); + } +} diff --git a/test/integration/DopplerHookMigratorIntegration.t.sol b/test/integration/DopplerHookMigratorIntegration.t.sol index feb3c6d7c..4e80e80d1 100644 --- a/test/integration/DopplerHookMigratorIntegration.t.sol +++ b/test/integration/DopplerHookMigratorIntegration.t.sol @@ -83,7 +83,7 @@ contract DopplerHookMigratorIntegrationTest is Deployers { migratorHookAddress ); - rehypeHook = new RehypeDopplerHookInitializer(address(migrator), manager); + rehypeHook = new RehypeDopplerHookInitializer(address(migrator), manager, address(0)); rehypeHookMigrator = new RehypeDopplerHookMigrator(migrator, manager); swapRestrictorHook = new SwapRestrictorDopplerHook(address(migrator)); diff --git a/test/integration/RehypeDopplerHookInitializer.t.sol b/test/integration/RehypeDopplerHookInitializer.t.sol index 74a586faa..902b13834 100644 --- a/test/integration/RehypeDopplerHookInitializer.t.sol +++ b/test/integration/RehypeDopplerHookInitializer.t.sol @@ -161,7 +161,7 @@ contract RehypeDopplerHookIntegrationTest is Deployers { deployCodeTo("DopplerHookInitializer", abi.encode(address(airlock), address(manager)), address(initializer)); - rehypeDopplerHook = new RehypeDopplerHookInitializer(address(initializer), manager); + rehypeDopplerHook = new RehypeDopplerHookInitializer(address(initializer), manager, address(0)); vm.label(address(rehypeDopplerHook), "RehypeDopplerHookInitializer"); feeBypassAttemptRouter = new FeeBypassAttemptRouter(manager); vm.label(address(feeBypassAttemptRouter), "FeeBypassAttemptRouter"); diff --git a/test/invariant/RehypeHandler.sol b/test/invariant/RehypeHandler.sol index afa531247..abff84c6a 100644 --- a/test/invariant/RehypeHandler.sol +++ b/test/invariant/RehypeHandler.sol @@ -55,7 +55,7 @@ contract RehyperInvariantTests is Deployers { ) ^ (0x4444 << 144) )) ); - rehypeHook = new RehypeDopplerHookInitializer(address(dopplerHookInitializer), manager); + rehypeHook = new RehypeDopplerHookInitializer(address(dopplerHookInitializer), manager, address(0)); quoter = new V4Quoter(manager); handler = new RehypeHandler(manager, swapRouter, dopplerHookInitializer, rehypeHook, quoter); @@ -204,6 +204,14 @@ contract RehypeHandler is Test { } } + function getAssetData(address) + external + pure + returns (address, address, address, address, address, address, address, uint256, uint256, address) + { + return (address(0), address(0), address(0), address(0), address(1), address(0), address(0), 0, 0, address(0)); + } + /* ------------------------------------------------------------------------------ */ /* Target functions */ /* ------------------------------------------------------------------------------ */ diff --git a/test/unit/dopplerHooks/rehypeHookInitializer/RehypeDopplerHookHarness.sol b/test/unit/dopplerHooks/rehypeHookInitializer/RehypeDopplerHookHarness.sol index 76891c70c..02b20cef6 100644 --- a/test/unit/dopplerHooks/rehypeHookInitializer/RehypeDopplerHookHarness.sol +++ b/test/unit/dopplerHooks/rehypeHookInitializer/RehypeDopplerHookHarness.sol @@ -16,7 +16,7 @@ contract RehypeDopplerHookHarness is RehypeDopplerHookInitializer { constructor( address initializer, IPoolManager poolManager_ - ) RehypeDopplerHookInitializer(initializer, poolManager_) { } + ) RehypeDopplerHookInitializer(initializer, poolManager_, address(0)) { } // ═══════════════════════════════════════════════════════════════════════════════ // EXPOSED PURE FUNCTIONS (Direct access - no quoter needed) diff --git a/test/unit/dopplerHooks/rehypeHookInitializer/RehypeDopplerHookInitializer.t.sol b/test/unit/dopplerHooks/rehypeHookInitializer/RehypeDopplerHookInitializer.t.sol index 68d43737e..d6c0ec16f 100644 --- a/test/unit/dopplerHooks/rehypeHookInitializer/RehypeDopplerHookInitializer.t.sol +++ b/test/unit/dopplerHooks/rehypeHookInitializer/RehypeDopplerHookInitializer.t.sol @@ -72,7 +72,7 @@ contract RehypeDopplerHookHarness is RehypeDopplerHookInitializer { constructor( address _initializer, IPoolManager _poolManager - ) RehypeDopplerHookInitializer(_initializer, _poolManager) { } + ) RehypeDopplerHookInitializer(_initializer, _poolManager, address(0)) { } function exposed_getCurrentFee(PoolId poolId) external returns (uint24) { return _getCurrentFee(poolId); @@ -88,7 +88,7 @@ contract RehypeDopplerHookHarness is RehypeDopplerHookInitializer { PoolKey memory key, PoolId poolId ) external returns (Currency feeCurrency, int128 feeDelta) { - return _collectSwapFees(params, delta, key, poolId); + return _collectSwapFees(address(0), params, delta, key, poolId); } function exposed_setBeneficiaryFees(PoolId poolId, uint128 fees0, uint128 fees1) external { @@ -110,6 +110,18 @@ contract RealLpHookCaller is IUnlockCallback { hook = hook_; } + function airlock() external view returns (address) { + return address(this); + } + + function getAssetData(address) + external + pure + returns (address, address, address, address, address, address, address, uint256, uint256, address) + { + return (address(0), address(0), address(0), address(0), address(1), address(0), address(0), 0, 0, address(0)); + } + function initialize(address asset, PoolKey memory key, bytes memory data) external { hook.onInitialization(asset, key, data); } @@ -144,6 +156,14 @@ contract MockAirlock { constructor(address _owner) { owner = _owner; } + + function getAssetData(address) + external + pure + returns (address, address, address, address, address, address, address, uint256, uint256, address) + { + return (address(0), address(0), address(0), address(0), address(1), address(0), address(0), 0, 0, address(0)); + } } contract MockInitializer { @@ -230,12 +250,13 @@ contract RehypeDopplerHookInitializerTest is Deployers { function setUp() public { poolManager = IPoolManager(address(new MockPoolManager())); initializer = new MockInitializer(); - dopplerHook = new RehypeDopplerHookInitializer(address(initializer), poolManager); + dopplerHook = new RehypeDopplerHookInitializer(address(initializer), poolManager, address(0)); harness = new RehypeDopplerHookHarness(address(initializer), poolManager); trackingPoolManager = new TrackingPoolManager(); trackingHarness = new RehypeDopplerHookHarness(address(initializer), IPoolManager(address(trackingPoolManager))); mockInitializer = new MockInitializer(); - dopplerHookWithMockInitializer = new RehypeDopplerHookInitializer(address(mockInitializer), poolManager); + dopplerHookWithMockInitializer = + new RehypeDopplerHookInitializer(address(mockInitializer), poolManager, address(0)); token0 = new TestERC20(type(uint128).max); token1 = new TestERC20(type(uint128).max); token0.mint(address(trackingPoolManager), type(uint128).max); From 49e909fff9dd96f73422d9b81d39c78b21cb81a3 Mon Sep 17 00:00:00 2001 From: Zodomo Date: Fri, 14 Aug 2026 17:52:58 -0500 Subject: [PATCH 2/2] address findings --- docs/Bundler.md | 1 + src/Bundler.sol | 26 +++++++++++++++++++++----- test/integration/BundlerVesting.t.sol | 15 +++++++++++---- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/Bundler.md b/docs/Bundler.md index 2ce73d1a4..5d1165a13 100644 --- a/docs/Bundler.md +++ b/docs/Bundler.md @@ -73,6 +73,7 @@ Pools created through `DopplerHookInitializer` without Rehype still support the A zero `vestingDuration` disables Bundler vesting and sends `amountOut` directly to `recipient`. Otherwise: +- `vestingDuration` must be at least one day - `cliffDuration` must not exceed `vestingDuration` - Bundler holds exactly `amountOut` - vesting begins at the successful bundle timestamp diff --git a/src/Bundler.sol b/src/Bundler.sol index 8964a42e6..791b5a3b4 100644 --- a/src/Bundler.sol +++ b/src/Bundler.sol @@ -48,7 +48,7 @@ error ExactInputAmountZero(); /// @notice Thrown when a vesting position already exists for an asset. error VestingAlreadyExists(address asset); -/// @notice Thrown when the cliff exceeds the vesting duration. +/// @notice Thrown when an enabled vesting duration is below one day or its cliff exceeds its duration. error InvalidVestingSchedule(); /// @notice Thrown when the pool does not consume the requested exact input amount. @@ -74,6 +74,9 @@ event VestingCreated( /// @notice Emitted when vested assets are claimed for their recipient. event VestingReleased(address indexed asset, address indexed recipient, uint128 amount); +/// @dev Minimum duration for an enabled vesting position. +uint64 constant MIN_VESTING_DURATION = 1 days; + /** * @title Doppler Bundler * @author Whetstone @@ -194,7 +197,12 @@ contract Bundler is IUnlockCallback { revert ExactInputAmountZero(); } if (recipient == address(0)) revert InvalidRecipient(); - if (vestingData.cliffDuration > vestingData.vestingDuration) revert InvalidVestingSchedule(); + if ( + (vestingData.vestingDuration != 0 && vestingData.vestingDuration < MIN_VESTING_DURATION) + || vestingData.cliffDuration > vestingData.vestingDuration + ) { + revert InvalidVestingSchedule(); + } bool nativeNumeraire = createData.numeraire == address(0); if (nativeNumeraire ? msg.value != exactAmountIn : msg.value != 0) revert InvalidNativeValue(); @@ -252,7 +260,15 @@ contract Bundler is IUnlockCallback { * @return amount Amount currently claimable by the position's recipient. */ function claimable(address asset) public view returns (uint256 amount) { - Vesting memory vesting = vestingOf[asset]; + return _claimable(vestingOf[asset]); + } + + /** + * @notice Returns the amount currently claimable from a vesting position. + * @param vesting Vesting position to calculate the claimable amount for. + * @return amount Amount currently claimable by the position's recipient. + */ + function _claimable(Vesting memory vesting) internal view returns (uint256 amount) { uint256 totalAmount = vesting.totalAmount; if (totalAmount == 0) return 0; @@ -279,10 +295,10 @@ contract Bundler is IUnlockCallback { if (vesting.totalAmount == 0) revert NoClaimableAmount(); if (!vesting.permissionlessClaim && msg.sender != vesting.recipient) revert SenderNotRecipient(); - amount = claimable(asset); + amount = _claimable(vesting); if (amount == 0) revert NoClaimableAmount(); - vestingOf[asset].claimedAmount += uint128(amount); + vestingOf[asset].claimedAmount = vesting.claimedAmount + uint128(amount); SafeTransferLib.safeTransfer(asset, vesting.recipient, amount); emit VestingReleased(asset, vesting.recipient, uint128(amount)); } diff --git a/test/integration/BundlerVesting.t.sol b/test/integration/BundlerVesting.t.sol index 23d8aa7be..10ba093fe 100644 --- a/test/integration/BundlerVesting.t.sol +++ b/test/integration/BundlerVesting.t.sol @@ -223,12 +223,12 @@ contract BundlerVestingIntegrationTest is Deployers { assertEq(TestERC20(vestedAsset).balanceOf(recipient), vestedAmountOut); } - function test_claim_OneSecondVestingWithNoCliff() public { - this.bundleERC20(bytes32(uint256(5)), true, 1, 0); + function test_claim_OneDayMinimumVestingWithNoCliff() public { + this.bundleERC20(bytes32(uint256(5)), true, 1 days, 0); (,, uint64 start,,,,) = bundler.vestingOf(vestedAsset); assertEq(bundler.claimable(vestedAsset), 0); - vm.warp(uint256(start) + 1); + vm.warp(uint256(start) + 1 days); assertEq(bundler.claimable(vestedAsset), vestedAmountOut); vm.prank(attacker); @@ -251,11 +251,18 @@ contract BundlerVestingIntegrationTest is Deployers { _assertOwnerOnlyDevBuyFee(poolKey.toId(), poolKey, amountOut); } + function test_bundle_RevertsWhenVestingDurationIsBelowOneDay() public { + CreateParams memory params = this.createParams(address(erc20Numeraire), bytes32(uint256(7))); + + vm.expectRevert(InvalidVestingSchedule.selector); + bundler.bundle(params, _vestingParams(true, 1 days - 1, 0), DEV_BUY_AMOUNT, recipient); + } + function test_bundle_RevertsWhenCliffExceedsVestingDuration() public { CreateParams memory params = this.createParams(address(erc20Numeraire), bytes32(uint256(7))); vm.expectRevert(InvalidVestingSchedule.selector); - bundler.bundle(params, _vestingParams(true, 1, 2), DEV_BUY_AMOUNT, recipient); + bundler.bundle(params, _vestingParams(true, 1 days, 1 days + 1), DEV_BUY_AMOUNT, recipient); } function test_bundle_RevertsWhenZeroDurationHasNonzeroCliff() public {