diff --git a/content/protocols/dualpool/concepts/inventory-and-yield.mdx b/content/protocols/dualpool/concepts/inventory-and-yield.mdx new file mode 100644 index 000000000..0faa0b269 --- /dev/null +++ b/content/protocols/dualpool/concepts/inventory-and-yield.mdx @@ -0,0 +1,60 @@ +--- +title: Inventory and Yield +description: Learn how DualPool tracks pool inventory across vault shares, ERC-6909 claims, and raw ERC-20 balances. +--- + +## Inventory and Yield + +Between swaps, a DualPool's capital does not sit in the Uniswap v4 `PoolManager`. For every `(PoolId, Currency)` pair, the hook maintains a pool-local ledger with three legs: + +- **ERC-4626 vault shares**: the yield-bearing rest state. Idle capital is deposited into the pool's configured vault and earns lending yield continuously. +- **ERC-6909 claims**: tokens owed to the hook by the `PoolManager`, held as [ERC-6909](/docs/protocols/v4/concepts/erc-6909) claim tokens. A short-lived settlement buffer (see below). +- **Raw ERC-20**: token balances held by the hook and attributed to the pool, typically only transiently during a JIT cycle. + +The hook never treats its global token balance as pool ownership. Many pools can share the same currency on one hook, so all accounting flows through per-pool mappings. Claims minted for one pool cannot be redeemed into another pool's ledger. + +## Why Claims Exist + +After a swap, the hook may be owed tokens by the `PoolManager`, but it cannot always take ERC-20 immediately, because the swapper settles their side of the swap *after* `afterSwap` runs. Instead, the hook mints ERC-6909 claims to itself and records them in the pool's ledger. + +At the start of the next JIT deployment, the hook burns those claims and takes the underlying ERC-20, crediting the pool's tracked balance. `removeLiquidity` also redeems claims before withdrawal math runs, so LPs can exit even when post-swap balances sit in claims. + + +Incoming swap tokens lag one cycle as claims. Under sustained one-way flow, a meaningful fraction of pool inventory can sit in claims rather than in the vault until an opposing-direction swap or LP action sweeps it back. Interfaces displaying pool inventory should show the claims leg distinctly. + + +## Reserves vs. Effective Liquidity + +DualPool exposes two views of pool inventory, and the distinction matters: + +- `getReserves(key)` returns **total economic assets**: `vault.convertToAssets(poolShares)` + claims + tracked ERC-20. This is what LPs own. +- `getEffectiveLiquidity(key)` returns **immediately deployable assets**: `vault.previewRedeem(poolShares)` + claims + tracked ERC-20. This is what the hook can withdraw and put to work right now. + +LP share math uses total assets, so LPs retain their full economic claim even when a vault is temporarily illiquid. JIT deployment and the quote views use effective assets, so routing only ever sees what the hook can realistically source this block. + +When the configured vault is paused, capped, or utilization-constrained, `getEffectiveLiquidity` falls below `getReserves`. Swaps larger than effective liquidity fill less, or revert on the vault withdrawal. + + +`previewRedeem` is used instead of `maxWithdraw` because curated vaults (e.g. Morpho VaultV2) often return `0` from `maxWithdraw` by design while still reporting meaningful per-share exit value. + + +## Yield Accrual + +Vault yield accrues to the pool automatically: as the vault's share price rises, the same vault-share balance converts to more underlying tokens. This has two visible effects: + +- LP positions appreciate without any pool activity, since share conversion is based on total assets +- The JIT curve deepens as yield accrues, so quotes for identical inputs improve over time + +There is no harvest step and no separate yield token. Yield is realized whenever the hook withdraws from the vault, during a JIT cycle or an LP exit. + +## Vault Requirements + +Configured vaults must satisfy strict requirements, enforced at pool initialization: + +- `vault.asset()` must equal the pool currency +- The vault must be feeless: `previewDeposit == convertToShares` and `previewRedeem == convertToAssets`. Entry or exit fees would bleed LPs on every JIT round-trip and break share math +- The vault receives a standing max token allowance from the hook, so operators must choose vaults whose governance and upgrade risk they accept + +## Developer Resources + +To understand how LP positions are denominated against this inventory, read [LP Shares](/docs/protocols/dualpool/concepts/lp-shares). For how routers should size fills against effective liquidity, read [Integrate as a Router or Aggregator](/docs/protocols/dualpool/guides/router-integration). diff --git a/content/protocols/dualpool/concepts/jit-liquidity.mdx b/content/protocols/dualpool/concepts/jit-liquidity.mdx new file mode 100644 index 000000000..a40d1f2c9 --- /dev/null +++ b/content/protocols/dualpool/concepts/jit-liquidity.mdx @@ -0,0 +1,59 @@ +--- +title: Just-in-Time Liquidity +description: Understand how DualPool deploys concentrated liquidity per swap and holds none between swaps. +--- + +## Just-in-Time Liquidity + +A DualPool holds approximately zero resident liquidity in the Uniswap v4 `PoolManager` between swaps. Instead, the pool's capital rests in ERC-4626 yield vaults, and the hook deploys concentrated liquidity just-in-time (JIT), inside `beforeSwap` and for exactly one swap, then removes it again in `afterSwap`. + +The lifecycle of every swap is: + +1. **Rest state**: the hook holds vault shares (plus any tracked ERC-20 and ERC-6909 claims). Capital earns lending yield continuously. The v4 pool itself holds no liquidity. +2. **`beforeSwap`**: the hook computes how much of each token its configured tick ranges need at the current price, redeems any pending ERC-6909 claims, withdraws only the remaining shortfall from the vaults, and adds one v4 position per [distribution bucket](/docs/protocols/dualpool/concepts/liquidity-distributions). +3. **Swap execution**: the `PoolManager` executes ordinary v4 swap math against the temporary positions and charges the pool's static `key.fee` natively. +4. **`afterSwap`**: the hook removes every position it added, settles its net deltas with the `PoolManager`, and deposits remaining balances back into the vaults. + +All four steps happen atomically within the swap transaction. The swapper sees a normal swap and a normal `BalanceDelta`. + + +Because standing liquidity is ~0, onchain pool depth is not a routing signal for DualPool. Capacity is discovered through the hook's view functions; see [Integrate as a Router or Aggregator](/docs/protocols/dualpool/guides/router-integration). + + +## Hook Permissions + +DualPool declares five hook permissions: + +| Permission | Purpose | +| --- | --- | +| `beforeInitialize` | Blocks direct `PoolManager` initialization, so pools must be created through `initializePool` | +| `beforeAddLiquidity` | Blocks external LP positions; only the hook adds positions, during JIT deployment | +| `beforeRemoveLiquidity` | Blocks external LP removals; only the hook removes its JIT positions | +| `beforeSwap` | Deploys JIT liquidity | +| `afterSwap` | Removes JIT liquidity, settles deltas, and re-deposits into vaults | + +All return-delta flags are false. DualPool does not use `BeforeSwapDelta` or custom accounting to synthesize execution: it lets normal v4 swap math run against temporarily deployed liquidity while v4 applies `key.fee` natively. + +## Static Fee + +DualPool pools use a static LP fee, fixed at pool creation via `PoolKey.fee` and never updated by the hook: + +- Dynamic-fee pools are rejected at initialization with `DynamicFeeNotSupported` +- `hookData` is ignored by `beforeSwap` and by the quote views, so it cannot change pricing or fees +- To change the fee, the operator deploys a new pool with a different `PoolKey.fee` + +Any v4 protocol fee is composed with the LP fee automatically, so quotes already reflect both. + +## Liveness + +A newly initialized pool is not live. Swaps revert with `PoolNotLive` until the owner calls `bootstrap`, which seeds the first deposit and flips the pool live. This closes the window between initialization and funding where a swap could move the pool price against zero liquidity. + +The owner can pause and resume a live pool with `setPoolLive`. While paused, swaps revert `PoolNotLive` and the quote views return `0`. + +## Transient State + +The hook records each deployed position's liquidity in [transient storage](https://eips.ethereum.org/EIPS/eip-1153) during `beforeSwap` so that `afterSwap` removes exactly what was added. A per-pool JIT lock and a global in-flight counter, both also transient, reject reentrant swaps and block LP or admin calls while any JIT cycle is active. Nothing survives past the transaction. + +## Developer Resources + +To see where the capital sits between swaps and how it is accounted, read [Inventory and Yield](/docs/protocols/dualpool/concepts/inventory-and-yield). To execute a swap against a DualPool, read [Swap Against a DualPool](/docs/protocols/dualpool/guides/swap). diff --git a/content/protocols/dualpool/concepts/liquidity-distributions.mdx b/content/protocols/dualpool/concepts/liquidity-distributions.mdx new file mode 100644 index 000000000..246ad1a19 --- /dev/null +++ b/content/protocols/dualpool/concepts/liquidity-distributions.mdx @@ -0,0 +1,64 @@ +--- +title: Liquidity Distributions +description: Learn how DualPool operators shape just-in-time liquidity across weighted tick buckets. +--- + +## Liquidity Distributions + +When a swap arrives, DualPool does not deploy its capital into a single price range. Each pool carries an owner-configured distribution, a list of tick buckets, each with a weight: + +```solidity +struct LiquidityBucket { + int24 tickLower; + int24 tickUpper; + uint16 weightBps; +} +``` + +During a JIT cycle, the hook budgets each bucket's share of the deployable balance by weight, computes the corresponding liquidity at the current price, and adds one v4 position per bucket. Buckets may overlap, be asymmetric, or be non-contiguous. + +A distribution must satisfy: + +- 1 to 8 buckets +- Every `weightBps` nonzero, and weights summing to exactly 10,000 bps +- Ticks aligned to the pool's `tickSpacing` and within `TickMath` bounds + +## Example Shapes + +A typical stable-pair distribution concentrates depth at the peg with thinner cover around it: + +| Bucket | Tick Range | Weight | +| --- | --- | --- | +| Tight | `[-10, 10]` | 75% | +| Medium | `[-30, 30]` | 15% | +| Wide | `[-60, 60]` | 10% | + +Because buckets are free-form, operators can express more opinionated structures: + +- **Ultra-tight**: most weight inside `[-1, 1]` and `[-5, 5]`, maximizing capital efficiency for small swaps near the peg at the cost of sensitivity to drift +- **Barbell**: a tight center plus one-sided tail buckets (e.g. `[50, 250]`) that are out of range at rest but give large swaps something to trade into, without diluting liquidity across unused middle ticks +- **Inventory skew**: overweighting the side that sells the maker's excess asset, so directional flow naturally rebalances the pool +- **Peg defense**: laddering progressively deeper buckets on the vulnerable side of a peg while keeping a small symmetric center + +## Pre-Budgeted Allocation + +For each bucket, the hook first budgets the balance by weight: + +``` +weightedBal0 = bal0 * weightBps / 10_000 +weightedBal1 = bal1 * weightBps / 10_000 +``` + +then derives the bucket's liquidity with `LiquidityAmounts.getLiquidityForAmounts` and the exact token requirements with `SqrtPriceMath`. Pre-budgeting (rather than sizing every bucket against the full balance and scaling afterward) keeps deployment, indicative quotes, and execution aligned, and means the hook withdraws from the vaults only what the positions actually require at the current price. + +## Rotating Distributions + +The owner can replace a pool's distribution at any time with `setDistribution`, for example rotating between tighter and wider shapes by volatility regime. Updates are blocked while a JIT cycle is in flight, so rotations always happen cleanly between swaps. + +## Reading the Distribution + +`getDistribution(poolId)` exposes the active bucket list: tick bounds and weights. The summed, pre-budgeted liquidity of the buckets in range at the current tick is what backs `getIndicativeQuote`; out-of-range buckets deploy alongside them but only become active if a swap moves price into their range. + +## Developer Resources + +To create a pool and configure its first distribution, read [Create and Operate a Pool](/docs/protocols/dualpool/guides/create-pool). diff --git a/content/protocols/dualpool/concepts/lp-shares.mdx b/content/protocols/dualpool/concepts/lp-shares.mdx new file mode 100644 index 000000000..b5ec14a79 --- /dev/null +++ b/content/protocols/dualpool/concepts/lp-shares.mdx @@ -0,0 +1,67 @@ +--- +title: LP Shares +description: Understand DualPool's internal share accounting, virtual-shares inflation defense, and deposit locks. +--- + +## LP Shares + +Liquidity providers in a DualPool do not receive ERC-20 share tokens and do not hold v4 positions. Shares are internal, non-transferable accounting entries kept per pool by the hook. A share represents a proportional claim on the pool's [total assets](/docs/protocols/dualpool/concepts/inventory-and-yield) (vault shares, claims, and tracked ERC-20 together), so LPs earn swap fees and vault yield without taking any further action. + +## Bootstrap + +Every pool must be seeded by the owner before it can trade: + +```solidity +bootstrap(PoolKey key, uint256 amount0, uint256 amount1) returns (uint256 shares) +``` + +Bootstrap pulls both tokens, mints `sqrt(received0 * received1)` shares to the owner, and flips the pool live. The owner-supplied amounts set the initial share/asset ratio, which matters for pairs with asymmetric decimals. Amounts below a per-pool floor revert with `BootstrapTooSmall`, so the bootstrapper's economic claim is not meaningfully diluted by the virtual position described below. + +## Virtual-Shares Inflation Defense + +Share conversion uses the EIP-4626 virtual-offset pattern. Conversions add one virtual asset and `10 ** decimalsOffset` virtual shares to the ratio: + +``` +amount = shares * (totalAssets + 1) / (totalShares + 10 ** offset) +``` + +The offset is derived per pool from the pair's token decimals: + +``` +offset = clamp((decimals0 + decimals1) / 2 - 6, 6, 12) +``` + +For an 18/18-decimal pair the offset is 12; for a 6/6-decimal stablecoin pair it is 6. The virtual position makes post-bootstrap donation attacks uneconomic, at the cost of permanently retaining a dust amount of assets (roughly one token unit per side on a 6/6 pool). + + +Interfaces must reproduce this exact math, `totalShares + 10 ** offset` denominators with the rounding below, when displaying redeemable amounts. Naive pro-rata math will not match the contract. Prefer the contract's `previewWithdraw` answer when they differ. + + +## Deposits and Withdrawals + +After bootstrap: + +- `addLiquidity` mints a requested number of shares and pulls token0/token1 proportional to current total assets. Deposits round up, so new LPs cannot dilute existing LPs. +- `removeLiquidity` burns shares and returns proportional token0/token1. Withdrawals round down, so exiting LPs cannot over-withdraw. + +Both entry points take caller slippage bounds (`maxAmount0`/`maxAmount1` on deposit, `minAmount0`/`minAmount1` on withdrawal) and a deadline, protecting LPs from ratio changes and vault share-price moves between preview and execution. + +Use `previewDeposit(key, shares)` and `previewWithdraw(key, shares)` for offchain sizing, and `sharesOf(key, account)` / `totalShares(poolId)` to read positions. + +## Deposit Lock + +Each pool has a `minDepositBlocks` parameter, fixed at initialization, that controls how many blocks must elapse after an account's last deposit before it may withdraw: + +- `0`: no lock; same-block deposit-then-withdraw is allowed +- `1`: same-block withdrawals revert, preventing single-block fee sniping +- Larger values enforce longer holding periods + +A withdrawal inside the lock window reverts with `DepositLocked`. + +## External Deposits + +`addLiquidity` is owner-only by default. The owner can open a pool to third-party LPs with `setExternalDeposits(key, true)`; the current policy is readable via `externalDepositsEnabled(poolId)`. + +## Developer Resources + +For the end-to-end deposit and withdrawal flow, read [Provide Liquidity](/docs/protocols/dualpool/guides/provide-liquidity). diff --git a/content/protocols/dualpool/concepts/meta.json b/content/protocols/dualpool/concepts/meta.json new file mode 100644 index 000000000..0c5b884ad --- /dev/null +++ b/content/protocols/dualpool/concepts/meta.json @@ -0,0 +1,9 @@ +{ + "title": "Concepts", + "pages": [ + "jit-liquidity", + "inventory-and-yield", + "liquidity-distributions", + "lp-shares" + ] +} diff --git a/content/protocols/dualpool/deployments.mdx b/content/protocols/dualpool/deployments.mdx new file mode 100644 index 000000000..31fa082fc --- /dev/null +++ b/content/protocols/dualpool/deployments.mdx @@ -0,0 +1,33 @@ +--- +title: Deployments +description: Find current DualPool deployment addresses and live pool parameters. +--- + +A DualPool test deployment is live on Ethereum mainnet. The hook address encodes DualPool's [permission flags](/docs/protocols/dualpool/concepts/jit-liquidity) per the v4 convention. + +## Mainnet Deployments + +### Ethereum: 1 + +| Contract | Address | +|----------|---------| +| DualPoolHook | [`0x00000078BD49D5279a99b5F4011a5C61eE8caaC0`](https://etherscan.io/address/0x00000078BD49D5279a99b5F4011a5C61eE8caaC0) | + +## Live Pools + +### USDC/USDT + +| Parameter | Value | +|-----------|-------| +| Pool ID | `0xf32349cbc41fec9d3194f2b4e9ee72ded0bfda412427be9cb8a4087f74bdb065` | +| Fee | `10` pips (0.001%) | +| Tick spacing | `10` | +| Steakhouse High Yield USDC (ERC-4626) | [`0xbeeff2C5bF38f90e3482a8b19F12E5a6D2FCa757`](https://etherscan.io/address/0xbeeff2C5bF38f90e3482a8b19F12E5a6D2FCa757) | +| Steakhouse USDT Prime (ERC-4626) | [`0xbeef003C68896c7D2c3c60d363e8d71a49Ab2bf9`](https://etherscan.io/address/0xbeef003C68896c7D2c3c60d363e8d71a49Ab2bf9) | +| External deposits | Enabled | + +The `PoolKey` is `(USDC, USDT, 10, 10, DualPoolHook)`, and the pool ID is `keccak256(abi.encode(poolKey))`. Both tokens use 6 decimals. + + +Do not hardcode derived values the chain can tell you: read vault addresses from `vaults(poolId, currency)` on the hook, and compute pool IDs by hashing the key. + diff --git a/content/protocols/dualpool/guides/create-pool.mdx b/content/protocols/dualpool/guides/create-pool.mdx new file mode 100644 index 000000000..6bd40f633 --- /dev/null +++ b/content/protocols/dualpool/guides/create-pool.mdx @@ -0,0 +1,110 @@ +--- +title: Create and Operate a Pool +description: Initialize, bootstrap, and manage a DualPool as the hook owner. +--- + +## Context + +DualPool pools are created and operated by the hook owner; pool creation is not permissionless. The owner configures the pool's fee, price, yield vaults, and liquidity distribution at creation, seeds the first deposit, and manages liveness and configuration afterward. + +This guide walks the full lifecycle: configuration, initialization, bootstrap, and ongoing operations. + +## 1. Configure the PoolKey + +```solidity +PoolKey memory key = PoolKey({ + currency0: currency0, + currency1: currency1, + fee: lpFee, // static fee in pips, e.g. 10 = 0.001% + tickSpacing: tickSpacing, + hooks: IHooks(address(dualPoolHook)) +}); +``` + +Constraints enforced at initialization: + +- _Currencies_ must be sorted, and neither may be native ETH. DualPool rejects `address(0)` currencies with `NativeNotSupported`; use wrapped-token pairs +- _fee_ must be a concrete static value at most `MAX_LP_FEE`; the dynamic-fee flag is rejected with `DynamicFeeNotSupported` +- _hooks_ must be the DualPool hook address + + +`key.fee` is fixed for the pool's lifetime; the hook cannot update it in place. To change the fee, deploy a new pool with a different `PoolKey.fee`. + + +## 2. Choose the Vaults + +Each currency may be paired with an ERC-4626 vault that holds the pool's idle capital. Vaults must satisfy, at initialization: + +- `vault.asset()` equals the pool currency (`VaultAssetMismatch` otherwise) +- Feeless entry and exit: `previewDeposit == convertToShares` and `previewRedeem == convertToAssets` (`VaultChargesEntryFee` / `VaultChargesExitFee` otherwise) + +The hook grants each configured vault a standing max token allowance so hot-path JIT deposits avoid an allowance read. This is a deliberate trust tradeoff: a compromised vault can pull the hook's full balance of that currency, including balances attributed to other pools sharing the token. Choose immutable or well-governed vaults, and see [Security](/docs/protocols/dualpool/security) for the emergency controls. + +## 3. Initialize the Pool + +```solidity +DualPoolHook.PoolConfig memory config = DualPoolHook.PoolConfig({ + sqrtPriceX96: TickMath.getSqrtPriceAtTick(0), // initial price; tick 0 = 1:1 + distribution: buckets, // see below + allowExternalDeposits: false, // owner-only LPs to start + vault0: vault0, + vault1: vault1, + minDepositBlocks: 1 // 1 = ban same-block deposit-withdraw +}); + +hook.initializePool(key, config); +``` + +The distribution is a list of 1-8 weighted tick buckets whose weights sum to 10,000 bps. A conservative stable-pair shape: + +```solidity +LiquidityBucket[] memory buckets = new LiquidityBucket[](3); +buckets[0] = LiquidityBucket({tickLower: -10, tickUpper: 10, weightBps: 7500}); +buckets[1] = LiquidityBucket({tickLower: -30, tickUpper: 30, weightBps: 1500}); +buckets[2] = LiquidityBucket({tickLower: -60, tickUpper: 60, weightBps: 1000}); +``` + +See [Liquidity Distributions](/docs/protocols/dualpool/concepts/liquidity-distributions) for design patterns and validation rules. + +`initializePool` validates the configuration, binds the vaults, grants their allowances, derives the pool's [virtual-shares offset](/docs/protocols/dualpool/concepts/lp-shares) from the pair's token decimals, and initializes the v4 pool at `sqrtPriceX96`. Direct `PoolManager.initialize` calls on the hook's pools are blocked. + + +A newly initialized pool is not live. Swaps revert with `PoolNotLive` until bootstrap, which closes the window where a swap could move the pool price against zero liquidity. + + +## 4. Bootstrap + +Seed the pool with its first inventory: + +```solidity +IERC20(token0).approve(address(hook), amount0); +IERC20(token1).approve(address(hook), amount1); + +uint256 shares = hook.bootstrap(key, amount0, amount1); +``` + +Bootstrap pulls both tokens, mints `sqrt(received0 * received1)` shares to the owner, and flips the pool live. The minted shares must meet a floor of `100 * 10 ** decimalsOffset` (`BootstrapTooSmall` otherwise), about 100 tokens per side for a 6/6-decimal pair. The supplied ratio sets the initial share/asset ratio, which matters for asymmetric-decimal pairs. + +From this point, swaps trigger JIT deployment and teardown automatically; there is no keeper or maintenance loop. + +## 5. Operate + +| Function | Purpose | +| --- | --- | +| `setPoolLive(key, live)` | Pause or resume swaps without changing configuration | +| `setDistribution(key, buckets)` | Replace the tick-bucket distribution (blocked mid-JIT-cycle) | +| `setExternalDeposits(key, enabled)` | Open or close the pool to third-party LPs | +| `refreshVaultApproval(key, currency)` | Re-arm a vault's max allowance | +| `emergencyRevokeVault(key)` | Atomically pause the pool, disable deposits, zero both vault allowances, and best-effort drain the vaults back to the hook | + +Ownership uses `Ownable2Step`: transfers require the new owner to accept, and `renounceOwnership` is disabled. + +Things to monitor in operation: + +- `getReserves` vs `getEffectiveLiquidity`: a widening gap means the vault is throttling withdrawals +- Quote-vs-execution fidelity from router infrastructure: sustained divergence costs routed flow +- Vault health: the vaults are the pool's largest external dependency + +## Next Steps + +Once the pool is live, LPs interact through the flows in [Provide Liquidity](/docs/protocols/dualpool/guides/provide-liquidity), and routers discover it per [Integrate as a Router or Aggregator](/docs/protocols/dualpool/guides/router-integration). diff --git a/content/protocols/dualpool/guides/meta.json b/content/protocols/dualpool/guides/meta.json new file mode 100644 index 000000000..6afffea1b --- /dev/null +++ b/content/protocols/dualpool/guides/meta.json @@ -0,0 +1,9 @@ +{ + "title": "Guides", + "pages": [ + "swap", + "provide-liquidity", + "router-integration", + "create-pool" + ] +} diff --git a/content/protocols/dualpool/guides/provide-liquidity.mdx b/content/protocols/dualpool/guides/provide-liquidity.mdx new file mode 100644 index 000000000..b64eea4a3 --- /dev/null +++ b/content/protocols/dualpool/guides/provide-liquidity.mdx @@ -0,0 +1,107 @@ +--- +title: Provide Liquidity +description: Deposit into and withdraw from DualPool pools using the hook's share-based liquidity functions. +--- + +## Context + +Liquidity provision in DualPool goes through the hook, not the v4 `PositionManager`; direct v4 liquidity operations on a DualPool are blocked by the hook's permissions. Deposits mint internal, non-transferable [pool shares](/docs/protocols/dualpool/concepts/lp-shares) that represent a proportional claim on the pool's total assets, and earn both swap fees and vault yield with no further action. + +This guide covers checking deposit eligibility, depositing, reading a position, withdrawing, and claiming rewards on incentivized pools. + +## 1. Check the Pool Accepts Deposits + +`addLiquidity` is owner-only unless the pool's operator has enabled external deposits: + +```solidity +bool open = hook.externalDepositsEnabled(poolId); +bool live = hook.livePools(poolId); +``` + +A pool must also be bootstrapped (live) before third-party deposits are possible. + +## 2. Preview the Deposit + +DualPool's LP interface is share-denominated: you request a number of shares to mint, and the hook pulls token amounts proportional to current total assets. Preview the token cost first: + +```solidity +(uint256 amount0, uint256 amount1) = hook.previewDeposit(key, sharesToMint); +``` + +Deposits round up, so the executed amounts can differ slightly from a stale preview if total assets move (a swap, or vault yield accruing) between preview and execution. + +## 3. Approve the Tokens + +The hook pulls tokens with `transferFrom`, so approve the hook on both currencies: + +```solidity +IERC20(token0).approve(address(hook), amount0Max); +IERC20(token1).approve(address(hook), amount1Max); +``` + + +Some tokens (e.g. USDT) require resetting a non-zero allowance to zero before setting a new one. Use a max-style approval or a force-approve helper. + + +## 4. Add Liquidity + +```solidity +(uint256 amount0, uint256 amount1) = hook.addLiquidity( + key, + sharesToMint, + maxAmount0, // slippage bound on token0 + maxAmount1, // slippage bound on token1 + deadline // unix timestamp +); +``` + +`maxAmount0`/`maxAmount1` bound how many tokens the deposit may pull; if the pool ratio moves against you past those bounds, the call reverts with `SlippageExceeded`. Expired deadlines revert with `DeadlineExpired`. + + +Fee-on-transfer and rebasing tokens are unsupported. If the hook receives fewer tokens than requested, the deposit reverts with `TransferReceiptShortfall`. + + +## 5. Read Your Position + +```solidity +uint256 shares = hook.sharesOf(key, account); +uint256 supply = hook.totalShares(poolId); +(uint256 total0, uint256 total1) = hook.totalAssets(key); +(uint256 out0, uint256 out1) = hook.previewWithdraw(key, shares); +``` + +`previewWithdraw` applies the exact [virtual-shares conversion](/docs/protocols/dualpool/concepts/lp-shares) the contract uses, including rounding, so prefer it over recomputing pro-rata amounts offchain. + +## 6. Remove Liquidity + +```solidity +(uint256 amount0, uint256 amount1) = hook.removeLiquidity( + key, + sharesToBurn, + minAmount0, // slippage bound on token0 + minAmount1, // slippage bound on token1 + deadline +); +``` + +Withdrawals round down and enforce `minAmount0`/`minAmount1` after transfer. Internally, the hook first redeems any pending ERC-6909 claims through a `PoolManager` unlock, so you can exit even when post-swap balances sit in claims rather than in the vault. + +Two timing rules to be aware of: + +- **Deposit lock**: if the pool was configured with `minDepositBlocks > 0`, withdrawals revert with `DepositLocked` until that many blocks have passed since your last deposit +- **JIT cycles**: LP entry points revert with `JITInProgress` if called while any swap's JIT cycle is in flight on the hook (only possible from a contract executing within a swap transaction) + +## Claim Rewards (Incentivized Pools) + +Pools deployed on `DualPoolIncentivizedHook` additionally stream a per-pool reward token to shareholders, Synthetix-style, on a per-block schedule: + +```solidity +uint256 pending = hook.earned(key, account); +uint256 claimed = hook.claimRewards(key); +``` + +Reward accrual checkpoints on share changes (deposits and withdrawals), never on swaps, so claiming does not interact with the swap path. + +## Next Steps + +To understand what backs your shares, and why reserves and immediately-withdrawable assets can differ, read [Inventory and Yield](/docs/protocols/dualpool/concepts/inventory-and-yield). diff --git a/content/protocols/dualpool/guides/router-integration.mdx b/content/protocols/dualpool/guides/router-integration.mdx new file mode 100644 index 000000000..4d28d9fb1 --- /dev/null +++ b/content/protocols/dualpool/guides/router-integration.mdx @@ -0,0 +1,94 @@ +--- +title: Integrate as a Router or Aggregator +description: Source liquidity from DualPool pools with the hook's quote surface, sizing rules, and failure modes. +--- + +## Context + +This guide is for aggregators, routers, solvers, and quote APIs sourcing liquidity from DualPool pools. The headline: **execution is a completely ordinary v4 swap; only quoting is different.** A DualPool holds zero persistent v4 liquidity between swaps, so onchain pool depth is not a routing signal: `getSlot0` shows a price, but `getLiquidity` is ~0. Capacity is discovered through the hook's view functions. + +## Detect a DualPool + +There is no shared registry. Discover pools through your own tracking, then confirm the hook implements the ALF view interface via ERC-165: + +```solidity +bool isAlf = IERC165(address(key.hooks)).supportsInterface(type(IALFHook).interfaceId); +``` + +A hook that passes carries the uniform `IALFHook` quote surface below, so you do not need to distinguish DualPool from other ALF strategies. DualPool-specific signals, if you want them, include `livePools(poolId)` and `getDistribution(poolId)`. + +## The Quote Surface + +All methods are `view`, intended to be called via `staticcall`: + +| Method | Returns | Use it for | +| --- | --- | --- | +| `getIndicativeQuote(key, zeroForOne, amountSpecified, hookData)` | `uint256` | Single-number indicative for ranking | +| `swapToPrice(key, zeroForOne, amountSpecified, sqrtPriceLimitX96, hookData)` | `(amountIn, amountOut)` | Price-bounded fill simulation for split planning | +| `getReserves(key)` | `(token0, token1)` | Total economic assets under management | +| `getEffectiveLiquidity(key)` | `(token0, token1)` | Immediately swappable liquidity | +| `maxGas()` | `uint32` | Gas budget to cap your `staticcall` at | +| `isLive()` | `bool` | Hook-level liveness (see caveat below) | + +Conventions: + +- `amountSpecified` follows v4's convention: negative for exact input, positive for exact output. Exact-input quotes return the expected output; exact-output quotes return the required input. +- `zeroForOne = true` swaps `currency0 -> currency1`. +- `hookData` is ignored entirely; pass empty bytes. There are no attestations, signed curves, or per-swap discounts. +- The quote already composes the static LP fee (`key.fee`) with any v4 protocol fee. Do not re-apply either. + + +The views return `0` rather than reverting when the hook cannot price a swap: the pool is paused or un-bootstrapped, effective balances are zero, or an exact-output request exceeds deliverable reserves. Treat `0` as "do not route here right now." + + +## Size Fills Against Effective Liquidity + +This is the one accounting subtlety that affects fill sizing: + +- `getReserves` is what LPs own: `vault.convertToAssets(shares)` + claims + tracked ERC-20 +- `getEffectiveLiquidity` is what the hook can withdraw and deploy this block: `vault.previewRedeem(shares)` + claims + tracked ERC-20 + +When the pool's ERC-4626 vault is paused, capped, or utilization-constrained, effective liquidity drops below reserves. The JIT cycle and quote path both size against **effective** liquidity, so plan fills against `getEffectiveLiquidity`, not `getReserves`. `swapToPrice` already does this internally, so prefer it for split planning. + +## Quote Fidelity + +`getIndicativeQuote` and `swapToPrice` are a compact, single-step simulation: they sum the liquidity of the [distribution buckets](/docs/protocols/dualpool/concepts/liquidity-distributions) in range at the current tick and run one `SwapMath.computeSwapStep` against that constant liquidity. Implications: + +- Accurate for swaps that stay within the current in-range bucket set, the common small and medium swap near the peg +- No tick-walking across bucket boundaries: a large swap that crosses into or out of bucket ranges sees liquidity change mid-swap that the single-step quote does not capture +- The output leg is capped at the effective output reserve, so a quote can never exceed what the pool can deliver + +Recommendations: + +1. Use `getIndicativeQuote` for ranking +2. Use `swapToPrice` with a real `sqrtPriceLimitX96` for split planning and tighter fills, so the simulation is bounded the same way execution will be +3. Track persistent quote-vs-fill divergence per pool and feed it into your routing reputation model; the design expects routers to deprioritize on sustained divergence + +## Failure Modes + +| Situation | What you observe | Handling | +| --- | --- | --- | +| Pool paused or not yet bootstrapped | Quote views return `0`; direct swap reverts `PoolNotLive(poolId)` | Skip on `0`; pre-filter with `livePools(poolId)` | +| Vault throttled | `getEffectiveLiquidity < getReserves`; oversized fills under-fill or revert on the vault withdrawal | Size against effective liquidity | +| Quote/execution divergence on large swaps | Realized output differs from the indicative | Use `swapToPrice`; track divergence | +| `isLive()` returns true for a paused pool | `isLive()` is hook-level, not per-pool | Use `livePools(poolId)` or the `0`-quote signal for pool liveness | + +## Execution + +Nothing here is DualPool-specific: swap as you would against any v4 pool, via the `PoolManager` or the Universal Router, and enforce your own slippage. See [Swap Against a DualPool](/docs/protocols/dualpool/guides/swap). + +## Integration Checklist + +- [ ] Confirm the hook via `supportsInterface(type(IALFHook).interfaceId)` +- [ ] Quote through `getIndicativeQuote` / `swapToPrice`, never pool liquidity +- [ ] Pass `hookData = ""` +- [ ] Read the fee from `key.fee` (static; never the dynamic-fee flag) +- [ ] Size fills against `getEffectiveLiquidity`, not `getReserves` +- [ ] Cap quote `staticcall` gas at `maxGas()` +- [ ] Treat a `0` quote as "skip"; pre-filter with `livePools(poolId)` for direct routing +- [ ] Enforce your own slippage on execution +- [ ] Track quote fidelity per pool + +## Next Steps + +For where the numbers behind `getReserves` and `getEffectiveLiquidity` come from, read [Inventory and Yield](/docs/protocols/dualpool/concepts/inventory-and-yield). diff --git a/content/protocols/dualpool/guides/swap.mdx b/content/protocols/dualpool/guides/swap.mdx new file mode 100644 index 000000000..5932ae9a2 --- /dev/null +++ b/content/protocols/dualpool/guides/swap.mdx @@ -0,0 +1,100 @@ +--- +title: Swap Against a DualPool +description: Quote and execute swaps against DualPool pools using standard Uniswap v4 execution. +--- + +## Context + +Executing a swap against a DualPool requires nothing DualPool-specific. The pool is a standard Uniswap v4 pool; the hook deploys liquidity inside `beforeSwap` and removes it in `afterSwap`, transparently to the swapper. If your integration already swaps against hooked v4 pools, the execution path is unchanged. + +The two things that differ from a vanilla pool: + +1. **Quoting**: the pool holds ~0 resident liquidity between swaps, so quotes come from the hook's view functions, not from pool state or a tick-walking quoter +2. **Gas**: each swap pays for a full JIT cycle (vault withdrawal, position adds/removes, re-deposit), which costs several times a vanilla v4 swap + +## Get a Quote + +Quote through the hook's `getIndicativeQuote`: + +```solidity +uint256 quoted = IALFHook(address(key.hooks)).getIndicativeQuote( + key, + zeroForOne, // true = currency0 -> currency1 + amountSpecified, // < 0 exact input, > 0 exact output (v4 convention) + "" // hookData is ignored; pass empty bytes +); +``` + +- For exact input (`amountSpecified < 0`), the return value is the expected output +- For exact output (`amountSpecified > 0`), the return value is the required input +- A return value of `0` means the pool cannot price the swap right now (paused, unfunded, or the requested output exceeds deliverable reserves); do not route to it + + +`getIndicativeQuote` is a non-binding upper bound, not a firm price. Enforce your own slippage with `amountOutMinimum` (or a maximum input) on execution. Never forward the quote as the user's bound. + + +## Execute via Universal Router + +Swaps route through the [Universal Router](/docs/protocols/universal-router/overview) exactly as for any v4 pool, with the `V4_SWAP` command: + +```solidity +bytes memory commands = abi.encodePacked(uint8(Commands.V4_SWAP)); + +bytes memory actions = abi.encodePacked( + uint8(Actions.SWAP_EXACT_IN_SINGLE), + uint8(Actions.SETTLE_ALL), + uint8(Actions.TAKE_ALL) +); + +bytes[] memory params = new bytes[](3); +params[0] = abi.encode( + IV4Router.ExactInputSingleParams({ + poolKey: key, + zeroForOne: zeroForOne, + amountIn: amountIn, + amountOutMinimum: amountOutMinimum, // your slippage bound + hookData: "" // ignored by DualPool + }) +); +params[1] = abi.encode(inputCurrency, amountIn); +params[2] = abi.encode(outputCurrency, amountOutMinimum); + +bytes[] memory inputs = new bytes[](1); +inputs[0] = abi.encode(actions, params); + +router.execute(commands, inputs, deadline); +``` + +Token allowances follow the standard [Permit2](/docs/protocols/permit2/overview) flow: a one-time max ERC-20 approval to Permit2, then a signed `PermitSingle` embedded in the same `execute` call via the `PERMIT2_PERMIT` command. Steady-state swaps that already have an unexpired permit are a single `V4_SWAP` command. + +For a full walkthrough of v4 swap encoding, see the [v4 swapping guides](/docs/protocols/v4/guides/swapping/getting-started). + +## Execute via PoolManager + +Contracts integrating at the core layer swap through the `PoolManager` directly. Call `unlock`, then inside `unlockCallback`: + +```solidity +SwapParams memory params = SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: amountSpecified, // < 0 exact input, > 0 exact output + sqrtPriceLimitX96: yourPriceLimit // your slippage bound +}); +BalanceDelta delta = poolManager.swap(key, params, ""); +``` + +Then settle and take the resulting deltas as usual. See [Unlock Callback and Deltas](/docs/protocols/v4/guides/unlock-callback-and-deltas). + +## Practical Notes + +- **Paused pools revert.** A paused or not-yet-bootstrapped pool reverts with `PoolNotLive(poolId)` on execution and returns `0` from the quote views. Read `livePools(poolId)` on the hook to pre-filter. +- **Native ETH is unsupported.** DualPool rejects native-currency pools at creation; use wrapped-token pairs. +- **No callbacks into the swapper.** DualPool declares no return-delta permissions, so your `BalanceDelta` is plain v4 swap math at the pool's static `key.fee`. +- **Token quirks still apply.** For example, USDT requires resetting a non-zero allowance to zero before changing it, and both USDC and USDT use 6 decimals; read `decimals()` rather than assuming 18. + + +A DualPool swap costs roughly 15x a vanilla v4 swap (observed 1.7M-2.1M gas on the mainnet USDC/USDT pool) because the JIT cycle includes vault withdrawals and deposits. At sub-gwei base fees this is pennies, but routers should weigh it in EV calculations. + + +## Next Steps + +Building a router or aggregator integration? Read [Integrate as a Router or Aggregator](/docs/protocols/dualpool/guides/router-integration) for the full quote surface, sizing rules, and failure modes. diff --git a/content/protocols/dualpool/meta.json b/content/protocols/dualpool/meta.json new file mode 100644 index 000000000..df668e372 --- /dev/null +++ b/content/protocols/dualpool/meta.json @@ -0,0 +1,4 @@ +{ + "title": "DualPool", + "pages": ["overview", "concepts", "guides", "deployments", "security"] +} diff --git a/content/protocols/dualpool/overview.mdx b/content/protocols/dualpool/overview.mdx new file mode 100644 index 000000000..06292eb63 --- /dev/null +++ b/content/protocols/dualpool/overview.mdx @@ -0,0 +1,38 @@ +--- +title: Overview +description: Navigate DualPool concepts, integration guides, and references for the yield-rehypothecating AMM built on Uniswap v4. +--- + +DualPool is a market-making protocol built as a [Uniswap v4 hook](/docs/protocols/v4/concepts/hooks). In a vanilla pool, liquidity sits in the pool at all times earning only swap fees. In a DualPool, capital rests in ERC-4626 yield vaults between swaps and is deployed as concentrated liquidity just-in-time, per swap. Liquidity providers earn swap fees and vault yield on the same dollar. + +From the outside, a DualPool is an ordinary v4 pool: swappers and routers execute standard v4 swaps at the pool's static fee. The difference is where the capital lives when no swap is in flight, and that the pool holds approximately zero resident v4 liquidity between swaps, so integrators discover capacity through the hook's quote views rather than through pool state. + +## Concepts + +- [Just-in-Time Liquidity](/docs/protocols/dualpool/concepts/jit-liquidity) +- [Inventory and Yield](/docs/protocols/dualpool/concepts/inventory-and-yield) +- [Liquidity Distributions](/docs/protocols/dualpool/concepts/liquidity-distributions) +- [LP Shares](/docs/protocols/dualpool/concepts/lp-shares) + +## Guides + +- [Swap Against a DualPool](/docs/protocols/dualpool/guides/swap) +- [Provide Liquidity](/docs/protocols/dualpool/guides/provide-liquidity) +- [Integrate as a Router or Aggregator](/docs/protocols/dualpool/guides/router-integration) +- [Create and Operate a Pool](/docs/protocols/dualpool/guides/create-pool) + +## Related Protocols + +- [Uniswap v4](/docs/protocols/v4/overview) +- [Universal Router](/docs/protocols/universal-router/overview) +- [Permit2](/docs/protocols/permit2/overview) + +## Deployments + +- [DualPool deployment addresses](/docs/protocols/dualpool/deployments) + +## Security + +DualPool was audited by OpenZeppelin in May-June 2026, with no critical or high-severity findings and all medium-severity findings resolved. + +For the trust model, protocol invariants, and audit summary, see [Security](/docs/protocols/dualpool/security). diff --git a/content/protocols/dualpool/security.mdx b/content/protocols/dualpool/security.mdx new file mode 100644 index 000000000..cc973dc02 --- /dev/null +++ b/content/protocols/dualpool/security.mdx @@ -0,0 +1,68 @@ +--- +title: Security +description: Review DualPool's trust model, protocol invariants, and audit history. +--- + +## Audit + +DualPool was audited by OpenZeppelin (May 20 to June 17, 2026). The audit reported no critical and no high-severity issues; all findings and notes were resolved. The full report is published in the DualPool repository. + +## Trust Model + +### Owner + +The hook owner is the protocol's single privileged principal. Ownership uses `Ownable2Step`, so transfers require the recipient to accept, and `renounceOwnership` is disabled, so the hook can never become unowned. + +The owner controls: + +- Pool creation (`initializePool`) and seeding (`bootstrap`) +- Liveness (`setPoolLive`): pausing and resuming swaps +- The liquidity distribution (`setDistribution`) +- External deposit policy (`setExternalDeposits`) +- Vault allowance management (`refreshVaultApproval`, `emergencyRevokeVault`) + +The owner does **not** control pricing: the LP fee is fixed at pool creation and cannot be updated, and `hookData` cannot alter fees or execution. + +### Vaults + +The pool's ERC-4626 vaults are its largest external dependency. The hook grants each configured vault a standing max token allowance, so a compromised or malicious vault can pull the hook's full balance of that currency, including balances attributed to other pools sharing the same token. Operators should choose immutable or well-governed vaults. + +Mitigations built into the protocol: + +- Vaults must be feeless on entry and exit, verified at pool initialization +- JIT sizing and quotes use `previewRedeem`, so a throttled vault reduces routable liquidity without corrupting LP claims +- Vault deposits in `afterSwap` are best-effort: a capped or paused vault cannot brick swaps (the deposit is skipped and retried on later cycles) +- `emergencyRevokeVault` atomically pauses the pool, disables deposits, zeroes both vault allowances, and best-effort drains vault balances back to the hook + +Curated or gated vaults (e.g. Morpho VaultV2) additionally require trusting the curator not to deny the hook withdrawal access. + +### Tokens + +Fee-on-transfer and rebasing tokens are unsupported. Deposits measure actual receipt and revert on shortfall, so a fee-charging token cannot seed a pool. This is a deposit-time check only, so operators must still restrict pools to well-behaved tokens. Native ETH is rejected at pool creation. + +## Reentrancy Model + +There are two defenses because there are two kinds of entry: + +1. User and admin entry points (`bootstrap`, `addLiquidity`, `removeLiquidity`, owner configuration) use OpenZeppelin's transient `nonReentrant` guard +2. `PoolManager` callbacks use transient JIT locks: a per-pool lock rejects reentrant `beforeSwap` on the same pool, and a global in-flight counter rejects LP or admin calls while any JIT cycle is active anywhere on the hook, so a malicious vault callback during pool A's swap cannot enter pool B + +`unlockCallback` is callable only by the `PoolManager`. + +## Core Invariants + +The implementation is shaped around these invariants: + +- No persistent DualPool v4 liquidity remains after a completed swap +- Direct `PoolManager` initialization, add-liquidity, and remove-liquidity paths are blocked +- Pools reject dynamic fees; pricing is static for the pool's lifetime +- Pools are not swappable until `bootstrap` succeeds, and share supply cannot return to zero afterward +- Deposits round up; withdrawals round down; virtual-shares math bounds inflation attacks +- Asset custody is tracked per `(PoolId, Currency)`, never by the hook's global token balance, and claims minted for one pool cannot be redeemed into another pool's ledger +- Distribution weights sum to exactly 10,000 bps with at most 8 buckets +- User and admin mutators cannot run during any in-flight JIT cycle +- `hookData` cannot change DualPool pricing or fees + +## Quote Semantics + +`getIndicativeQuote` is a non-binding upper bound intended for ranking, capped so it can never exceed deliverable reserves. Binding slippage protection belongs in the caller: enforce a minimum output or maximum input on every execution. See [Integrate as a Router or Aggregator](/docs/protocols/dualpool/guides/router-integration) for fidelity characteristics and failure modes. diff --git a/content/protocols/meta.json b/content/protocols/meta.json index 3c97600f8..a28664f67 100644 --- a/content/protocols/meta.json +++ b/content/protocols/meta.json @@ -7,6 +7,7 @@ "v4", "v3", "v2", + "dualpool", "smart-wallet", "the-compact", "permit2",