UniswapV3AdapterLib
Title: UniswapV3AdapterLib
Authors: David Dada <daveproxy80@gmail.com> (https://github.com/dadadave80), Modified from Uniswap V3 (https://github.com/Uniswap/v3-periphery)
Logic for a custom, full-range Uniswap V3 LP strategy. A v3 LP is a two-token,
NFT-wrapped concentrated-liquidity position that does not fit the single-asset IStrategy
surface cleanly, so this adapter makes three deliberate simplifications: (1) the position
is pinned to full-range (min/max usable ticks) — no active range management; (2) the
adapter is swap-free — the keeper funds both token0 and token1; (3) the vault-facing
asset is token0 and all NAV is denominated in token0.
NAV is token0 only (#271). Being swap-free, the adapter can only ever hand the vault token0, and the vault counts only its asset. NAV is therefore idle token0 plus the position's token0 leg. token1 is never NAV, whether it sits idle in the adapter (keeper-funded, or left over from a deploy) or in the position's token1 leg. Counting it would put value in the share price that no redeemer can receive.
Valuation — TWAP, never spot (the central risk). NAV reads the pool's TWAP via
pool.observe(twapWindow), converts the arithmetic-mean tick to a sqrt price, and derives
the position's token0 leg at that price. It never reads slot0: the spot tick is
single-block manipulable (a flash swap can push it arbitrarily), and pricing vault shares off
spot would let an attacker mint/redeem at a skewed NAV. The TWAP averages the tick over the
whole window, so a one-block spike barely moves it. Uncollected fees are NOT counted in NAV
(they are the yield distribution, forwarded raw on harvest).
Deploys happen at spot, so the guard bounds them. The pool prices a mint or increase at spot,
while NAV counts the new token0 leg at the TWAP. A deploy therefore steps NAV by about
consumed0 × (√(P_spot / P_twap) − 1), and deploy runs inside the permissionless rebalance(),
where anyone can move spot first. deploy refuses any step larger than slippageBps of the token0
it consumed (UniswapV3AdapterDeployOffTwap); the StrategyManager then reports the failed deploy and
the token0 stays idle and counted. Within the bound the step is real: an attacker choosing spot can
move NAV by up to slippageBps of each deploy, so keep slippageBps tight.
Withdraw pays idle token0 only. withdraw(amount, to) is token0-denominated and sends at
most the adapter's idle token0; it never removes liquidity. A decrease pays out at spot while
NAV counts the token0 leg at the TWAP, so with spot above the TWAP it frees less token0 than NAV
drops by, and the StrategyManager's value-loss check reverts the whole rebalance(); anyone
who can move spot could force that. Deciding from spot would make the recall manipulable, and
the token1 a decrease frees would sit idle with nothing to pair it. It returns the REAL token0
delta and never over-reports; anything beyond the idle token0 is an honest partial recall,
which the StrategyManager accepts.
Exit needs the admin. rebalance() can allocate into the position but never recall from it,
so capital allocated here reaches redeemers only after the admin's emergencyWithdraw; size the
target with that in mind. At a target of 0 a rebalance recalls the idle token0, the position's
token0 leg stays counted, and removeStrategy refuses the adapter. To retire it: set the target
to 0 and rebalance, then the admin calls emergencyWithdraw, which removes the position and sends
both tokens to the vault; then removeStrategy. The token0 replaces the token0 leg NAV already
counted, but the pool pays it at spot, so NAV steps by about leg0 × (√(P_twap / P_spot) − 1)
with no slippage floor: the admin should exit while spot sits near the TWAP (and through a private
transaction where one is available). The token1 lands in the vault outside NAV, and VaultCore has
no sweep for a non-asset token, so it stays there unless the vault's admin cuts one in.
All heavy math lives in UniswapV3FullRangeMath to keep the facet under the 24KB limit.
Functions
uniswapV3AdapterStorage
function uniswapV3AdapterStorage() internal pure returns (UniswapV3AdapterStorage storage $);Returns
| Name | Type | Description |
|---|---|---|
| $ | UniswapV3AdapterStorage |
__UniswapV3Adapter_init
function __UniswapV3Adapter_init(
address positionManager_,
address pool_,
address vault_,
address recipient_,
uint32 twapWindow_,
uint256 slippageBps_
) internal;Parameters
| Name | Type | Description |
|---|---|---|
| positionManager_ | address | |
| pool_ | address | |
| vault_ | address | |
| recipient_ | address | |
| twapWindow_ | uint32 | |
| slippageBps_ | uint256 |
_initPoolConfig
Reads the pool's token0/token1/fee/tickSpacing, validates the spacing, computes the full-range ticks, and writes them to storage. Split out of the initializer for stack room.
function _initPoolConfig(UniswapV3AdapterStorage storage $, address pool_) private;Parameters
| Name | Type | Description |
|---|---|---|
| $ | UniswapV3AdapterStorage | |
| pool_ | address |
registerInterface
function registerInterface() internal;asset
function asset() internal view returns (address);Returns
| Name | Type | Description |
|---|---|---|
| <none> | address |
positionManager
function positionManager() internal view returns (address);Returns
| Name | Type | Description |
|---|---|---|
| <none> | address |
pool
function pool() internal view returns (address);Returns
| Name | Type | Description |
|---|---|---|
| <none> | address |
token0
function token0() internal view returns (address);Returns
| Name | Type | Description |
|---|---|---|
| <none> | address |
token1
function token1() internal view returns (address);Returns
| Name | Type | Description |
|---|---|---|
| <none> | address |
fee
function fee() internal view returns (uint24);Returns
| Name | Type | Description |
|---|---|---|
| <none> | uint24 |
tokenId
function tokenId() internal view returns (uint256);Returns
| Name | Type | Description |
|---|---|---|
| <none> | uint256 |
vault
function vault() internal view returns (address);Returns
| Name | Type | Description |
|---|---|---|
| <none> | address |
twapWindow
function twapWindow() internal view returns (uint32);Returns
| Name | Type | Description |
|---|---|---|
| <none> | uint32 |
slippageBps
function slippageBps() internal view returns (uint256);Returns
| Name | Type | Description |
|---|---|---|
| <none> | uint256 |
rewardRecipient
function rewardRecipient() internal view returns (address);Returns
| Name | Type | Description |
|---|---|---|
| <none> | address |
operator
function operator() internal view returns (address);Returns
| Name | Type | Description |
|---|---|---|
| <none> | address |
_checkOperator
Reverts ProtocolAdapterUnauthorized unless the caller is the wired operator. Placed at
the very top of deploy/withdraw/harvest — BEFORE the reentrancy guard — so an
unauthorized call never leaves the guard latched. Zero operator ⇒ always reverts.
function _checkOperator() private view;minHealthFactor
function minHealthFactor() internal pure returns (uint256);Returns
| Name | Type | Description |
|---|---|---|
| <none> | uint256 |
healthFactor
function healthFactor() internal pure returns (uint256);Returns
| Name | Type | Description |
|---|---|---|
| <none> | uint256 |
isPaused
function isPaused() internal view returns (bool);Returns
| Name | Type | Description |
|---|---|---|
| <none> | bool |
_twapSqrtPriceX96
The pool's time-weighted-average sqrt price (Q64.96) over twapWindow.
Reads pool.observe([twapWindow, 0]) and converts the arithmetic-mean tick to a sqrt
price. This is the only price source for NAV. observe is a view and is resistant
to single-block manipulation (it averages the tick across the window), unlike slot0,
which is never read here. Rounds the mean tick toward negative infinity to match Uniswap's
OracleLibrary.consult (matters only for negative remainders).
function _twapSqrtPriceX96(UniswapV3AdapterStorage storage $) private view returns (uint160);Parameters
| Name | Type | Description |
|---|---|---|
| $ | UniswapV3AdapterStorage |
Returns
| Name | Type | Description |
|---|---|---|
| <none> | uint160 |
_positionLiquidity
The live liquidity of the adapter's position (0 if none minted).
positions() returns a flat 12-word tuple; decoding all 12 just to keep one blows the
stack under the via-ir-disabled CI profile, so we staticcall and read only the liquidity
word (index 7 → returndata offset 224). Reverts if the call fails or returns short data.
function _positionLiquidity(UniswapV3AdapterStorage storage $) private view returns (uint128 liquidity);Parameters
| Name | Type | Description |
|---|---|---|
| $ | UniswapV3AdapterStorage |
Returns
| Name | Type | Description |
|---|---|---|
| liquidity | uint128 |
totalAssetsManaged
Total assets managed, in token0 = idle token0 + the position's token0 leg at the TWAP price. token1 is excluded, idle or in the position: the swap-free adapter can never hand it to the vault as token0 (see the library's "NAV is token0 only"). Uncollected fees are intentionally excluded too (forwarded raw on harvest, not part of principal NAV).
Manipulation-resistance: the position's token0 leg is derived from the TWAP sqrt price (see
_twapSqrtPriceX96), NEVER slot0. The TWAP averages the tick over twapWindow, so a
flash-loan spike of the spot price within one block barely moves the reported NAV — an
attacker cannot mint/redeem vault shares against a skewed price. view (observe is a
view). The adapter's state-changing ops are all nonReentrant, and VaultCore blocks
share-price-sensitive vault entries while a rebalance is in flight.
token1 (#271): keeper-funded token1 does not move NAV when it lands. A deploy turns idle token0
into the token0 leg, consumed at spot and counted at the TWAP, so it leaves NAV flat at spot == TWAP
and otherwise steps it by the gap, which deploy caps at slippageBps of the token0 consumed (see
the library's "Deploys happen at spot"). An adapter holding only token1 reports zero, so
addStrategy accepts it. The token1 is recovered only by the admin's emergencyWithdraw, which sends
it to the vault, outside NAV.
function totalAssetsManaged() internal view returns (uint256);Returns
| Name | Type | Description |
|---|---|---|
| <none> | uint256 |
_positionAmount0
The token0 that liquidity of the full-range position holds at the TWAP price.
The price source is the TWAP sqrt price (_twapSqrtPriceX96) — never slot0. Excludes
uncollected fees.
function _positionAmount0(UniswapV3AdapterStorage storage $, uint128 liquidity)
private
view
returns (uint256 amount0);Parameters
| Name | Type | Description |
|---|---|---|
| $ | UniswapV3AdapterStorage | |
| liquidity | uint128 |
Returns
| Name | Type | Description |
|---|---|---|
| amount0 | uint256 |
setTwapWindow
function setTwapWindow(uint32 twapWindow_) internal;Parameters
| Name | Type | Description |
|---|---|---|
| twapWindow_ | uint32 |
setSlippageBps
function setSlippageBps(uint256 slippageBps_) internal;Parameters
| Name | Type | Description |
|---|---|---|
| slippageBps_ | uint256 |
setRewardRecipient
function setRewardRecipient(address recipient) internal;Parameters
| Name | Type | Description |
|---|---|---|
| recipient | address |
setOperator
Sets the authorized operator for deploy/withdraw/harvest (admin-only). Rejects
address(0) so the trio cannot be opened to an unauthenticated default.
function setOperator(address operator_) internal;Parameters
| Name | Type | Description |
|---|---|---|
| operator_ | address |
deploy
Adds the adapter's held token0 + token1 to a full-range position: mint the first
time, increaseLiquidity thereafter. Swap-free — consumes whatever the keeper funded.
Slippage floors amount0Min/amount1Min are the desired amounts haircut by slippageBps
(Uniswap enforces them; a thin/imbalanced pool that would consume too little reverts). The floors
check the keeper's funding ratio, not the price, so _checkDeployAtTwap also refuses a deploy whose
new token0 leg, counted at the TWAP, differs from the token0 it consumed at spot by more than
slippageBps (#271).
"deployed" is reported in token0 units (the amount0 actually consumed) for parity with the
single-asset adapters; token1 consumption is incidental to building the position.
function deploy() internal returns (uint256 deployed);Returns
| Name | Type | Description |
|---|---|---|
| deployed | uint256 |
_mintPosition
Mints the initial full-range position from (bal0, bal1); stores the new id and returns
the token0 actually consumed. Slippage floors are the balances haircut by slippageBps.
function _mintPosition(UniswapV3AdapterStorage storage $, uint256 bal0, uint256 bal1)
private
returns (uint128, uint256);Parameters
| Name | Type | Description |
|---|---|---|
| $ | UniswapV3AdapterStorage | |
| bal0 | uint256 | |
| bal1 | uint256 |
Returns
| Name | Type | Description |
|---|---|---|
| <none> | uint128 | |
| <none> | uint256 |
_increasePosition
Adds (bal0, bal1) to the existing position; returns the liquidity added and the token0 actually
consumed.
function _increasePosition(UniswapV3AdapterStorage storage $, uint256 bal0, uint256 bal1)
private
returns (uint128, uint256);Parameters
| Name | Type | Description |
|---|---|---|
| $ | UniswapV3AdapterStorage | |
| bal0 | uint256 | |
| bal1 | uint256 |
Returns
| Name | Type | Description |
|---|---|---|
| <none> | uint128 | |
| <none> | uint256 |
_checkDeployAtTwap
Refuses a deploy that would step NAV by more than slippageBps of the token0 it consumed (#271).
The pool prices a mint or increase at spot, so it takes consumed0 token0, while NAV counts the
added liquidity's token0 leg at the TWAP. NAV therefore moves by the difference, about
consumed0 × (√(P_spot / P_twap) − 1), and anyone who can move spot inside the permissionless
rebalance() could choose it. Reverting rolls the deploy back, and the StrategyManager reports the
failure and leaves the token0 idle (and counted), so the worst case is a skipped deploy. Compares
the two amounts the deploy produced and never reads slot0. The 2 wei allowance absorbs the
pool rounding the consumed amount up and the TWAP leg rounding down, so slippageBps == 0 still
deploys at spot == TWAP.
function _checkDeployAtTwap(UniswapV3AdapterStorage storage $, uint128 added, uint256 consumed0) private view;Parameters
| Name | Type | Description |
|---|---|---|
| $ | UniswapV3AdapterStorage | |
| added | uint128 | |
| consumed0 | uint256 |
withdraw
Recalls up to amount of token0 to to from the adapter's idle token0. Shortfall-honest: the
returned value is the REAL token0 sent to to, never over-reported.
Never removes liquidity (#271): a decrease pays out at spot against a TWAP-counted token0 leg, so
with spot above the TWAP the StrategyManager would see a value loss and revert the whole
rebalance() (see the library's "Withdraw pays idle token0 only"). Asking for more than the idle
token0 is therefore an honest partial recall: the unpaid part stays in the counted token0 leg. The
position is unwound only by the admin's emergencyWithdraw (see the library's "Exit needs the
admin").
function withdraw(uint256 amount, address to) internal returns (uint256 withdrawn);Parameters
| Name | Type | Description |
|---|---|---|
| amount | uint256 | |
| to | address |
Returns
| Name | Type | Description |
|---|---|---|
| withdrawn | uint256 |
harvest
Collects accrued swap fees (token0 + token1) and forwards them RAW to the reward recipient. Fees are the yield distribution; they are NOT counted in NAV.
Collects with tokensOwed* maxima straight to the recipient. Graceful on a zero-fee position
(collect returns 0). Because principal (live liquidity) is never decreased here, a non-zero
collect can only be accrued fees — never principal — so it cannot drain the LP position. Idle
balances are never forwarded: idle token0 is NAV, and idle token1 waits for the next deploy.
function harvest() internal;emergencyWithdraw
Fully exits: removes ALL liquidity, collects everything, and sends both tokens to the vault. Admin-gated; runs even when paused/stopped (the emergency path).
No slippage floor (min == 0): an emergency prioritizes getting funds out. Both token0 and
token1 (principal + any accrued fees, indistinguishable once collected) go to the vault.
This is the only way the position is unwound and the only way token1 leaves the adapter (see
"Exit needs the admin"). The token0 replaces the token0 leg and idle token0 NAV already counted,
but the decrease pays at spot, so the vault's NAV steps by about leg0 × (√(P_twap / P_spot) − 1)
(plus any collected token0 fees), with no floor: call it while spot sits near the TWAP. The token1
was never NAV and lands in the vault outside it.
function emergencyWithdraw() internal returns (uint256 recovered);Returns
| Name | Type | Description |
|---|---|---|
| recovered | uint256 |
_decreaseAndCollect
Decreases liquidityToRemove from the position and collects the freed token0 + token1 (plus
any accrued fees) to the adapter. Used only by emergencyWithdraw.
function _decreaseAndCollect(UniswapV3AdapterStorage storage $, uint128 liquidityToRemove) private;Parameters
| Name | Type | Description |
|---|---|---|
| $ | UniswapV3AdapterStorage | |
| liquidityToRemove | uint128 |