Skip to content
Open
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
60 changes: 60 additions & 0 deletions content/protocols/dualpool/concepts/inventory-and-yield.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Callout type="note" title="Claims earn no yield">
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.
</Callout>

## 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.

<Callout type="info">
`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.
</Callout>

## 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).
59 changes: 59 additions & 0 deletions content/protocols/dualpool/concepts/jit-liquidity.mdx
Original file line number Diff line number Diff line change
@@ -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`.

<Callout type="info">
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).
</Callout>

## 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).
64 changes: 64 additions & 0 deletions content/protocols/dualpool/concepts/liquidity-distributions.mdx
Original file line number Diff line number Diff line change
@@ -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).
67 changes: 67 additions & 0 deletions content/protocols/dualpool/concepts/lp-shares.mdx
Original file line number Diff line number Diff line change
@@ -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).

<Callout type="warn">
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.
</Callout>

## 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).
9 changes: 9 additions & 0 deletions content/protocols/dualpool/concepts/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"title": "Concepts",
"pages": [
"jit-liquidity",
"inventory-and-yield",
"liquidity-distributions",
"lp-shares"
]
}
33 changes: 33 additions & 0 deletions content/protocols/dualpool/deployments.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Callout type="note">
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.
</Callout>
Loading
Loading