// SPDX-License-Identifier: MIT pragma solidity 0.8.26; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; import {UniMath} from "./UniMath.sol"; interface IUniswapV3Pool { function token0() external view returns (address); function token1() external view returns (address); function fee() external view returns (uint24); function tickSpacing() external view returns (int24); function slot0() external view returns (uint160 sqrtPriceX96, int24 tick, uint16, uint16, uint16, uint8, bool); function observe(uint32[] calldata secondsAgos) external view returns (int56[] memory tickCumulatives, uint160[] memory); function positions(bytes32 key) external view returns (uint128 liquidity, uint256, uint256, uint128 tokensOwed0, uint128 tokensOwed1); function mint(address recipient, int24 tickLower, int24 tickUpper, uint128 amount, bytes calldata data) external returns (uint256, uint256); function burn(int24 tickLower, int24 tickUpper, uint128 amount) external returns (uint256, uint256); function collect(address recipient, int24 tickLower, int24 tickUpper, uint128 amount0Requested, uint128 amount1Requested) external returns (uint128, uint128); function swap(address recipient, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96, bytes calldata data) external returns (int256, int256); } /// @title WeirVault — one Uniswap v3 pool's trading fees, as an ERC-20 /// @notice The vault holds a single concentrated position in one pool, a fixed number of ticks either side of /// the price. Every trade through that range pays the pool's fee; the vault collects it and folds it /// back into the position, so each share is worth a little more of both tokens as the pool trades. /// /// Deposit USDG (or the stock, or both) and receive shares. Withdraw at any block for your slice of /// the position and of any uninvested fees, or take it all back as one token. /// /// When the price leaves the range the position stops earning. Anyone can call `tend()`, which /// re-centres the range on the price and carries on. Every zap and deposit does the same first. /// /// @dev No owner, no fee, no admin, no upgrade, no pause. The only price the vault ever acts on is the pool's, /// and it refuses to act on it (deposit, zap, re-centre, compound) unless the pool's tick is within /// MAX_DEVIATION of its own TWAP_SECONDS average. Withdrawing in kind is never refused. contract WeirVault is ERC20, ReentrancyGuardTransient { using SafeERC20 for IERC20; uint32 public constant TWAP_SECONDS = 300; int24 public constant MAX_DEVIATION = 200; // ticks, about 2% uint256 public constant DEAD_SHARES = 1e6; address internal constant DEAD = 0x000000000000000000000000000000000000dEaD; IUniswapV3Pool public immutable pool; IERC20 public immutable token0; IERC20 public immutable token1; uint24 public immutable fee; int24 public immutable tickSpacing; int24 public immutable halfWidth; int24 public tickLower; int24 public tickUpper; error NotPool(); error WrongToken(); error PriceMoved(int24 tick, int24 twap); error TooSmall(); error TooLittle(uint256 got, uint256 min); error TooMuch(); error Expired(); error NotOpen(); event Deposit(address indexed sender, address indexed to, uint256 shares, uint256 amount0, uint256 amount1); event Withdraw(address indexed owner, address indexed to, uint256 shares, uint256 amount0, uint256 amount1); event Collected(uint256 fees0, uint256 fees1); event Compounded(uint128 liquidity, uint256 amount0, uint256 amount1); event Recentred(int24 tickLower, int24 tickUpper, int24 tick, int24 twap); constructor(IUniswapV3Pool pool_, int24 halfWidth_, string memory name_, string memory symbol_) ERC20(name_, symbol_) { pool = pool_; token0 = IERC20(pool_.token0()); token1 = IERC20(pool_.token1()); fee = pool_.fee(); tickSpacing = pool_.tickSpacing(); halfWidth = halfWidth_; } /// @notice Shares count liquidity units. Twelve decimals makes one share roughly a dollar or so of the pool /// in wallets; it changes nothing else. function decimals() public pure override returns (uint8) { return 12; } // ------------------------------------------------------------------ user actions /// @notice Pay in `amountIn` of one pool token; the vault swaps the right part of it through the pool and /// deposits both sides. Whatever it could not use comes back to the caller in the same transaction. function zapIn(IERC20 tokenIn, uint256 amountIn, uint256 minShares, address to, uint256 deadline) external nonReentrant returns (uint256 shares) { _live(deadline); bool in0 = tokenIn == token0; if (!in0 && tokenIn != token1) revert WrongToken(); if (totalSupply() > 0) _tendIfOut(); (uint160 sp, int24 tick, int24 twap) = _checkedPrice(); uint256 before0 = token0.balanceOf(address(this)); uint256 before1 = token1.balanceOf(address(this)); tokenIn.safeTransferFrom(msg.sender, address(this), amountIn); (uint256 want0, uint256 want1) = _mix(sp, tick); (bool zeroForOne, uint256 swapIn) = _swapAmount( token0.balanceOf(address(this)) - before0, token1.balanceOf(address(this)) - before1, want0, want1, sp ); _swap(zeroForOne, swapIn, twap); uint256 got0 = token0.balanceOf(address(this)) - before0; uint256 got1 = token1.balanceOf(address(this)) - before1; uint256 used0; uint256 used1; (shares, used0, used1) = _deposit(address(this), got0, got1, to, got0, got1); if (shares < minShares) revert TooLittle(shares, minShares); if (got0 > used0) token0.safeTransfer(msg.sender, got0 - used0); if (got1 > used1) token1.safeTransfer(msg.sender, got1 - used1); } /// @notice Burn `shares` and receive everything they are worth as one pool token. function zapOut(uint256 shares, IERC20 tokenOut, uint256 minOut, address to, uint256 deadline) external nonReentrant returns (uint256 out) { _live(deadline); bool out0 = tokenOut == token0; if (!out0 && tokenOut != token1) revert WrongToken(); (, , int24 twap) = _checkedPrice(); (uint256 a0, uint256 a1) = _withdraw(msg.sender, shares, address(this)); // The leaver's tokens now sit in the vault on top of what the remaining holders own. uint256 keep0 = token0.balanceOf(address(this)) - a0; uint256 keep1 = token1.balanceOf(address(this)) - a1; _swap(!out0, out0 ? a1 : a0, twap); uint256 give0 = token0.balanceOf(address(this)) - keep0; uint256 give1 = token1.balanceOf(address(this)) - keep1; out = out0 ? give0 : give1; if (out < minOut) revert TooLittle(out, minOut); if (give0 > 0) token0.safeTransfer(to, give0); if (give1 > 0) token1.safeTransfer(to, give1); } /// @notice Deposit both tokens in the vault's current proportion. At most `max0`/`max1` is taken. function deposit(uint256 max0, uint256 max1, uint256 minShares, address to, uint256 deadline) external nonReentrant returns (uint256 shares, uint256 used0, uint256 used1) { _live(deadline); _checkedPrice(); (shares, used0, used1) = _deposit(msg.sender, max0, max1, to, 0, 0); if (shares < minShares) revert TooLittle(shares, minShares); } /// @notice Burn `shares` for their slice of the position and of the uninvested balance, in kind. /// Never refused on price: this is the way out that always works. function withdraw(uint256 shares, uint256 min0, uint256 min1, address to, uint256 deadline) external nonReentrant returns (uint256 amount0, uint256 amount1) { _live(deadline); (amount0, amount1) = _withdraw(msg.sender, shares, to); if (amount0 < min0) revert TooLittle(amount0, min0); if (amount1 < min1) revert TooLittle(amount1, min1); } /// @notice Collect the fees, and either put them back to work in the range or, if the price has left the /// range, re-centre the range on the price. Anyone may call it; it has no reward and needs none. function tend() external nonReentrant returns (bool recentred) { if (totalSupply() == 0) revert NotOpen(); (uint160 sp, int24 tick, int24 twap) = _checkedPrice(); _collect(); if (tick < tickLower || tick >= tickUpper) { _recentre(tick, twap); return true; } _compound(sp); } // ------------------------------------------------------------------ reading /// @notice Everything the vault owns, fees included, at the pool's current price — and the share supply. /// Collects the fees to count them, so call it with eth_call; sending it is harmless. function totalsNow() external nonReentrant returns (uint256 total0, uint256 total1, uint256 supply, uint160 sqrtPriceX96) { if (totalSupply() > 0) _collect(); (sqrtPriceX96, , , , , , ) = pool.slot0(); (total0, total1, ) = _totals(sqrtPriceX96, 0, 0); supply = totalSupply(); } function liquidity() public view returns (uint128 l) { (l, , , , ) = pool.positions(_key()); } // ------------------------------------------------------------------ pool callbacks function uniswapV3MintCallback(uint256 owed0, uint256 owed1, bytes calldata data) external { if (msg.sender != address(pool)) revert NotPool(); address payer = abi.decode(data, (address)); if (owed0 > 0) _pay(token0, payer, owed0); if (owed1 > 0) _pay(token1, payer, owed1); } function uniswapV3SwapCallback(int256 delta0, int256 delta1, bytes calldata) external { if (msg.sender != address(pool)) revert NotPool(); if (delta0 > 0) token0.safeTransfer(msg.sender, uint256(delta0)); if (delta1 > 0) token1.safeTransfer(msg.sender, uint256(delta1)); } // ------------------------------------------------------------------ internals function _live(uint256 deadline) internal view { if (block.timestamp > deadline) revert Expired(); } function _key() internal view returns (bytes32) { return keccak256(abi.encodePacked(address(this), tickLower, tickUpper)); } function _bounds() internal view returns (uint160 sa, uint160 sb) { sa = UniMath.sqrtRatioAtTick(tickLower); sb = UniMath.sqrtRatioAtTick(tickUpper); } function _twap() internal view returns (int24 t) { uint32[] memory ago = new uint32[](2); ago[0] = TWAP_SECONDS; (int56[] memory cum, ) = pool.observe(ago); int56 d = cum[1] - cum[0]; t = int24(d / int56(uint56(TWAP_SECONDS))); if (d < 0 && d % int56(uint56(TWAP_SECONDS)) != 0) t--; } /// @dev The pool's price, refused if it is further than MAX_DEVIATION from the pool's own recent average. function _checkedPrice() internal view returns (uint160 sp, int24 tick, int24 twap) { (sp, tick, , , , , ) = pool.slot0(); twap = _twap(); if (tick > twap + MAX_DEVIATION || tick < twap - MAX_DEVIATION) revert PriceMoved(tick, twap); } /// @dev A range of halfWidth ticks either side of the spacing boundary at or below `tick`. function _rangeAround(int24 tick) internal view returns (int24 lo, int24 hi) { int24 s = tickSpacing; int24 c = tick / s; if (tick < 0 && tick % s != 0) c--; c *= s; int24 minT = (UniMath.MIN_TICK / s) * s; int24 maxT = (UniMath.MAX_TICK / s) * s; lo = c - halfWidth < minT ? minT : c - halfWidth; hi = c + halfWidth > maxT ? maxT : c + halfWidth; } /// @dev The proportion (token0 : token1) a deposit must come in: the vault's own holdings, or for the first /// deposit, what a position in the range it is about to open holds at this price. function _mix(uint160 sp, int24 tick) internal view returns (uint256 want0, uint256 want1) { if (totalSupply() > 0) { (want0, want1, ) = _totals(sp, 0, 0); } else { (int24 lo, int24 hi) = _rangeAround(tick); (want0, want1) = UniMath.amountsForLiquidity(sp, UniMath.sqrtRatioAtTick(lo), UniMath.sqrtRatioAtTick(hi), 1e27); } } /// @dev Position (at `sp`, fees not yet collected excluded) plus the idle balance, minus `pending` tokens /// that sit in the vault but belong to a zap in progress. function _totals(uint160 sp, uint256 pending0, uint256 pending1) internal view returns (uint256 t0, uint256 t1, uint128 liq) { liq = liquidity(); if (liq > 0) { (uint160 sa, uint160 sb) = _bounds(); (t0, t1) = UniMath.amountsForLiquidity(sp, sa, sb, liq); } t0 += token0.balanceOf(address(this)) - pending0; t1 += token1.balanceOf(address(this)) - pending1; } /// @dev How much of `have` to swap, and which way, to end up in the proportion want0 : want1. function _swapAmount(uint256 have0, uint256 have1, uint256 want0, uint256 want1, uint160 sp) internal view returns (bool zeroForOne, uint256 amountIn) { if (want0 == 0 && want1 == 0) return (false, 0); uint256 keep = 1e6 - fee; if (have0 * want1 > have1 * want0) { // too much token0: sell s of it. (have0 - s) / (have1 + s·p·(1-f)) = want0 / want1 uint256 want0In1 = Math.mulDiv(Math.mulDiv(want0, sp, UniMath.Q96), sp, UniMath.Q96) * keep / 1e6; return (true, (have0 * want1 - have1 * want0) / (want1 + want0In1)); } uint256 want1In0 = Math.mulDiv(Math.mulDiv(want1, UniMath.Q96, sp), UniMath.Q96, sp) * keep / 1e6; return (false, (have1 * want0 - have0 * want1) / (want0 + want1In0)); } /// @dev Swap through the pool, never past MAX_DEVIATION (+1) from the TWAP. A swap that reaches the limit /// stops there; what it did not sell stays where it is. function _swap(bool zeroForOne, uint256 amountIn, int24 twap) internal { if (amountIn == 0) return; int24 edge = zeroForOne ? twap - MAX_DEVIATION - 1 : twap + MAX_DEVIATION + 1; if (edge < UniMath.MIN_TICK) edge = UniMath.MIN_TICK; if (edge > UniMath.MAX_TICK) edge = UniMath.MAX_TICK; uint160 limit = UniMath.sqrtRatioAtTick(edge); if (limit <= UniMath.MIN_SQRT_RATIO) limit = UniMath.MIN_SQRT_RATIO + 1; if (limit >= UniMath.MAX_SQRT_RATIO) limit = UniMath.MAX_SQRT_RATIO - 1; pool.swap(address(this), zeroForOne, int256(amountIn), limit, ""); } function _pay(IERC20 token, address payer, uint256 amount) internal { if (payer == address(this)) token.safeTransfer(address(pool), amount); else token.safeTransferFrom(payer, address(pool), amount); } function _mintLiquidity(uint128 l, address payer) internal returns (uint256 paid0, uint256 paid1) { (paid0, paid1) = pool.mint(address(this), tickLower, tickUpper, l, abi.encode(payer)); } function _collect() internal { if (liquidity() > 0) pool.burn(tickLower, tickUpper, 0); (uint128 f0, uint128 f1) = pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max); if (f0 > 0 || f1 > 0) emit Collected(f0, f1); } function _tendIfOut() internal { (, int24 tick, , , , , ) = pool.slot0(); if (tick >= tickLower && tick < tickUpper) return; (, , int24 twap) = _checkedPrice(); _collect(); _recentre(tick, twap); } function _compound(uint160 sp) internal { (uint160 sa, uint160 sb) = _bounds(); uint256 b0 = token0.balanceOf(address(this)); uint256 b1 = token1.balanceOf(address(this)); uint128 l = UniMath.liquidityForAmounts(sp, sa, sb, b0, b1); if (l == 0) return; (uint256 p0, uint256 p1) = _mintLiquidity(l, address(this)); emit Compounded(l, p0, p1); } function _recentre(int24 tick, int24 twap) internal { uint128 liq = liquidity(); if (liq > 0) { pool.burn(tickLower, tickUpper, liq); pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max); } (tickLower, tickUpper) = _rangeAround(tick); (uint160 sp, , , , , , ) = pool.slot0(); (uint160 sa, uint160 sb) = _bounds(); (uint256 u0, uint256 u1) = UniMath.amountsForLiquidity(sp, sa, sb, 1e27); (bool zeroForOne, uint256 amountIn) = _swapAmount(token0.balanceOf(address(this)), token1.balanceOf(address(this)), u0, u1, sp); _swap(zeroForOne, amountIn, twap); (sp, , , , , , ) = pool.slot0(); uint128 l = UniMath.liquidityForAmounts( sp, sa, sb, token0.balanceOf(address(this)), token1.balanceOf(address(this)) ); if (l > 0) _mintLiquidity(l, address(this)); emit Recentred(tickLower, tickUpper, tick, twap); } /// @dev `pending0/1` is 0 for a deposit paid from the caller's wallet, and the zap's own tokens (already in /// the vault) for a zap. Shares round down; what is taken rounds up. function _deposit(address payer, uint256 max0, uint256 max1, address to, uint256 pending0, uint256 pending1) internal returns (uint256 shares, uint256 used0, uint256 used1) { uint256 supply = totalSupply(); (uint160 sp, int24 tick, , , , , ) = pool.slot0(); if (supply == 0) { (tickLower, tickUpper) = _rangeAround(tick); (uint160 sa, uint160 sb) = _bounds(); uint128 l = UniMath.liquidityForAmounts(sp, sa, sb, max0 > 2 ? max0 - 2 : 0, max1 > 2 ? max1 - 2 : 0); if (l <= DEAD_SHARES * 10) revert TooSmall(); (used0, used1) = _mintLiquidity(l, payer); shares = uint256(l) - DEAD_SHARES; _mint(DEAD, DEAD_SHARES); } else { _collect(); (uint256 t0, uint256 t1, uint128 liq) = _totals(sp, pending0, pending1); shares = type(uint256).max; if (t0 > 0) shares = Math.mulDiv(max0 > 2 ? max0 - 2 : 0, supply, t0); if (t1 > 0) shares = Math.min(shares, Math.mulDiv(max1 > 2 ? max1 - 2 : 0, supply, t1)); if (shares == 0 || shares == type(uint256).max) revert TooSmall(); uint128 addL = uint128(Math.mulDiv(liq, shares, supply)); // The pool rounds what it charges for addL up and our view of the position rounds down, so that // unit of rounding is paid by the depositor (+1), never by the holders already here. uint256 need0 = Math.mulDiv(shares, t0, supply, Math.Rounding.Ceil) + (addL > 0 ? 1 : 0); uint256 need1 = Math.mulDiv(shares, t1, supply, Math.Rounding.Ceil) + (addL > 0 ? 1 : 0); if (addL > 0) (used0, used1) = _mintLiquidity(addL, payer); // The rest of the proportion is the vault's uninvested balance: it joins that balance. if (need0 > used0) { if (payer != address(this)) token0.safeTransferFrom(payer, address(this), need0 - used0); used0 = need0; } if (need1 > used1) { if (payer != address(this)) token1.safeTransferFrom(payer, address(this), need1 - used1); used1 = need1; } } if (used0 > max0 || used1 > max1) revert TooMuch(); _mint(to, shares); emit Deposit(payer == address(this) ? msg.sender : payer, to, shares, used0, used1); } function _withdraw(address owner, uint256 shares, address to) internal returns (uint256 a0, uint256 a1) { if (shares == 0) revert TooSmall(); if (totalSupply() == 0) revert NotOpen(); _collect(); uint256 supply = totalSupply(); uint256 idle0 = token0.balanceOf(address(this)); uint256 idle1 = token1.balanceOf(address(this)); uint128 burnL = uint128(Math.mulDiv(liquidity(), shares, supply)); _burn(owner, shares); if (burnL > 0) { (a0, a1) = pool.burn(tickLower, tickUpper, burnL); pool.collect(address(this), tickLower, tickUpper, uint128(a0), uint128(a1)); } a0 += Math.mulDiv(idle0, shares, supply); a1 += Math.mulDiv(idle1, shares, supply); if (to != address(this)) { if (a0 > 0) token0.safeTransfer(to, a0); if (a1 > 0) token1.safeTransfer(to, a1); } emit Withdraw(owner, to, shares, a0, a1); } }