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()); + } }