From eff4983a44e42c0f673443e43d52b2279f235724 Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Mon, 6 Jul 2026 23:22:15 +0530 Subject: [PATCH] Rebalancer refinements: cap trigger, buffer-aware target, retire spike Wire the methodology cap trigger as a distinct, permissionless emergency open. IMethodology.capTriggerBps() exposes the actual-weight ceiling above the cap target; the rebalancer promotes openEpoch to a permissionless emergency when any held, freshly-priced name breaches it, so the Nasdaq-style hysteresis (cap to target, trigger higher) is enforced off-cycle rather than left to the drift band. Methodologies with no cap report zero and the path is inert. Make the rebalance target buffer-aware. openEpoch now holds back the vault's operating buffer (bufferTargetBps) on top of the redemption reserve, so it tops the sync USDC lane toward target instead of deploying the whole basket and draining it. The hold-back pulls the buffer toward target from either side. Retire the CoWOrderHandler spike. Its mainnet-fork digest proof is ported to RebalancerFork.t.sol, which builds the real stack and confirms the rebalancer's domain separator, relayer, and EIP-712 digest match the live GPv2Settlement byte-for-byte. Also fold in leftover doc-only natspec touch-ups in ISupplyOracle and WeightMath. 162 tests passing. --- src/interfaces/IMethodology.sol | 7 + src/interfaces/ISupplyOracle.sol | 9 +- src/libraries/WeightMath.sol | 4 + src/methodology/MarketCapMethodology.sol | 9 + src/rebalancer/CoWOrderHandler.sol | 186 ----------------- src/rebalancer/Rebalancer.sol | 50 ++++- test/CoWOrderHandler.t.sol | 197 ------------------ test/Rebalancer.t.sol | 50 +++-- ...HandlerFork.t.sol => RebalancerFork.t.sol} | 50 +++-- test/RebalancerQuarantine.t.sol | 6 +- test/RebalancerSettle.t.sol | 38 ++-- test/RebalancerTrigger.t.sol | 46 ++++ 12 files changed, 206 insertions(+), 446 deletions(-) delete mode 100644 src/rebalancer/CoWOrderHandler.sol delete mode 100644 test/CoWOrderHandler.t.sol rename test/{CoWOrderHandlerFork.t.sol => RebalancerFork.t.sol} (55%) diff --git a/src/interfaces/IMethodology.sol b/src/interfaces/IMethodology.sol index 263ee0d..3f39b8c 100644 --- a/src/interfaces/IMethodology.sol +++ b/src/interfaces/IMethodology.sol @@ -14,4 +14,11 @@ interface IMethodology { /// (pruned by the minimum-weight floor). MUST revert on bad inputs /// (stale price, stale supply) rather than return a degraded weighting. function getWeights(address[] calldata tokens) external view returns (uint256[] memory weights); + + /// @notice Actual-weight threshold in bps above which a held constituent + /// forces an off-cycle rebalance, or 0 if the methodology has no cap trigger. + /// The methodology caps target weights to a lower level and exposes this + /// higher threshold so the rebalancer can treat a breach as an emergency, + /// which is the Nasdaq-100 hysteresis: cap to the target, trigger higher. + function capTriggerBps() external view returns (uint256); } diff --git a/src/interfaces/ISupplyOracle.sol b/src/interfaces/ISupplyOracle.sol index 94357e5..cad8e71 100644 --- a/src/interfaces/ISupplyOracle.sol +++ b/src/interfaces/ISupplyOracle.sol @@ -5,12 +5,11 @@ pragma solidity 0.8.28; * @title ISupplyOracle * @notice Source of float-adjusted circulating supply per constituent. The * methodology engine consumes an already-float-adjusted figure and does not - * itself decide float. The implementation layers on-chain - * derivation, a multi-source median with divergence freeze, and containment - * guards behind this interface, so the methodology never needs to know how - * the number was secured. + * itself decide float. The implementation layers on-chain derivation, a + * multi-source median with divergence freeze, and containment guards behind + * this interface, so the methodology never needs to know how the number was + * secured. * @dev Supply is reported in whole-token units, never native token decimals. - * * Freeze versus revert: because supply is the slow-moving input (price, the * fast input, is Chainlink's job), a residual source that goes quiet does not * halt the index. Soft staleness and source divergence FREEZE the constituent diff --git a/src/libraries/WeightMath.sol b/src/libraries/WeightMath.sol index f4df7c1..029c002 100644 --- a/src/libraries/WeightMath.sol +++ b/src/libraries/WeightMath.sol @@ -38,6 +38,9 @@ library WeightMath { * @notice Normalizes raw scores (for example market caps) into weights * summing to at most WAD. Floor rounding keeps the result conservative; * the deficit is repaired exactly in the capping stage. + * @param raw Raw scores, for example constituent market caps. The sum may be + * zero, in which case the function reverts. + * @return weights Normalized weights summing to at most WAD. */ function normalize(uint256[] memory raw) internal pure returns (uint256[] memory weights) { uint256 n = raw.length; @@ -61,6 +64,7 @@ library WeightMath { * cap, and a fully-pinned set exits through the break. * @param weights Weights summing to approximately WAD (floor dust tolerated). * @param cap Maximum weight per constituent, in WAD. + * @return weights Cap-adjusted weights summing to exactly WAD. */ function applyCap(uint256[] memory weights, uint256 cap) internal pure returns (uint256[] memory) { uint256 n = weights.length; diff --git a/src/methodology/MarketCapMethodology.sol b/src/methodology/MarketCapMethodology.sol index 2bf0375..e26089e 100644 --- a/src/methodology/MarketCapMethodology.sol +++ b/src/methodology/MarketCapMethodology.sol @@ -111,6 +111,15 @@ contract MarketCapMethodology is IMethodology, Ownable2Step { return WeightMath.applyFloor(weights, floorWad, capTargetWad); } + /// @inheritdoc IMethodology + /// @dev The trigger is the actual-weight ceiling in bps. getWeights caps + /// targets to capTargetWad, so the trigger sits at or above the target and + /// the gap is the rebalancer's dead-band. capTriggerWad is bounded to WAD by + /// setWeightParams, so this never exceeds 10_000. + function capTriggerBps() external view returns (uint256) { + return capTriggerWad.mulDiv(10_000, WeightMath.WAD, Math.Rounding.Floor); + } + /// @notice Sets the cap target, the cap trigger, and the minimum-weight floor. /// @dev Methodology-admin lever; sits behind the methodology-admin timelock. /// The cap target must be feasible for the constituent set: getWeights reverts diff --git a/src/rebalancer/CoWOrderHandler.sol b/src/rebalancer/CoWOrderHandler.sol deleted file mode 100644 index 9371376..0000000 --- a/src/rebalancer/CoWOrderHandler.sol +++ /dev/null @@ -1,186 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.28; - -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; - -import { AssetRegistry } from "src/AssetRegistry.sol"; -import { GPv2Order } from "src/libraries/GPv2Order.sol"; - -/// @notice The slice of GPv2Settlement this handler reads. -interface IGPv2Settlement { - function domainSeparator() external view returns (bytes32); - function vaultRelayer() external view returns (address); -} - -// ============================================================================ -// Errors -// ============================================================================ - -error CoWHandler_ZeroAddress(); -error CoWHandler_DigestMismatch(bytes32 expected, bytes32 presented); -error CoWHandler_NotSellKind(); -error CoWHandler_WrongBuyToken(address buyToken); -error CoWHandler_WrongReceiver(address receiver); -error CoWHandler_NonZeroFee(); -error CoWHandler_NotPartiallyFillable(); -error CoWHandler_Expired(uint32 validTo); -error CoWHandler_SellTokenNotRegistered(address sellToken); -error CoWHandler_NonErc20Balance(); -error CoWHandler_BelowMinOut(uint256 buyAmount, uint256 minOut); -error CoWHandler_InvalidSlippage(); - -/** - * @title CoWOrderHandler (Stage 3 spike) - * @notice Proof-of-concept that the protocol can be a first-class CoW trader. - * It implements ERC-1271 `isValidSignature` so the real GPv2Settlement accepts - * orders it has not pre-signed, validating each presented order against - * on-chain state rather than a fixed instruction: the order must be a sell of a - * registered constituent into USDC, paid to the vault, with a buy amount at or - * above an oracle-anchored minimum-out. This isolates and de-risks the CoW - * integration mechanics (EIP-712 digest, the magic-value flow, the relayer - * approval, oracle-bounded execution) ahead of the full rebalancer. - * - * Scope of the spike: only the overweight-sell leg (constituent to USDC), no - * delta sizing, no epoch lifecycle, no partial-fill NAV reconciliation, and the - * handler itself holds the sell tokens and approves the relayer. In the real - * integration this logic is the vault's (or delegated by it), so the order - * owner, the token holder, and the validator are one address. - */ -contract CoWOrderHandler { - using SafeERC20 for IERC20; - using Math for uint256; - using GPv2Order for GPv2Order.Data; - - /// @dev ERC-1271 magic value: bytes4(keccak256("isValidSignature(bytes32,bytes)")). - bytes4 internal constant MAGICVALUE = 0x1626ba7e; - - uint256 internal constant BPS = 10_000; - - /// @notice Address that receives sale proceeds (the vault). - address public immutable VAULT; - - /// @notice Shared asset catalog used for oracle prices. - AssetRegistry public immutable REGISTRY; - - /// @notice Settlement asset (USDC) and its whole unit. - address public immutable USDC; - uint256 internal immutable USDC_UNIT; - - /// @notice CoW settlement and its relayer (the puller of sell tokens). - IGPv2Settlement public immutable SETTLEMENT; - address public immutable RELAYER; - - /// @notice Domain separator read from the settlement at construction, so the - /// handler reconstructs the exact digest the settlement verifies against. - bytes32 public immutable DOMAIN_SEPARATOR; - - /// @notice Maximum tolerated slippage below the oracle price, in bps. - uint256 public immutable MAX_SLIPPAGE_BPS; - - constructor(address vault, AssetRegistry registry, address usdc, address settlement, uint256 maxSlippageBps) { - if (vault == address(0) || address(registry) == address(0) || usdc == address(0) || settlement == address(0)) { - revert CoWHandler_ZeroAddress(); - } - if (maxSlippageBps >= BPS) revert CoWHandler_InvalidSlippage(); - VAULT = vault; - REGISTRY = registry; - USDC = usdc; - USDC_UNIT = 10 ** IERC20Metadata(usdc).decimals(); - SETTLEMENT = IGPv2Settlement(settlement); - RELAYER = IGPv2Settlement(settlement).vaultRelayer(); - DOMAIN_SEPARATOR = IGPv2Settlement(settlement).domainSeparator(); - MAX_SLIPPAGE_BPS = maxSlippageBps; - } - - // ======================================================================== - // ERC-1271 - // ======================================================================== - - /// @notice Validates a CoW order presented by a solver during settlement. - /// @param digest The order digest the settlement computed for the trade. - /// @param signature The ABI-encoded `GPv2Order.Data` for that trade. - /// @return The ERC-1271 magic value if the order is one the handler authorizes. - /// @dev The digest is rebound to the decoded order, so a solver cannot pair - /// a digest for order A with the encoding of a different, valid order B. - function isValidSignature(bytes32 digest, bytes calldata signature) external view returns (bytes4) { - GPv2Order.Data memory order = abi.decode(signature, (GPv2Order.Data)); - - bytes32 expected = order.hash(DOMAIN_SEPARATOR); - if (expected != digest) revert CoWHandler_DigestMismatch(expected, digest); - - _validateSellOrder(order); - return MAGICVALUE; - } - - // ======================================================================== - // Order derivation and validation - // ======================================================================== - - /// @notice Builds the canonical sell-to-USDC order the handler will accept - /// for `sellAmount` of `sellToken`, for a solver to discover and fill. This - /// is the spike's analog of a Composable CoW `getTradeableOrder`. - function buildSellOrder(address sellToken, uint256 sellAmount, uint32 validTo, bytes32 appData) - external - view - returns (GPv2Order.Data memory order) - { - order = GPv2Order.Data({ - sellToken: sellToken, - buyToken: USDC, - receiver: VAULT, - sellAmount: sellAmount, - buyAmount: minOut(sellToken, sellAmount), - validTo: validTo, - appData: appData, - feeAmount: 0, - kind: GPv2Order.KIND_SELL, - partiallyFillable: true, - sellTokenBalance: GPv2Order.BALANCE_ERC20, - buyTokenBalance: GPv2Order.BALANCE_ERC20 - }); - } - - /// @notice Oracle-anchored minimum USDC out for selling `sellAmount` of - /// `sellToken`: the oracle USD value converted to USDC, less the slippage - /// haircut. A solver cannot fill below this. - function minOut(address sellToken, uint256 sellAmount) public view returns (uint256) { - uint256 price = REGISTRY.getPriceUsd(sellToken); // 8-decimal USD - uint256 usdcPrice = REGISTRY.getUsdcPriceUsd(); // 8-decimal USD - uint8 sellDecimals = REGISTRY.getAsset(sellToken).tokenDecimals; - - // sellAmount (native) -> 8-decimal USD -> USDC native units. - uint256 usdValue = sellAmount.mulDiv(price, 10 ** sellDecimals, Math.Rounding.Floor); - uint256 usdcOut = usdValue.mulDiv(USDC_UNIT, usdcPrice, Math.Rounding.Floor); - return usdcOut.mulDiv(BPS - MAX_SLIPPAGE_BPS, BPS, Math.Rounding.Floor); - } - - /// @dev The digest of an order under this handler's domain separator. - function orderDigest(GPv2Order.Data memory order) external view returns (bytes32) { - return order.hash(DOMAIN_SEPARATOR); - } - - /// @notice Approves the relayer to pull `token` for selling. Permissionless: - /// it only enables selling, and every sale is bounded by order validation. - function approveSell(address token) external { - IERC20(token).forceApprove(RELAYER, type(uint256).max); - } - - function _validateSellOrder(GPv2Order.Data memory order) internal view { - if (order.kind != GPv2Order.KIND_SELL) revert CoWHandler_NotSellKind(); - if (order.sellTokenBalance != GPv2Order.BALANCE_ERC20 || order.buyTokenBalance != GPv2Order.BALANCE_ERC20) { - revert CoWHandler_NonErc20Balance(); - } - if (order.buyToken != USDC) revert CoWHandler_WrongBuyToken(order.buyToken); - if (order.receiver != VAULT) revert CoWHandler_WrongReceiver(order.receiver); - if (order.feeAmount != 0) revert CoWHandler_NonZeroFee(); - if (!order.partiallyFillable) revert CoWHandler_NotPartiallyFillable(); - if (order.validTo < block.timestamp) revert CoWHandler_Expired(order.validTo); - if (!REGISTRY.isRegistered(order.sellToken)) revert CoWHandler_SellTokenNotRegistered(order.sellToken); - - uint256 required = minOut(order.sellToken, order.sellAmount); - if (order.buyAmount < required) revert CoWHandler_BelowMinOut(order.buyAmount, required); - } -} diff --git a/src/rebalancer/Rebalancer.sol b/src/rebalancer/Rebalancer.sol index b95f028..ed95b6e 100644 --- a/src/rebalancer/Rebalancer.sol +++ b/src/rebalancer/Rebalancer.sol @@ -161,22 +161,31 @@ contract Rebalancer { // ======================================================================== /// @notice Opens a rebalance epoch, freezing each constituent's target USD - /// value at weight times NAV. Two triggers under one anti-churn floor: a + /// value at weight times NAV. Triggers under one anti-churn floor: a /// scheduled open (keeper, once the cadence has elapsed and drift is at least - /// the small gate) or a permissionless emergency open (drift at least the - /// large threshold). Nothing reopens within MIN_INTERVAL of the last open. + /// the small gate), a permissionless emergency open (drift at least the large + /// threshold, or a constituent past the methodology's cap trigger), or a + /// keeper-gated funding open when the buffer cannot cover pending redemptions. + /// Nothing reopens within MIN_INTERVAL of the last open. function openEpoch() external { if (epochId != 0 && block.timestamp < epochOpenedAt + MIN_INTERVAL) { revert Rebalancer_IntervalNotElapsed(epochOpenedAt + MIN_INTERVAL); } uint256 drift = maxDriftBps(); + // Two conditions promote an open to a permissionless emergency: drift at + // or above the large threshold, or a constituent past the methodology's + // cap trigger. The cap breach is a distinct signal from generic drift: + // one name has appreciated past its hard weight ceiling and must be + // brought back to the cap off-cycle, so anyone may force the epoch + // rather than waiting on the keeper's cadence. + bool emergency = drift >= D_LARGE_BPS || capTriggerBreached(); // A redemption the buffer cannot cover is a first-class trigger: the // keeper may open an epoch to free USDC from the basket regardless of // drift or cadence, because redemption liveness cannot wait for the // reweight schedule. The min-interval floor still applies. bool fundingNeeded = VAULT.redemptionFundingNeeded(); - if (drift < D_LARGE_BPS && !fundingNeeded) { + if (!emergency && !fundingNeeded) { // Not an emergency and no funding need: scheduled reweight path, // keeper plus cadence plus small gate. if (msg.sender != KEEPER) revert Rebalancer_NotKeeper(msg.sender); @@ -184,7 +193,7 @@ contract Rebalancer { revert Rebalancer_CadenceNotElapsed(epochOpenedAt + CADENCE); } if (drift < D_SMALL_BPS) revert Rebalancer_BelowDriftThreshold(drift, D_SMALL_BPS); - } else if (fundingNeeded && drift < D_LARGE_BPS) { + } else if (fundingNeeded && !emergency) { // Funding open below the emergency band is keeper-gated. if (msg.sender != KEEPER) revert Rebalancer_NotKeeper(msg.sender); } @@ -218,7 +227,17 @@ contract Rebalancer { // would fall a haircut short of the redemption it must fund. reserveUsd = reserveUsd.mulDiv(BPS, BPS - MAX_SLIPPAGE_BPS, Math.Rounding.Ceil); } - uint256 deployableNav = navUsd > reserveUsd ? navUsd - reserveUsd : 0; + // Also hold back the vault's target operating buffer, so the rebalance + // tops the sync USDC lane back toward bufferTargetBps of NAV instead of + // deploying the whole basket and draining it. The redemption reserve + // funds outflows already requested and leaves at settle; this buffer is + // standing liquidity for the sync lane that must survive the rebalance. + // If the basket is under-buffered it is sold down to reach the target; if + // over-buffered the excess idle is deployed, so the hold-back pulls the + // buffer toward target from either side. + uint256 bufferUsd = navUsd.mulDiv(VAULT.bufferTargetBps(), BPS, Math.Rounding.Ceil); + uint256 holdbackUsd = reserveUsd + bufferUsd; + uint256 deployableNav = navUsd > holdbackUsd ? navUsd - holdbackUsd : 0; for (uint256 i = 0; i < fresh.length; i++) { address token = fresh[i]; @@ -279,6 +298,25 @@ contract Rebalancer { } } + /// @notice Whether any held, freshly-priced constituent has grown past the + /// methodology's cap trigger. This is the Nasdaq-100 hysteresis made real: + /// getWeights caps targets to capTargetWad, but a name that appreciates past + /// the higher trigger must be brought back to the cap off-cycle rather than + /// waiting on the cadence, so a single runaway name cannot dominate the index + /// between scheduled reweights. Methodologies with no cap report a zero + /// trigger and this is always false. Quarantined names are skipped: they are + /// held and cannot be traded until their feed recovers. + function capTriggerBreached() public view returns (bool) { + uint256 trigBps = METHODOLOGY.capTriggerBps(); + if (trigBps == 0) return false; + (IndexVault.Holding[] memory holdings,,) = VAULT.getHoldings(); + for (uint256 i = 0; i < holdings.length; i++) { + if (VAULT.isQuarantined(holdings[i].token)) continue; + if (holdings[i].weightBps > trigBps) return true; + } + return false; + } + // ======================================================================== // Deltas // ======================================================================== diff --git a/test/CoWOrderHandler.t.sol b/test/CoWOrderHandler.t.sol deleted file mode 100644 index b8e61b4..0000000 --- a/test/CoWOrderHandler.t.sol +++ /dev/null @@ -1,197 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.28; - -import { Test, console2 } from "forge-std/Test.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -import { AssetRegistry } from "src/AssetRegistry.sol"; -import { GPv2Order } from "src/libraries/GPv2Order.sol"; -import { - CoWOrderHandler, - CoWHandler_DigestMismatch, - CoWHandler_BelowMinOut, - CoWHandler_WrongReceiver, - CoWHandler_WrongBuyToken, - CoWHandler_SellTokenNotRegistered -} from "src/rebalancer/CoWOrderHandler.sol"; -import { MockGPv2Settlement } from "test/mocks/MockGPv2Settlement.sol"; -import { MockERC20 } from "test/mocks/MockERC20.sol"; -import { MockAggregator } from "test/mocks/MockAggregator.sol"; - -/// @notice Spike test: proves the CoW integration mechanics against a faithful -/// mock settlement, with no network. The fork test proves the digest matches -/// the real GPv2Settlement domain separator. -contract CoWOrderHandlerTest is Test { - uint48 internal constant HEARTBEAT = 1 days; - uint256 internal constant SLIPPAGE_BPS = 100; // 1% - - AssetRegistry internal registry; - MockGPv2Settlement internal settlement; - CoWOrderHandler internal handler; - - MockERC20 internal usdc; - MockERC20 internal weth; - MockAggregator internal usdcFeed; - MockAggregator internal wethFeed; - - address internal vault = makeAddr("vault"); - - function setUp() public { - vm.warp(30 days); - - usdc = new MockERC20("USD Coin", "USDC", 6); - weth = new MockERC20("Wrapped Ether", "WETH", 18); - usdcFeed = new MockAggregator(8, 1e8); // $1 - wethFeed = new MockAggregator(8, 2000e8); // $2000 - - registry = new AssetRegistry(address(this)); - registry.setUsdcFeed(address(usdc), address(usdcFeed), HEARTBEAT); - registry.registerAsset(address(weth), address(wethFeed), HEARTBEAT); - - settlement = new MockGPv2Settlement(); - handler = new CoWOrderHandler(vault, registry, address(usdc), address(settlement), SLIPPAGE_BPS); - } - - function _order(uint256 sellAmount) internal view returns (GPv2Order.Data memory) { - return handler.buildSellOrder(address(weth), sellAmount, uint32(block.timestamp + 1 hours), bytes32("epoch1")); - } - - // ======================================================================== - // Order derivation and minOut - // ======================================================================== - - function test_BuildSellOrder_OracleAnchoredMinOut() public view { - GPv2Order.Data memory order = _order(1e18); // sell 1 WETH - - assertEq(order.sellToken, address(weth)); - assertEq(order.buyToken, address(usdc)); - assertEq(order.receiver, vault); - assertEq(order.kind, GPv2Order.KIND_SELL); - assertTrue(order.partiallyFillable); - assertEq(order.feeAmount, 0); - // 1 WETH = $2000, less 1% slippage = 1980 USDC (6 decimals). - assertEq(order.buyAmount, 1980e6); - assertEq(handler.minOut(address(weth), 1e18), 1980e6); - } - - // ======================================================================== - // ERC-1271 validation - // ======================================================================== - - function test_IsValidSignature_AcceptsDerivedOrder() public view { - GPv2Order.Data memory order = _order(1e18); - bytes32 digest = handler.orderDigest(order); - - bytes4 magic = handler.isValidSignature(digest, abi.encode(order)); - assertEq(magic, bytes4(0x1626ba7e), "did not return the ERC-1271 magic value"); - } - - function test_IsValidSignature_RejectsBelowMinOut() public { - GPv2Order.Data memory order = _order(1e18); - order.buyAmount = 1980e6 - 1; // one wei under the oracle-anchored minimum - bytes32 digest = handler.orderDigest(order); - - vm.expectRevert(abi.encodeWithSelector(CoWHandler_BelowMinOut.selector, order.buyAmount, 1980e6)); - handler.isValidSignature(digest, abi.encode(order)); - } - - function test_IsValidSignature_RejectsWrongReceiver() public { - GPv2Order.Data memory order = _order(1e18); - order.receiver = makeAddr("attacker"); - bytes32 digest = handler.orderDigest(order); - - vm.expectRevert(abi.encodeWithSelector(CoWHandler_WrongReceiver.selector, order.receiver)); - handler.isValidSignature(digest, abi.encode(order)); - } - - function test_IsValidSignature_RejectsWrongBuyToken() public { - GPv2Order.Data memory order = _order(1e18); - order.buyToken = address(weth); - bytes32 digest = handler.orderDigest(order); - - vm.expectRevert(abi.encodeWithSelector(CoWHandler_WrongBuyToken.selector, order.buyToken)); - handler.isValidSignature(digest, abi.encode(order)); - } - - function test_IsValidSignature_RejectsUnregisteredSellToken() public { - MockERC20 stray = new MockERC20("Stray", "STR", 18); - GPv2Order.Data memory order = _order(1e18); - order.sellToken = address(stray); - // Keep the order internally consistent so it fails on the registry check. - bytes32 digest = handler.orderDigest(order); - - vm.expectRevert(abi.encodeWithSelector(CoWHandler_SellTokenNotRegistered.selector, address(stray))); - handler.isValidSignature(digest, abi.encode(order)); - } - - /// @notice The digest is rebound to the decoded order: a solver cannot pair - /// the digest of a valid order with the encoding of a different order. - function test_IsValidSignature_RejectsDigestOrderMismatch() public { - GPv2Order.Data memory good = _order(1e18); - GPv2Order.Data memory other = _order(2e18); - bytes32 goodDigest = handler.orderDigest(good); - - // Present the good digest but encode the other order. - vm.expectRevert( - abi.encodeWithSelector(CoWHandler_DigestMismatch.selector, handler.orderDigest(other), goodDigest) - ); - handler.isValidSignature(goodDigest, abi.encode(other)); - } - - function test_IsValidSignature_Gas() public view { - GPv2Order.Data memory order = _order(1e18); - bytes32 digest = handler.orderDigest(order); - bytes memory sig = abi.encode(order); - - uint256 g = gasleft(); - handler.isValidSignature(digest, sig); - console2.log("isValidSignature gas:", g - gasleft()); - } - - // ======================================================================== - // End-to-end settlement through the mock - // ======================================================================== - - function test_Settle_FullFillPaysVault() public { - GPv2Order.Data memory order = _order(1e18); - - // Fund the trader (handler) with WETH and approve the relayer; fund the - // settlement with USDC to pay out. - weth.mint(address(handler), 1e18); - handler.approveSell(address(weth)); - usdc.mint(address(settlement), 100_000e6); - - settlement.settle(order, address(handler), 1e18); - - assertEq(weth.balanceOf(address(handler)), 0, "sell token not pulled"); - assertEq(usdc.balanceOf(vault), 1980e6, "vault not paid the buy amount"); - } - - function test_Settle_PartialFillIsProportional() public { - GPv2Order.Data memory order = _order(1e18); - - weth.mint(address(handler), 1e18); - handler.approveSell(address(weth)); - usdc.mint(address(settlement), 100_000e6); - - // Fill half the order. - settlement.settle(order, address(handler), 0.5e18); - - assertEq(weth.balanceOf(address(handler)), 0.5e18, "wrong sell remainder"); - assertEq(usdc.balanceOf(vault), 990e6, "partial fill not proportional"); - } - - function test_Settle_RejectsTamperedOrderAtSettlement() public { - GPv2Order.Data memory order = _order(1e18); - order.buyAmount = 1; // far below minOut - - weth.mint(address(handler), 1e18); - handler.approveSell(address(weth)); - usdc.mint(address(settlement), 100_000e6); - - // The settlement computes the digest of this tampered order and calls - // isValidSignature, which rejects it, so settlement reverts. - vm.expectRevert(); - settlement.settle(order, address(handler), 1e18); - } -} diff --git a/test/Rebalancer.t.sol b/test/Rebalancer.t.sol index 844a7e4..d9f5dd7 100644 --- a/test/Rebalancer.t.sol +++ b/test/Rebalancer.t.sol @@ -105,26 +105,44 @@ contract RebalancerTest is Test { // Epoch snapshot and deltas // ======================================================================== - function test_OpenEpoch_SnapshotsTargetsAtWeightTimesNav() public { + function test_OpenEpoch_SnapshotsTargetsAtWeightTimesDeployableNav() public { _openEpoch(); assertEq(rebalancer.epochId(), 1); assertEq(rebalancer.epochNavUsd(), 300_000e8); - // 50/50 of $300k = $150k each (8-decimal USD). - assertEq(rebalancer.targetUsd(address(wbtc)), 150_000e8); - assertEq(rebalancer.targetUsd(address(weth)), 150_000e8); + // The rebalancer holds back the 5% operating buffer ($15k), so it weights + // over the $285k deployable NAV: 50/50 of $285k = $142.5k each. + assertEq(rebalancer.targetUsd(address(wbtc)), 142_500e8); + assertEq(rebalancer.targetUsd(address(weth)), 142_500e8); } function test_Deltas_OverAndUnderweight() public { _openEpoch(); - // WBTC is $200k vs $150k target = $50k overweight. - assertEq(rebalancer.overweightUsd(address(wbtc)), 50_000e8); + // Targets are $142.5k each (50/50 of the $285k deployable NAV). + // WBTC is $200k vs $142.5k target = $57.5k overweight. + assertEq(rebalancer.overweightUsd(address(wbtc)), 57_500e8); assertEq(rebalancer.underweightUsd(address(wbtc)), 0); - // WETH is $100k vs $150k target = $50k underweight. - assertEq(rebalancer.underweightUsd(address(weth)), 50_000e8); + // WETH is $100k vs $142.5k target = $42.5k underweight. + assertEq(rebalancer.underweightUsd(address(weth)), 42_500e8); assertEq(rebalancer.overweightUsd(address(weth)), 0); } + function test_OpenEpoch_HoldsBackOperatingBuffer() public { + // Targets sum to the deployable NAV, which is NAV less the buffer, so the + // rebalance leaves the sync USDC lane funded rather than draining it. + _openEpoch(); + uint256 nav = rebalancer.epochNavUsd(); + uint256 deployed = rebalancer.targetUsd(address(wbtc)) + rebalancer.targetUsd(address(weth)); + assertEq(nav - deployed, nav * vault.bufferTargetBps() / 10_000, "held-back buffer wrong"); + + // A tighter buffer band deploys more of NAV into the basket on reopen. + vault.setBufferBand(100, 200, 800); // 2% target + vm.warp(block.timestamp + MIN_INTERVAL); + _openEpoch(); + uint256 deployedTighter = rebalancer.targetUsd(address(wbtc)) + rebalancer.targetUsd(address(weth)); + assertGt(deployedTighter, deployed, "tighter buffer must deploy more"); + } + function test_OpenEpoch_EmergencyIsPermissionless() public { // The off-target basket drifts far past the large threshold, so an // emergency open is permissionless: a non-keeper can open it. @@ -151,7 +169,7 @@ contract RebalancerTest is Test { function test_SellLeg_ValidWithinBudget() public { _openEpoch(); - // Sell 0.5 WBTC ($50k), exactly the overweight budget. + // Sell 0.5 WBTC ($50k), within the $57.5k overweight budget. GPv2Order.Data memory order = rebalancer.buildSellOrder(address(wbtc), 0.5e8, uint32(block.timestamp + 1 hours), bytes32("e1")); @@ -162,11 +180,11 @@ contract RebalancerTest is Test { function test_SellLeg_RevertsOnOvershoot() public { _openEpoch(); - // Sell 0.6 WBTC ($60k) exceeds the $50k overweight budget. + // Sell 0.6 WBTC ($60k) exceeds the $57.5k overweight budget. GPv2Order.Data memory order = rebalancer.buildSellOrder(address(wbtc), 0.6e8, uint32(block.timestamp + 1 hours), bytes32("e1")); - vm.expectRevert(abi.encodeWithSelector(Rebalancer_ExceedsDelta.selector, 60_000e8, 50_000e8)); + vm.expectRevert(abi.encodeWithSelector(Rebalancer_ExceedsDelta.selector, 60_000e8, 57_500e8)); rebalancer.validateOrder(order); } @@ -186,14 +204,14 @@ contract RebalancerTest is Test { function test_BuyLeg_ValidWithinBudget() public { _openEpoch(); - // Spend $50k USDC buying WETH, exactly the underweight budget. + // Spend $42.5k USDC buying WETH, exactly the underweight budget. GPv2Order.Data memory order = - rebalancer.buildBuyOrder(address(weth), 50_000e6, uint32(block.timestamp + 1 hours), bytes32("e1")); + rebalancer.buildBuyOrder(address(weth), 42_500e6, uint32(block.timestamp + 1 hours), bytes32("e1")); assertEq(order.sellToken, address(usdc)); assertEq(order.buyToken, address(weth)); - // $50k / $5k = 10 WETH, less 1% = 9.9 WETH. - assertEq(order.buyAmount, 9.9e18); + // $42.5k / $5k = 8.5 WETH, less 1% = 8.415 WETH. + assertEq(order.buyAmount, 8.415e18); rebalancer.validateOrder(order); } @@ -202,7 +220,7 @@ contract RebalancerTest is Test { GPv2Order.Data memory order = rebalancer.buildBuyOrder(address(weth), 60_000e6, uint32(block.timestamp + 1 hours), bytes32("e1")); - vm.expectRevert(abi.encodeWithSelector(Rebalancer_ExceedsDelta.selector, 60_000e8, 50_000e8)); + vm.expectRevert(abi.encodeWithSelector(Rebalancer_ExceedsDelta.selector, 60_000e8, 42_500e8)); rebalancer.validateOrder(order); } diff --git a/test/CoWOrderHandlerFork.t.sol b/test/RebalancerFork.t.sol similarity index 55% rename from test/CoWOrderHandlerFork.t.sol rename to test/RebalancerFork.t.sol index 3cff89a..5db3ab5 100644 --- a/test/CoWOrderHandlerFork.t.sol +++ b/test/RebalancerFork.t.sol @@ -2,22 +2,28 @@ pragma solidity 0.8.28; import { Test } from "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IndexVault } from "src/IndexVault.sol"; import { AssetRegistry } from "src/AssetRegistry.sol"; +import { MarketCapMethodology } from "src/methodology/MarketCapMethodology.sol"; +import { ISupplyOracle } from "src/interfaces/ISupplyOracle.sol"; import { GPv2Order } from "src/libraries/GPv2Order.sol"; -import { CoWOrderHandler } from "src/rebalancer/CoWOrderHandler.sol"; +import { Rebalancer } from "src/rebalancer/Rebalancer.sol"; import { MockAggregator } from "test/mocks/MockAggregator.sol"; +import { MockSupplyOracle } from "test/mocks/MockSupplyOracle.sol"; interface ICoWSettlement { function domainSeparator() external view returns (bytes32); function vaultRelayer() external view returns (address); } -/// @notice Mainnet-fork spike: proves the handler reconstructs the exact EIP-712 +/// @notice Mainnet-fork proof: the rebalancer reconstructs the exact EIP-712 /// digest the real GPv2Settlement verifies against, and binds to the real -/// relayer. Requires a mainnet RPC; run with `forge test --match-path -/// '*CoWOrderHandlerFork*'` against the `mainnet` endpoint. -contract CoWOrderHandlerForkTest is Test { +/// relayer. This is the make-or-break check that our order encoding hashes +/// byte-for-byte to what CoW verifies on-chain. Requires a mainnet RPC; run with +/// `forge test --match-path '*RebalancerFork*'` against the `mainnet` endpoint. +contract RebalancerForkTest is Test { // Canonical CoW deployments (same address across chains). address internal constant SETTLEMENT = 0x9008D19f58AAbD9eD0D60971565AA8510560ab41; address internal constant RELAYER = 0xC92E8bdf79f0507f65a392b0ab4667716BFE0110; @@ -25,9 +31,10 @@ contract CoWOrderHandlerForkTest is Test { address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; uint48 internal constant HEARTBEAT = 1 days; + uint256 internal constant SLIPPAGE_BPS = 100; AssetRegistry internal registry; - CoWOrderHandler internal handler; + Rebalancer internal rebalancer; function setUp() public { vm.createSelectFork("mainnet"); @@ -36,31 +43,36 @@ contract CoWOrderHandlerForkTest is Test { registry.setUsdcFeed(USDC, address(new MockAggregator(8, 1e8)), HEARTBEAT); registry.registerAsset(WETH, address(new MockAggregator(8, 2000e8)), HEARTBEAT); - handler = new CoWOrderHandler(makeAddr("vault"), registry, USDC, SETTLEMENT, 100); + MockSupplyOracle supplyOracle = new MockSupplyOracle(); + MarketCapMethodology methodology = + new MarketCapMethodology(registry, ISupplyOracle(address(supplyOracle)), address(this)); + + IndexVault vault = new IndexVault(IERC20(USDC), registry, makeAddr("keeper"), address(this)); + + rebalancer = new Rebalancer( + vault, methodology, registry, USDC, SETTLEMENT, makeAddr("keeper"), SLIPPAGE_BPS, 1 hours, 7 days, 200, 500 + ); } - /// @notice The handler bound to the real settlement's domain and relayer. + /// @notice The rebalancer bound to the real settlement's domain and relayer. function test_Fork_BindsRealDomainAndRelayer() public view { bytes32 real = ICoWSettlement(SETTLEMENT).domainSeparator(); - assertEq(handler.DOMAIN_SEPARATOR(), real, "handler domain separator != real settlement"); - assertEq(handler.RELAYER(), RELAYER, "handler relayer != real vault relayer"); + assertEq(rebalancer.DOMAIN_SEPARATOR(), real, "rebalancer domain separator != real settlement"); + assertEq(rebalancer.RELAYER(), RELAYER, "rebalancer relayer != real vault relayer"); assertEq(ICoWSettlement(SETTLEMENT).vaultRelayer(), RELAYER, "unexpected real relayer"); } - /// @notice The digest the handler validates equals the digest computed from - /// the real settlement's domain separator. This is the make-or-break proof: - /// our order encoding hashes byte-for-byte to what CoW verifies on-chain. + /// @notice The digest the rebalancer computes equals the digest computed from + /// the real settlement's domain separator: our order encoding hashes + /// byte-for-byte to what CoW verifies on-chain. function test_Fork_DigestMatchesRealSettlement() public view { GPv2Order.Data memory order = - handler.buildSellOrder(WETH, 1e18, uint32(block.timestamp + 1 hours), bytes32("epoch1")); + rebalancer.buildSellOrder(WETH, 1e18, uint32(block.timestamp + 1 hours), bytes32("epoch1")); - bytes32 viaHandler = handler.orderDigest(order); + bytes32 viaRebalancer = rebalancer.orderDigest(order); bytes32 viaRealDomain = GPv2Order.hash(order, ICoWSettlement(SETTLEMENT).domainSeparator()); - assertEq(viaHandler, viaRealDomain, "handler digest != real-domain digest"); - - // And the handler accepts that digest, returning the ERC-1271 magic value. - assertEq(handler.isValidSignature(viaHandler, abi.encode(order)), bytes4(0x1626ba7e)); + assertEq(viaRebalancer, viaRealDomain, "rebalancer digest != real-domain digest"); } /// @notice The independently recomputed EIP-712 domain separator (chainId 1, diff --git a/test/RebalancerQuarantine.t.sol b/test/RebalancerQuarantine.t.sol index 50002a6..5f9d16e 100644 --- a/test/RebalancerQuarantine.t.sol +++ b/test/RebalancerQuarantine.t.sol @@ -130,8 +130,12 @@ contract RebalancerQuarantineTest is Test { assertFalse(rebalancer.inEpoch(address(wbtc))); assertEq(rebalancer.targetUsd(address(wbtc)), 0); + // The fresh name carries the whole deployable target, which is NAV less + // the 5% operating buffer the rebalancer holds back for the sync lane. assertTrue(rebalancer.inEpoch(address(weth))); - assertEq(rebalancer.targetUsd(address(weth)), rebalancer.epochNavUsd()); + uint256 nav = rebalancer.epochNavUsd(); + uint256 bufferUsd = nav * 500 / 10_000; + assertEq(rebalancer.targetUsd(address(weth)), nav - bufferUsd); assertGt(rebalancer.targetUsd(address(weth)), 0); } diff --git a/test/RebalancerSettle.t.sol b/test/RebalancerSettle.t.sol index 8b02536..1d75a6c 100644 --- a/test/RebalancerSettle.t.sol +++ b/test/RebalancerSettle.t.sol @@ -105,27 +105,31 @@ contract RebalancerSettleTest is Test { _open(); - // Sell leg: sell 0.5 WBTC ($50k overweight) to USDC. + // Targets are $142.5k each: 50/50 of the $285k deployable NAV, after the + // rebalancer holds back the 5% ($15k) operating buffer for the sync lane. + // Sell leg: sell 0.575 WBTC ($57.5k overweight) to USDC. GPv2Order.Data memory sell = - rebalancer.buildSellOrder(address(wbtc), 0.5e8, uint32(block.timestamp + 1 hours), bytes32("e1")); - settlement.settle(sell, address(vault), 0.5e8); + rebalancer.buildSellOrder(address(wbtc), 0.575e8, uint32(block.timestamp + 1 hours), bytes32("e1")); + settlement.settle(sell, address(vault), 0.575e8); - assertEq(wbtc.balanceOf(address(vault)), 1.5e8, "WBTC not sold to target"); - assertEq(usdc.balanceOf(address(vault)), 49_500e6, "USDC proceeds wrong"); + assertEq(wbtc.balanceOf(address(vault)), 1.425e8, "WBTC not sold to target"); + assertEq(usdc.balanceOf(address(vault)), 56_925e6, "USDC proceeds wrong"); - // Buy leg: spend the proceeds buying WETH ($50k underweight). + // Buy leg: deploy the WETH underweight ($42.5k); the rest of the proceeds + // stays as the operating buffer. GPv2Order.Data memory buy = - rebalancer.buildBuyOrder(address(weth), 49_500e6, uint32(block.timestamp + 1 hours), bytes32("e1")); - settlement.settle(buy, address(vault), 49_500e6); + rebalancer.buildBuyOrder(address(weth), 42_500e6, uint32(block.timestamp + 1 hours), bytes32("e1")); + settlement.settle(buy, address(vault), 42_500e6); - assertEq(usdc.balanceOf(address(vault)), 0, "USDC not deployed"); - assertEq(weth.balanceOf(address(vault)), 29.801e18, "WETH not bought"); + // The buffer survives the rebalance instead of the basket draining it. + assertEq(usdc.balanceOf(address(vault)), 14_425e6, "operating buffer not retained"); + assertEq(weth.balanceOf(address(vault)), 28.415e18, "WETH not bought"); - // Basket is now much closer to the 50/50 target: WBTC exactly at target, - // WETH lifted from $100k toward $150k. + // Basket is now much closer to the 50/50 deployable target: WBTC exactly + // at target, WETH lifted from $100k toward $142.5k. (IndexVault.Holding[] memory holdings,,) = vault.getHoldings(); - assertEq(holdings[0].valueUsd, 150_000e8, "WBTC not at target"); - assertEq(holdings[1].valueUsd, 149_005e8, "WETH not lifted toward target"); + assertEq(holdings[0].valueUsd, 142_500e8, "WBTC not at target"); + assertEq(holdings[1].valueUsd, 142_075e8, "WETH not lifted toward target"); // NAV loss is only the worst-case slippage and is within the guard. uint256 navAfter = vault.totalAssets(); @@ -142,12 +146,14 @@ contract RebalancerSettleTest is Test { // The vault returns the ERC-1271 magic value for a valid rebalance leg. assertEq(vault.isValidSignature(digest, abi.encode(order)), bytes4(0x1626ba7e)); - // An order overshooting the delta is rejected by the delegated rebalancer. + // An order overshooting the delta is rejected by the delegated + // rebalancer. The overweight budget is $57.5k (WBTC $200k vs the $142.5k + // deployable target), so a $60k sell overshoots. GPv2Order.Data memory bad = rebalancer.buildSellOrder(address(wbtc), 0.6e8, uint32(block.timestamp + 1 hours), bytes32("e1")); bytes32 badDigest = rebalancer.orderDigest(bad); bytes memory badSig = abi.encode(bad); - vm.expectRevert(abi.encodeWithSelector(Rebalancer_ExceedsDelta.selector, 60_000e8, 50_000e8)); + vm.expectRevert(abi.encodeWithSelector(Rebalancer_ExceedsDelta.selector, 60_000e8, 57_500e8)); vault.isValidSignature(badDigest, badSig); } diff --git a/test/RebalancerTrigger.t.sol b/test/RebalancerTrigger.t.sol index df73b8b..979e3c1 100644 --- a/test/RebalancerTrigger.t.sol +++ b/test/RebalancerTrigger.t.sol @@ -177,4 +177,50 @@ contract RebalancerTriggerTest is Test { vm.expectRevert(abi.encodeWithSelector(Rebalancer_IntervalNotElapsed.selector, block.timestamp + MIN_INTERVAL)); rebalancer.openEpoch(); } + + // ======================================================================== + // Cap-trigger emergency path + // ======================================================================== + + function test_CapTrigger_PromotesToEmergencyBelowLargeDrift() public { + // A real cap: 50% target, 52% trigger. The 2-name basket is cap-feasible + // (2 * 50% = 100%), so getWeights does not revert on the open. + methodology.setWeightParams(0.5e18, 0.52e18, 1); + + // Push WBTC to ~53% of NAV: past the 52% trigger, but its drift from the + // 50% target is only ~312 bps, inside the scheduled band (below D_LARGE). + wbtc.mint(address(vault), 0.2e8); // WBTC $170k, WETH $150k, NAV $320k + uint256 drift = rebalancer.maxDriftBps(); + assertGe(drift, D_SMALL_BPS); + assertLt(drift, D_LARGE_BPS); // not a drift emergency on its own + assertTrue(rebalancer.capTriggerBreached()); + + // The cap breach alone makes the open permissionless. + vm.prank(anyone); + rebalancer.openEpoch(); + assertEq(rebalancer.epochId(), 1); + } + + function test_CapTrigger_NotBreachedStaysKeeperGated() public { + // Trigger set above the drifted weight, so the cap is not breached and + // the scheduled-band open is still keeper-only. + methodology.setWeightParams(0.5e18, 0.55e18, 1); + + wbtc.mint(address(vault), 0.2e8); // ~53% weight, under the 55% trigger + uint256 drift = rebalancer.maxDriftBps(); + assertGe(drift, D_SMALL_BPS); + assertLt(drift, D_LARGE_BPS); + assertFalse(rebalancer.capTriggerBreached()); + + vm.prank(anyone); + vm.expectRevert(abi.encodeWithSelector(Rebalancer_NotKeeper.selector, anyone)); + rebalancer.openEpoch(); + } + + function test_CapTrigger_DisabledWhenNoCap() public { + // The default 50/50 config here uses a WAD trigger (no cap), so the cap + // trigger is inert no matter how a name is weighted. + wbtc.mint(address(vault), 0.6e8); // heavy overweight + assertFalse(rebalancer.capTriggerBreached()); + } }