diff --git a/README.md b/README.md index e1d55ee..9312fd7 100644 --- a/README.md +++ b/README.md @@ -58,20 +58,26 @@ deviation OR 1 hour elapsed (whichever first). Volatile periods get pushes every few minutes; the 1-hour heartbeat is the dead-market floor. Recommended `maxAge = 2 hours` (one missed heartbeat tolerance). -> **⚠️ Required config invariant: `pauseTimeAfter >= maxAge`** (enforced at -> `initialize` — a violating config reverts `PauseTimeAfterBelowMaxAge`). The -> share price multiplies the DIA equity price by the vault's _live_ NAV ratio, -> and both must belong to the same corporate-action epoch. When an action -> completes the ratio rebalances instantly, but DIA can keep serving the -> pre-action price for up to `maxAge`. Only the post-window pause separates the -> two; if it lifts while a pre-action price is still fresh, that price pairs -> with the post-action ratio — on a 2:1 split the share reads ~2× its true -> value, enabling over-borrow (bad debt). Setting `pauseTimeAfter >= maxAge` -> guarantees only same-epoch prices are served. The exact boundary -> (`pauseTimeAfter == maxAge`) is airtight because the staleness check rejects -> the `maxAge` edge (`age >= maxAge` is stale), so at pause-lift the oldest -> still-fresh push is strictly newer than `effectiveTime`. A margin above -> `maxAge` is still recommended as defence-in-depth. +> **⚠️ Required config invariant: `pauseTimeAfter > maxAge` (STRICTLY)** +> (enforced at `initialize` — a violating config, including the equal boundary, +> reverts `PauseTimeAfterBelowMaxAge`). The share price multiplies the DIA +> equity price by the vault's _live_ NAV ratio, and both must belong to the same +> corporate-action epoch. When an action completes the ratio rebalances +> instantly, but DIA can keep serving the pre-action price for up to `maxAge`. +> Only the post-window pause separates the two; if it lifts while a pre-action +> price is still fresh, that price pairs with the post-action ratio — on a 2:1 +> split the share reads ~2× its true value, enabling over-borrow (bad debt). +> +> The margin `pauseTimeAfter - maxAge` is the maximum forward DIA feed clock +> skew the config tolerates. Staleness is aged from the push's own _source_ +> timestamp, so a feed running `skew` seconds fast can stamp a pre-action +> observation up to `skew` after `effectiveTime`; only a margin exceeding that +> skew guarantees every still-acceptable push at pause-lift was observed at or +> after the action. Equality (`pauseTimeAfter == maxAge`) tolerates **zero** +> skew — a feed even one second fast reopens the 2× window — so it is rejected. +> Size the margin above your feed's worst-case forward skew; the recommended +> `maxAge = 2 hours` with `pauseTimeAfter = 3 hours` (a 1h margin) is the +> reference. **DIA on Base mainnet:** the canonical oracle contract is `0xCE521b52513242c5094bc56f57887BB2A05B8129`. Per-symbol feeds are all served @@ -91,7 +97,7 @@ DIAVaultOracle oracle = diaVaultOracleBeaconSetDeployer.newDIAVaultOracle( maxAge: 2 hours, actionTypeMask: type(uint256).max, pauseTimeBefore: 1 hours, - pauseTimeAfter: 3 hours // >= maxAge, with a margin (see invariant above) + pauseTimeAfter: 3 hours // > maxAge, with a skew margin (see invariant above) }) ); ``` diff --git a/src/concrete/deploy/MorphoPairAdapterBeaconSetDeployer.sol b/src/concrete/deploy/MorphoPairAdapterBeaconSetDeployer.sol index a3c69eb..817d034 100644 --- a/src/concrete/deploy/MorphoPairAdapterBeaconSetDeployer.sol +++ b/src/concrete/deploy/MorphoPairAdapterBeaconSetDeployer.sol @@ -62,8 +62,10 @@ contract MorphoPairAdapterBeaconSetDeployer { /// The beacon for the MorphoPairAdapter implementation contracts. IBeacon public immutable iMorphoPairAdapterBeacon; - /// The central multi-pair price store every adapter deployed through this - /// beacon reads — fixed for the beacon's whole life. + /// The central multi-pair price store recorded at deploy time. This is the + /// central baked into the FIRST implementation; a beacon upgrade can retarget + /// live adapters to a different central while this value is unchanged — see + /// the config-struct NatSpec for why it is not a live invariant. ST0xPriceOracle public immutable iCentral; constructor(MorphoPairAdapterBeaconSetDeployerConfig memory config) { diff --git a/src/concrete/oracle/DIAVaultOracle.sol b/src/concrete/oracle/DIAVaultOracle.sol index 91a8414..3f3396f 100644 --- a/src/concrete/oracle/DIAVaultOracle.sol +++ b/src/concrete/oracle/DIAVaultOracle.sol @@ -104,8 +104,8 @@ error HistoricalRoundDataUnsupported(uint80 roundId); /// @param maxAge Maximum acceptable DIA push age in seconds. /// `block.timestamp - timestamp >= maxAge` reverts `DIAPriceStale` (the edge /// instant fails closed — a push exactly `maxAge` old is stale). Immutable -/// after init — redeploy a fresh proxy to change. MUST be `<= pauseTimeAfter` -/// (see `pauseTimeAfter`). +/// after init — redeploy a fresh proxy to change. MUST be `< pauseTimeAfter` +/// STRICTLY (a positive margin is required — see `pauseTimeAfter`). /// @param actionTypeMask Bitmap of action types that trigger the auto-pause. /// `ACTION_TYPE_STOCK_SPLIT_V1` for splits only, or `type(uint256).max` for /// every present and future action type. Must be non-zero. diff --git a/test/src/concrete/oracle/DIAVaultOracle.t.sol b/test/src/concrete/oracle/DIAVaultOracle.t.sol index ac97a9b..adb798e 100644 --- a/test/src/concrete/oracle/DIAVaultOracle.t.sol +++ b/test/src/concrete/oracle/DIAVaultOracle.t.sol @@ -319,7 +319,7 @@ contract DIAVaultOracleTest is Test { /// which condition dominates. /// /// The overlap is not contrived: it is the normal state late in a post-action - /// window, since `pauseTimeAfter >= maxAge` guarantees every push predating + /// window, since `pauseTimeAfter > maxAge` guarantees every push predating /// the action has gone stale before the pause lifts. Here the push sits /// exactly on the staleness edge (`age == maxAge`) while the completed action /// is still mid-window. @@ -672,8 +672,9 @@ contract DIAVaultOracleTest is Test { /// @notice The staleness edge fails closed: a push aged EXACTLY `maxAge` /// reverts `DIAPriceStale` (`age >= maxAge` is stale). This edge-rejection - /// is what makes the cross-epoch invariant airtight at `pauseTimeAfter == - /// maxAge` — see the contract NatSpec. + /// tightens the cross-epoch invariant by one second; the invariant itself is + /// closed by the strict `pauseTimeAfter > maxAge` init margin — see the + /// contract NatSpec. function testLatestAnswerAtMaxAgeBoundaryIsStale() external { DIAVaultOracle oracle = _deployProxy(_defaultConfig()); uint128 boundary = uint128(block.timestamp - MAX_AGE);