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
42 changes: 29 additions & 13 deletions src/methodology/MarketCapMethodology.sol
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,25 @@ contract MarketCapMethodology is IMethodology, Ownable2Step {
/// supply arrived in native token decimals instead of whole tokens.
uint256 public constant MARKET_CAP_SANITY_BOUND = 1e30;

/// @notice Hard per-asset weight cap in WAD. Default 25%.
uint256 public capWad = 0.25e18;
/// @notice Per-asset weight cap in WAD, the level a constituent is brought
/// down to when capped. The target computation always caps to this. Default 25%.
uint256 public capTargetWad = 0.25e18;

/// @notice Actual-weight threshold (at or above capTargetWad) past which a
/// capped constituent is rebalanced back to capTargetWad. The gap is
/// hysteresis: a held weight may drift between target and trigger without
/// forcing a trade, mirroring the Nasdaq-100 special rebalance (cap to 20%,
/// trigger at 24%). The weight computation here always caps to capTargetWad;
/// the rebalancer consumes capTriggerWad as the off-cycle rebalance condition.
/// Default 30% (a 1.2x gap over the 25% target).
uint256 public capTriggerWad = 0.3e18;

/// @notice Minimum weight in WAD below which a constituent is pruned to
/// zero. Default 0.01%: positions smaller than this cost more in gas and
/// slippage at rebalance than they contribute to tracking.
uint256 public floorWad = 1e14;

event WeightParamsSet(uint256 capWad, uint256 floorWad);
event WeightParamsSet(uint256 capTargetWad, uint256 capTriggerWad, uint256 floorWad);

constructor(AssetRegistry registry, ISupplyOracle supplyOracle, address initialOwner) Ownable(initialOwner) {
if (address(registry) == address(0) || address(supplyOracle) == address(0)) {
Expand Down Expand Up @@ -97,22 +107,28 @@ contract MarketCapMethodology is IMethodology, Ownable2Step {
if (total == 0) revert MarketCapMethodology_InvalidTotalMarketCap();

uint256[] memory weights = WeightMath.normalize(marketCaps);
weights = WeightMath.applyCap(weights, capWad);
return WeightMath.applyFloor(weights, floorWad, capWad);
weights = WeightMath.applyCap(weights, capTargetWad);
return WeightMath.applyFloor(weights, floorWad, capTargetWad);
}

/// @notice Sets the per-asset cap and the minimum-weight 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 must be feasible for the constituent set: getWeights reverts
/// The cap target must be feasible for the constituent set: getWeights reverts
/// with WeightMath_CapInfeasible when the number of nonzero-market-cap
/// constituents k satisfies k * capWad < 1e18 (a 25% cap needs at least
/// four viable names). Size the cap to the index, not the other way round.
function setWeightParams(uint256 capWad_, uint256 floorWad_) external onlyOwner {
if (capWad_ == 0 || capWad_ > WeightMath.WAD || floorWad_ >= capWad_) {
/// constituents k satisfies k * capTargetWad < 1e18 (a 25% target needs at
/// least four viable names). The trigger must sit at or above the target;
/// equal disables hysteresis (rebalance on any breach of the cap), a wider
/// gap gives the rebalancer a larger dead-band.
function setWeightParams(uint256 capTargetWad_, uint256 capTriggerWad_, uint256 floorWad_) external onlyOwner {
if (
capTargetWad_ == 0 || capTriggerWad_ > WeightMath.WAD || capTriggerWad_ < capTargetWad_
|| floorWad_ >= capTargetWad_
) {
revert MarketCapMethodology_InvalidParams();
}
capWad = capWad_;
capTargetWad = capTargetWad_;
capTriggerWad = capTriggerWad_;
floorWad = floorWad_;
emit WeightParamsSet(capWad_, floorWad_);
emit WeightParamsSet(capTargetWad_, capTriggerWad_, floorWad_);
}
}
35 changes: 28 additions & 7 deletions src/oracle/SupplyOracle.sol
Original file line number Diff line number Diff line change
Expand Up @@ -274,19 +274,26 @@ contract SupplyOracle is ISupplyOracle, Ownable2Step {
revert SupplyOracle_SourcesDiverged(token, spreadBps, divergenceToleranceBps);
}

// Layer 3 rate-limit: clamp the move toward the median so a large jump
// is approached over several commits, each at least minCommitInterval
// apart (enforced above), rather than landing at once.
uint256 next = median;
// Round the agreed median to a coarse float-factor tier (CRSP Effective
// Float Factor style) so small, noisy float changes do not move target
// weights: a median that wiggles within a tier produces the same target,
// and once the committed value has converged to it no further commit
// moves it.
uint256 target = _roundFactorWad(median);

// Layer 3 rate-limit: clamp the move toward the rounded target so a
// large jump is approached over several commits, each at least
// minCommitInterval apart (enforced above), rather than landing at once.
uint256 next = target;
if (prev.initialized) {
uint256 maxStep = prev.factorWad.mulDiv(maxFactorDeltaBps, BPS, Math.Rounding.Floor);
if (median > prev.factorWad + maxStep) {
if (target > prev.factorWad + maxStep) {
next = prev.factorWad + maxStep;
} else if (median + maxStep < prev.factorWad) {
} else if (target + maxStep < prev.factorWad) {
next = prev.factorWad - maxStep;
}
}
if (next > WAD) next = WAD; // structural cap; median is already <= WAD
if (next > WAD) next = WAD; // structural cap; target is already <= WAD

committed[token] = Committed({ factorWad: next, timestamp: uint64(block.timestamp), initialized: true });
emit Committed_(token, next, median, count);
Expand Down Expand Up @@ -398,4 +405,18 @@ contract SupplyOracle is ISupplyOracle, Ownable2Step {
if (len % 2 == 1) return sorted[mid];
return (sorted[mid - 1] + sorted[mid]) / 2;
}

/// @dev Rounds a free-float factor to a coarse tier, modelled on CRSP's
/// Effective Float Factor: nearest 5% at or above 10% float, nearest 1%
/// between 1% and 10%, nearest 0.1% below 1%. A nonzero factor never rounds
/// to zero. Coarse rounding is what suppresses needless re-weighting on
/// small, noisy float changes.
function _roundFactorWad(uint256 f) private pure returns (uint256) {
if (f == 0) return 0;
uint256 tier = f >= 1e17 ? 5e16 : (f >= 1e16 ? 1e16 : 1e15);
uint256 rounded = ((f + tier / 2) / tier) * tier;
if (rounded > WAD) rounded = WAD;
if (rounded == 0) rounded = tier;
return rounded;
}
}
36 changes: 30 additions & 6 deletions test/MarketCapMethodology.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ contract MarketCapMethodologyTest is Test {

function test_GetWeights_UncappedMatchesRawMarketCapRatio() public {
// Lift the cap out of the way to verify the raw weighting alone.
methodology.setWeightParams(WAD, 1);
methodology.setWeightParams(WAD, WAD, 1);
uint256[] memory w = methodology.getWeights(tokens);

// Total $2.701T: BTC 2000/2701, ETH 600/2701, SOL 100/2701, TAIL 1/2701.
Expand Down Expand Up @@ -112,7 +112,7 @@ contract MarketCapMethodologyTest is Test {

function test_GetWeights_FloorPrunesDustPosition() public {
// 40% cap so the index is not fully degenerate, tail floor at 0.05%.
methodology.setWeightParams(0.4e18, 5e14);
methodology.setWeightParams(0.4e18, 0.4e18, 5e14);
// Shrink TAIL to a dust market cap ($100M against $2.7T).
supplyOracle.setSupply(address(tail), 100_000_000);

Expand Down Expand Up @@ -156,16 +156,40 @@ contract MarketCapMethodologyTest is Test {
methodology.getWeights(single);
}

function test_CapParams_Defaults() public view {
// The target is what getWeights caps to; the trigger is the wider
// actual-weight threshold the rebalancer reads for hysteresis.
assertEq(methodology.capTargetWad(), 0.25e18);
assertEq(methodology.capTriggerWad(), 0.3e18);
assertEq(methodology.floorWad(), 1e14);
assertGt(methodology.capTriggerWad(), methodology.capTargetWad());
}

function test_SetWeightParams_Validates() public {
// Zero cap target is invalid.
vm.expectRevert(MarketCapMethodology_InvalidParams.selector);
methodology.setWeightParams(0, 0.3e18, 0);

// Floor at or above the cap target is invalid.
vm.expectRevert(MarketCapMethodology_InvalidParams.selector);
methodology.setWeightParams(0, 0);
methodology.setWeightParams(0.2e18, 0.2e18, 0.2e18);

// Trigger below the cap target is invalid.
vm.expectRevert(MarketCapMethodology_InvalidParams.selector);
methodology.setWeightParams(0.2e18, 0.2e18);
methodology.setWeightParams(0.3e18, 0.2e18, 1e14);

// Trigger above 1e18 is invalid.
vm.expectRevert(MarketCapMethodology_InvalidParams.selector);
methodology.setWeightParams(0.3e18, WAD + 1, 1e14);

// Trigger equal to target is allowed (hysteresis disabled).
methodology.setWeightParams(0.3e18, 0.3e18, 1e14);
assertEq(methodology.capTriggerWad(), 0.3e18);

// Non-owner cannot set params.
vm.prank(makeAddr("rando"));
vm.expectRevert();
methodology.setWeightParams(0.3e18, 1e14);
methodology.setWeightParams(0.3e18, 0.35e18, 1e14);
}

/// @notice End-to-end weighting invariants under fuzzed prices and supplies.
Expand All @@ -187,7 +211,7 @@ contract MarketCapMethodologyTest is Test {

uint256 sum = 0;
for (uint256 i = 0; i < 4; i++) {
assertLe(w[i], methodology.capWad());
assertLe(w[i], methodology.capTargetWad());
assertTrue(w[i] == 0 || w[i] >= methodology.floorWad());
sum += w[i];
}
Expand Down
65 changes: 58 additions & 7 deletions test/SupplyOracle.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,17 @@ contract SupplyOracleTest is Test {
function test_Commit_MedianOfFreshReports() public {
_exclude(treasury);
_exclude(vesting); // circulating = 600,000
// Reports cluster within the 2% tolerance; median is the middle value.
// Reports cluster within the 2% tolerance; median is the middle value,
// 0.92, which the EFF rounding snaps to the nearest 5% tier, 0.90.
_reportAll(0.91e18, 0.92e18, 0.93e18);
oracle.commit(address(token));

(uint256 factor,, bool frozen) = oracle.freeFloatFactor(address(token));
assertEq(factor, 0.92e18, "median not committed");
assertEq(factor, 0.9e18, "median not committed at the rounded tier");
assertFalse(frozen);

// free-float = 600,000 * 0.92 = 552,000
assertEq(oracle.getFreeFloatSupply(address(token)), 552_000);
// free-float = 600,000 * 0.90 = 540,000
assertEq(oracle.getFreeFloatSupply(address(token)), 540_000);
}

function test_Commit_RevertsBelowQuorum() public {
Expand Down Expand Up @@ -213,7 +214,8 @@ contract SupplyOracleTest is Test {
_exclude(vesting);
_reportAll(0.91e18, 0.92e18, 0.93e18);
oracle.commit(address(token));
assertEq(oracle.getFreeFloatSupply(address(token)), 600_000 * 92 / 100);
// Median 0.92 rounds to the 0.90 tier, so free-float = 600,000 * 0.90.
assertEq(oracle.getFreeFloatSupply(address(token)), 600_000 * 90 / 100);

// All three reporters now disagree wildly: median 0.60, and neither
// 0.30 nor 0.92 sits within the 2% band, so fewer than two agree.
Expand All @@ -225,9 +227,9 @@ contract SupplyOracleTest is Test {
);
oracle.commit(address(token));

// Last-good factor untouched: the freeze held.
// Last-good factor untouched: the freeze held (at the rounded 0.90 tier).
(uint256 factor,,) = oracle.freeFloatFactor(address(token));
assertEq(factor, 0.92e18);
assertEq(factor, 0.9e18);
}

/// @notice The median is robust to a single captured reporter: a 2-of-3
Expand Down Expand Up @@ -382,4 +384,53 @@ contract SupplyOracleTest is Test {
uint256 circulating = registry.onChainCirculating(address(token));
assertLe(oracle.getFreeFloatSupply(address(token)), circulating);
}

// ========================================================================
// Float-factor rounding (CRSP EFF tiers)
// ========================================================================

/// @notice A float wiggle within the same tier does not move the committed
/// factor, while a change that crosses a tier does. This is what suppresses
/// needless re-weighting on small, noisy float changes.
function test_FloatRounding_WiggleWithinTierIsNoOp() public {
_exclude(treasury);
_exclude(vesting);

// 0.90 is tier-aligned (nearest 5%).
_reportAll(0.9e18, 0.9e18, 0.9e18);
oracle.commit(address(token));
(uint256 f0,,) = oracle.freeFloatFactor(address(token));
assertEq(f0, 0.9e18);

// 0.91 rounds back to the 0.90 tier, so the committed factor does not move.
vm.warp(block.timestamp + oracle.minCommitInterval());
_reportAll(0.91e18, 0.91e18, 0.91e18);
oracle.commit(address(token));
(uint256 f1,,) = oracle.freeFloatFactor(address(token));
assertEq(f1, 0.9e18, "in-tier wiggle moved the factor");

// 0.93 crosses into the 0.95 tier, so the factor does move.
vm.warp(block.timestamp + oracle.minCommitInterval());
_reportAll(0.93e18, 0.93e18, 0.93e18);
oracle.commit(address(token));
(uint256 f2,,) = oracle.freeFloatFactor(address(token));
assertEq(f2, 0.95e18, "tier-crossing change did not round to 0.95");
}

/// @notice The committed factor is always tier-aligned at steady state, and
/// a nonzero factor never rounds to zero.
function testFuzz_FloatRounding_StaysTierAlignedAndNonzero(uint256 factorSeed) public {
uint256 factor = bound(factorSeed, 1, WAD);
_reportAll(factor, factor, factor);
// The first commit has no prior value, so the clamp does not apply and
// the committed factor is exactly the rounded target.
oracle.commit(address(token));

(uint256 committedFactor,,) = oracle.freeFloatFactor(address(token));
assertGt(committedFactor, 0, "nonzero factor rounded to zero");
assertLe(committedFactor, WAD);
// Aligned to one of the three tiers.
uint256 tier = committedFactor >= 1e17 ? 5e16 : (committedFactor >= 1e16 ? 1e16 : 1e15);
assertEq(committedFactor % tier, 0, "committed factor not tier-aligned");
}
}
2 changes: 1 addition & 1 deletion test/SupplyOracleIntegration.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ contract SupplyOracleIntegrationTest is Test {
// Raise the cap so the 4-name index is not saturated (at 25% every
// name pins to the cap and no supply change can move a weight); at 40%
// the tails sit below the cap and supply flows through.
methodology.setWeightParams(0.4e18, 1e14);
methodology.setWeightParams(0.4e18, 0.4e18, 1e14);
uint256[] memory before = methodology.getWeights(tokens);

// Exclude tailA's vesting tranche (half its supply) once identified.
Expand Down