Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down Expand Up @@ -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
118 changes: 118 additions & 0 deletions docs/Bundler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# 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:

- `vestingDuration` must be at least one day
- `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
14 changes: 12 additions & 2 deletions docs/RehypeDopplerHookInitializer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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`:
Expand Down Expand Up @@ -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.
Loading
Loading