// SPDX-License-Identifier: MIT pragma solidity ^0.8.26; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import { IERC20, IERC20Metadata, ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; import {PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; import {CurrencySettler} from "@uniswap/v4-core/test/utils/CurrencySettler.sol"; import {IPyth} from "@pythnetwork/pyth-sdk-solidity/IPyth.sol"; import {PythStructs} from "@pythnetwork/pyth-sdk-solidity/PythStructs.sol"; import {IDeltaNeutralVault, IReceiver} from "../interfaces/IDeltaNeutralVault.sol"; import {IPerpExchange} from "../interfaces/IPerpExchange.sol"; import {IMaasHook} from "../interfaces/IMaasHook.sol"; import {PerpMath} from "../libraries/PerpMath.sol"; /// @notice Delta-neutral USDC vault. NAV = idle + spot (Pyth) + perp equity. Fee on withdraw only. /// Vault calls PoolManager itself (hook fee 0) and is fee-exempt on PerpExchange. UUPS proxy. contract DeltaNeutralVault is IDeltaNeutralVault, ERC4626Upgradeable, OwnableUpgradeable, UUPSUpgradeable, IUnlockCallback { using SafeERC20 for IERC20; using SafeCast for uint256; using CurrencySettler for Currency; using PoolIdLibrary for PoolKey; uint256 internal constant N = 3; uint256 internal constant BPS = 1e4; IPerpExchange public exchange; IPoolManager public manager; IPyth public pyth; IMaasHook public hook; address public keeper; address public forwarder; address public treasury; address public workflowOwner; Config internal _cfg; PoolKey[N] internal _keys; address[N] public base; uint256[N] internal _unit; // 10**base decimals bytes32[N] internal _feed; Mode[N] public mode; uint256[N] public lastModeChange; mapping(uint256 => WithdrawRequest) public requests; uint256 public nextRequestId; uint256 public totalQueuedShares; uint256 public lpAccrued; // withdraw-fee share owed to LPs; not depositor NAV uint256[N] public epochVolume; // USDC notional of vault swaps since last LP epoch uint64 public lastLpEpoch; struct Roles { address owner; address keeper; address forwarder; address treasury; address workflowOwner; } modifier onlyKeeper() { if (msg.sender != keeper) revert NotKeeper(); _; } /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } /// @dev keys[i] pairs USDC with market i's base; Pyth feed read from exchange market i. function initialize( IERC20 usdc, IPerpExchange exchange_, IPoolManager manager_, IPyth pyth_, PoolKey[N] memory keys, Roles memory r, Config memory cfg, IMaasHook hook_ ) external initializer { __ERC20_init("PerpVault Delta Neutral USDC", "pvUSDC"); __ERC4626_init(usdc); __Ownable_init(r.owner); if (address(exchange_) == address(0) || address(manager_) == address(0) || address(pyth_) == address(0)) { revert ZeroAddress(); } (exchange, manager, pyth) = (exchange_, manager_, pyth_); _setMarkets(address(usdc), keys); _setKeeper(r.keeper); _setForwarder(r.forwarder); _setTreasury(r.treasury); _setWorkflowOwner(r.workflowOwner); _setConfig(cfg); _setHook(hook_); nextRequestId = 1; } function _setMarkets(address usdc, PoolKey[N] memory keys) internal { for (uint256 i; i < N; i++) { (address c0, address c1) = (Currency.unwrap(keys[i].currency0), Currency.unwrap(keys[i].currency1)); if (c0 != usdc && c1 != usdc) revert BadMarket(); (bytes32 feed,,,,,,,) = exchange.markets(i); if (feed == 0) revert BadMarket(); _keys[i] = keys[i]; base[i] = c0 == usdc ? c1 : c0; _unit[i] = 10 ** IERC20Metadata(base[i]).decimals(); _feed[i] = feed; } } function _authorizeUpgrade(address) internal override onlyOwner {} receive() external payable {} // Pyth fee refunds from exchange /// @dev 1e6 virtual shares per asset wei: donation/inflation attack unprofitable. function _decimalsOffset() internal pure override returns (uint8) { return 6; } // ---- NAV ---- function totalAssets() public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { uint256 idle = IERC20(asset()).balanceOf(address(this)) - lpAccrued; int256 nav = (idle + spotValue()).toInt256() + perpEquity(); return nav > 0 ? uint256(nav) : 0; } function spotValue() public view returns (uint256 v) { for (uint256 i; i < N; i++) { uint256 bal = IERC20(base[i]).balanceOf(address(this)); if (bal != 0) v += Math.mulDiv(bal, price(i), _unit[i]); } } function perpEquity() public view returns (int256 eq) { eq = exchange.collateral(address(this)); for (uint256 i; i < N; i++) { (int256 size, uint256 entry, int256 lastIdx) = exchange.positions(address(this), i); if (size == 0) continue; eq += PerpMath.upnl(size, entry, price(i)) - PerpMath.funding(size, exchange.fundingIndex(i) - lastIdx); } } function price(uint256 market) public view returns (uint256) { PythStructs.Price memory p = pyth.getPriceNoOlderThan(_feed[market], _cfg.maxPriceAge); return PerpMath.toPrice6(p.price, p.expo); } function spotSize(uint256 market) public view returns (uint256) { return IERC20(base[market]).balanceOf(address(this)) * PerpMath.SIZE_UNIT / _unit[market]; } // ---- withdraw: instant (capped at unreserved idle) + queue ---- /// @dev Idle USDC not reserved for queued shares or LPs. function _available() internal view returns (uint256) { uint256 idle = IERC20(asset()).balanceOf(address(this)) - lpAccrued; uint256 reserved = _convertToAssets(totalQueuedShares, Math.Rounding.Ceil); return idle > reserved ? idle - reserved : 0; } function maxWithdraw(address owner) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { uint256 a = _available(); return Math.min(previewRedeem(balanceOf(owner)), a - _feeOnTotal(a)); } function maxRedeem(address owner) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { (uint256 bal, uint256 a) = (balanceOf(owner), _available()); return _convertToAssets(bal, Math.Rounding.Floor) <= a ? bal : _convertToShares(a, Math.Rounding.Floor); } function previewWithdraw(uint256 assets) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { return super.previewWithdraw(assets + _feeOnRaw(assets)); } function previewRedeem(uint256 shares) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { uint256 assets = super.previewRedeem(shares); return assets - _feeOnTotal(assets); } function requestRedeem(uint256 shares, address receiver) external returns (uint256 id, uint256 assets) { if (shares == 0 || shares > balanceOf(msg.sender)) revert BadAmount(); if (receiver == address(0)) revert ZeroAddress(); uint256 instant = maxRedeem(msg.sender); if (instant > shares) instant = shares; if (instant != 0) { assets = previewRedeem(instant); _withdraw(msg.sender, receiver, msg.sender, assets, instant); } uint256 rest = shares - instant; if (rest == 0) return (0, assets); _transfer(msg.sender, address(this), rest); id = nextRequestId++; requests[id] = WithdrawRequest(msg.sender, receiver, rest); totalQueuedShares += rest; emit WithdrawQueued(id, msg.sender, receiver, rest); } function cancelRequest(uint256 id) external { WithdrawRequest memory r = requests[id]; if (r.shares == 0) revert UnknownRequest(); if (msg.sender != r.owner) revert NotRequestOwner(); delete requests[id]; totalQueuedShares -= r.shares; _transfer(address(this), r.owner, r.shares); emit WithdrawCancelled(id, r.owner, r.shares); } function fulfill(uint256 id) external onlyKeeper returns (uint256 assets) { WithdrawRequest memory r = requests[id]; if (r.shares == 0) revert UnknownRequest(); assets = previewRedeem(r.shares); delete requests[id]; totalQueuedShares -= r.shares; _withdraw(address(this), r.receiver, address(this), assets, r.shares); emit WithdrawFulfilled(id, r.owner, r.receiver, r.shares, assets); } /// @dev Both withdraw paths: fee on top of `assets`; lpShareBps of it kept for LPs, rest to treasury. function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) internal override { uint256 fee = _feeOnRaw(assets); uint256 lp = fee * _cfg.lpShareBps / BPS; super._withdraw(caller, receiver, owner, assets, shares); if (fee != lp) IERC20(asset()).safeTransfer(treasury, fee - lp); lpAccrued += lp; if (IERC20(asset()).balanceOf(address(this)) < lpAccrued) revert Reserved(); // never pay out the LP pot } function _feeOnRaw(uint256 assets) internal view returns (uint256) { return Math.mulDiv(assets, _cfg.withdrawFeeBps, BPS, Math.Rounding.Ceil); } function _feeOnTotal(uint256 assets) internal view returns (uint256) { return Math.mulDiv(assets, _cfg.withdrawFeeBps, _cfg.withdrawFeeBps + BPS, Math.Rounding.Ceil); } // ---- spot leg ---- /// @dev Buy (USDC in) only when HEDGED; sell must keep spot ≥ |short|. Out ≥ max(minOut, oracle − slippage). function swap(uint256 market, bool zeroForOne, uint256 amountIn, uint256 minOut) external onlyKeeper returns (uint256 amountOut) { if (market >= N) revert BadMarket(); if (amountIn == 0) revert BadAmount(); PoolKey memory key = _keys[market]; bool buy = Currency.unwrap(zeroForOne ? key.currency0 : key.currency1) == asset(); if (buy && mode[market] != Mode.HEDGED) revert NotHedged(); if (buy && amountIn > _available()) revert Reserved(); uint256 spent; (spent, amountOut) = abi.decode(manager.unlock(abi.encode(market, zeroForOne, amountIn)), (uint256, uint256)); uint256 p = price(market); uint256 fair = buy ? Math.mulDiv(spent, _unit[market], p) : Math.mulDiv(spent, p, _unit[market]); if (amountOut < Math.max(minOut, fair * (BPS - _cfg.slippageBps) / BPS)) revert Slippage(); if (!buy) { (int256 size,,) = exchange.positions(address(this), market); if (size < 0 && PerpMath.abs(size) > spotSize(market)) revert OverShort(); } epochVolume[market] += buy ? spent : amountOut; emit Swapped(market, zeroForOne, spent, amountOut); } function unlockCallback(bytes calldata data) external returns (bytes memory) { if (msg.sender != address(manager)) revert NotManager(); (uint256 market, bool zeroForOne, uint256 amountIn) = abi.decode(data, (uint256, bool, uint256)); PoolKey memory key = _keys[market]; BalanceDelta d = manager.swap( key, SwapParams({ zeroForOne: zeroForOne, amountSpecified: -amountIn.toInt256(), sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 }), "" ); (Currency cIn, Currency cOut) = zeroForOne ? (key.currency0, key.currency1) : (key.currency1, key.currency0); (int128 dIn, int128 dOut) = zeroForOne ? (d.amount0(), d.amount1()) : (d.amount1(), d.amount0()); uint256 spent = uint256(uint128(-dIn)); uint256 out = uint256(uint128(dOut)); cIn.settle(manager, address(this), spent, false); cOut.take(manager, address(this), out, false); return abi.encode(spent, out); } // ---- perp leg ---- function depositMargin(uint256 amount) external onlyKeeper { if (amount > _available()) revert Reserved(); IERC20(asset()).forceApprove(address(exchange), amount); exchange.deposit(amount); } /// @dev Pyth refund lands in receive(); whole ETH balance forwarded to keeper. function withdrawMargin(uint256 amount, bytes[] calldata priceUpdate) external payable onlyKeeper { exchange.withdraw{value: msg.value}(amount, priceUpdate); if (address(this).balance != 0) Address.sendValue(payable(msg.sender), address(this).balance); } function cancelOrder(IPerpExchange.Order calldata order) external onlyKeeper { exchange.cancel(order); } /// @notice EIP-1271: sig = abi.encode(Order, keeperSig over h). Guards: trader = vault, market on, size ≤ cap, /// price in oracle band, never net long; short grows only when HEDGED and ≤ spot held. function isValidSignature(bytes32 h, bytes calldata sig) external view returns (bytes4) { (IPerpExchange.Order memory o, bytes memory ks) = abi.decode(sig, (IPerpExchange.Order, bytes)); return _guard(h, o, ks) ? IERC1271.isValidSignature.selector : bytes4(0xffffffff); } function _guard(bytes32 h, IPerpExchange.Order memory o, bytes memory ks) internal view returns (bool) { if (o.trader != address(this) || o.marketId >= N || exchange.orderHash(o) != h) return false; (address signer, ECDSA.RecoverError err,) = ECDSA.tryRecover(h, ks); if (err != ECDSA.RecoverError.NoError || signer != keeper) return false; uint256 m = o.marketId; uint256 rem = o.size - exchange.filled(h); // unfilled incl. current fill (exchange books after 1271) if (_cfg.weightsBps[m] == 0 || rem > _cfg.sizeCap[m]) return false; uint256 p = price(m); uint256 diff = o.price > p ? o.price - p : p - o.price; if (diff * BPS > p * _cfg.priceBandBps) return false; (int256 size,,) = exchange.positions(address(this), m); int256 next = o.isBuy ? size + rem.toInt256() : size - rem.toInt256(); if (next > 0) return false; if (o.isBuy) return true; return mode[m] == Mode.HEDGED && PerpMath.abs(next) <= spotSize(m); } // ---- CRE ---- /// @dev report = abi.encode(uint8 kind, bytes payload); kind 0 = modes, 1 = LP epoch. function onReport(bytes calldata metadata, bytes calldata report) external { if (msg.sender != forwarder) revert NotForwarder(); // forwarder is shared by all workflows: owner at metadata[42:62], see Chainlink ReceiverTemplate address wo = workflowOwner; if (wo == address(0) || metadata.length < 62 || address(bytes20(metadata[42:62])) != wo) revert BadWorkflow(); (uint8 kind, bytes memory payload) = abi.decode(report, (uint8, bytes)); if (kind == 0) _setModes(payload); else if (kind == 1) _lpEpoch(payload); else revert BadReport(); } /// @dev payload = abi.encode(uint8[3] modes, bytes32 reasonRef). Same mode or in cooldown -> skip market. function _setModes(bytes memory payload) internal { (uint8[N] memory modes, bytes32 reasonRef) = abi.decode(payload, (uint8[3], bytes32)); for (uint256 i; i < N; i++) { if (modes[i] > uint8(Mode.HEDGED)) revert BadMode(); Mode md = Mode(modes[i]); uint256 last = lastModeChange[i]; // 0 = never changed, no cooldown if (md == mode[i] || (last != 0 && block.timestamp < last + _cfg.cooldown)) continue; (mode[i], lastModeChange[i]) = (md, block.timestamp); emit ModeChanged(i, md, reasonRef); } } /// @dev payload = abi.encode(uint64 epoch, uint256[3] shares, uint256 fromFunding, bytes32 ref). /// Pays lpAccrued + fromFunding (capped vs swap volume) to hook streams per pool. function _lpEpoch(bytes memory payload) internal { (uint64 epoch, uint256[N] memory shares, uint256 fromFunding, bytes32 ref) = abi.decode(payload, (uint64, uint256[3], uint256, bytes32)); if (epoch <= lastLpEpoch) revert BadEpoch(); uint256 vol; uint256 sum; for (uint256 i; i < N; i++) { vol += epochVolume[i]; sum += shares[i]; } if (fromFunding > vol * _cfg.refFeeBps * _cfg.capMultBps / (BPS * BPS)) revert OverCap(); if (sum != lpAccrued + fromFunding) revert BadShares(); if (fromFunding > _available()) revert Reserved(); IMaasHook h = hook; Currency usdc = Currency.wrap(asset()); for (uint256 i; i < N; i++) { if (shares[i] == 0) continue; IERC20(asset()).forceApprove(address(h), shares[i]); h.notifyReward(_keys[i].toId(), usdc, shares[i]); } lpAccrued = 0; delete epochVolume; lastLpEpoch = epoch; emit LpEpoch(epoch, shares, fromFunding, ref); } function supportsInterface(bytes4 id) external pure returns (bool) { return id == type(IReceiver).interfaceId || id == type(IERC165).interfaceId; } // ---- admin ---- function config() external view returns (Config memory) { return _cfg; } function setConfig(Config calldata cfg) external onlyOwner { _setConfig(cfg); } function setKeeper(address k) external onlyOwner { _setKeeper(k); } function setForwarder(address f) external onlyOwner { _setForwarder(f); } function setTreasury(address t) external onlyOwner { _setTreasury(t); } /// @dev 0 allowed: disables reports. function setWorkflowOwner(address w) external onlyOwner { _setWorkflowOwner(w); } function setHook(IMaasHook h) external onlyOwner { _setHook(h); } function _setHook(IMaasHook h) internal { if (address(h) == address(0)) revert ZeroAddress(); hook = h; emit HookSet(address(h)); } function _setWorkflowOwner(address w) internal { workflowOwner = w; emit WorkflowOwnerSet(w); } function _setConfig(Config memory c) internal { uint256 sum; for (uint256 i; i < N; i++) { sum += c.weightsBps[i]; } if ( sum != BPS || c.shortLeverageBps < BPS || c.shortLeverageBps > 5 * BPS || c.idleBufferBps > 5000 || c.withdrawFeeBps > 100 || c.priceBandBps == 0 || c.priceBandBps > 1000 || c.slippageBps > 1000 || c.maxPriceAge == 0 || c.lpShareBps > BPS ) revert BadConfig(); _cfg = c; emit ConfigSet(c); } function _setKeeper(address k) internal { if (k == address(0)) revert ZeroAddress(); keeper = k; emit KeeperSet(k); } function _setForwarder(address f) internal { if (f == address(0)) revert ZeroAddress(); forwarder = f; emit ForwarderSet(f); } function _setTreasury(address t) internal { if (t == address(0)) revert ZeroAddress(); treasury = t; emit TreasurySet(t); } }