Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/interfaces/IMethodology.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
9 changes: 4 additions & 5 deletions src/interfaces/ISupplyOracle.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/libraries/WeightMath.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/methodology/MarketCapMethodology.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
186 changes: 0 additions & 186 deletions src/rebalancer/CoWOrderHandler.sol

This file was deleted.

50 changes: 44 additions & 6 deletions src/rebalancer/Rebalancer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -161,30 +161,39 @@ 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);
if (epochId != 0 && block.timestamp < epochOpenedAt + CADENCE) {
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);
}
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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
// ========================================================================
Expand Down
Loading