diff --git a/AGENTS.md b/AGENTS.md index 49e05339..e92b8bad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,3 +139,7 @@ Follow the conventional commit style seen in history (`feat:`, `fix:`, `refactor ## Security & Configuration Tips Never commit secrets; mirror new environment keys in `.env.example` instead. When updating upgradeable contracts, inspect `out/.storageLayout.json` to confirm slot ordering. Check `foundry.toml` `fs_permissions` before broadcasting and surface required env vars in the PR body. + +### Storage Layout Discipline (Deployed UUPS Proxies) + +`PolicyManager`, `ClaimManager`, `PremiumManager`, `SpecRegistry`, `Swapper`, and `CoverPoolFactory` are deployed on Ethereum mainnet as UUPS proxies (see `docs/deployment/Ethereum-Mainnet.md`). Their storage layouts are pinned by `test/unit/StorageLayout.t.sol`. New state variables for these contracts may only be **appended** at the end; insertions, removals, reorderings, or type changes corrupt live proxy state on the next upgrade. When a legitimate append happens, update the corresponding baseline in `StorageLayout.t.sol` in the same commit so the regression test still passes. diff --git a/test/defi/CoveredVaultWrapper.fork.t.sol b/test/defi/CoveredVaultWrapper.fork.t.sol index 02b85f47..30655e20 100644 --- a/test/defi/CoveredVaultWrapper.fork.t.sol +++ b/test/defi/CoveredVaultWrapper.fork.t.sol @@ -19,7 +19,9 @@ contract CoveredVaultWrapperForkTest is ForkCoverageSetup { uint16 internal constant MAX_COVERABLE_LOSS_BPS = 1000; // 10% uint16 internal constant DEDUCTIBLE_BPS = 100; // 1% - uint256 internal constant COVERAGE_LIMIT = 100_000e6; // 100k USDC + /// @dev $1k limit keeps collateral ~0.67 wstETH so the wstETH/USDC Uniswap V3 pool + /// can quote it without hitting depth constraints on the oracle-deviation check. + uint256 internal constant COVERAGE_LIMIT = 1000e6; // 1k USDC IVaultV2 morphoVault; CoveredVaultWrapper wrapper; @@ -147,7 +149,7 @@ contract CoveredVaultWrapperForkTest is ForkCoverageSetup { uint256 depositAmount = 1000e6; _userDeposit(user1, depositAmount); - skip(365 days); + _skipFork(365 days); _userRedeemAll(user1); // Morpho returns ~980 after 2% fee; shortfall ~20; claim = 20 - 10 deductible ≈ 10 USDC. @@ -174,7 +176,7 @@ contract CoveredVaultWrapperForkTest is ForkCoverageSetup { uint256 depositAmount = 1000e6; _userDeposit(user1, depositAmount); deal(USDC, address(morphoVault), IERC20(USDC).balanceOf(address(morphoVault)) + depositAmount * 5 / 100); - skip(365 days); + _skipFork(365 days); _userRedeemAll(user1); assertGt(IERC20(USDC).balanceOf(user1), depositAmount, "User received profit from underlying vault"); @@ -189,7 +191,7 @@ contract CoveredVaultWrapperForkTest is ForkCoverageSetup { uint256 depositAmount = 1000e6; _userDeposit(user1, depositAmount); - skip(365 days); + _skipFork(365 days); uint256 morphoBalance = IERC20(USDC).balanceOf(address(morphoVault)); deal(USDC, address(morphoVault), morphoBalance - depositAmount * 1 / 100); // +1% hack on top of fee diff --git a/test/defi/helpers/ForkCoverageSetup.sol b/test/defi/helpers/ForkCoverageSetup.sol index f0bba570..2d1ba87b 100644 --- a/test/defi/helpers/ForkCoverageSetup.sol +++ b/test/defi/helpers/ForkCoverageSetup.sol @@ -53,6 +53,13 @@ interface IOraclePriceFeedFork { function getUSDValue(address token, uint256 amount) external view returns (uint256); } +interface IAggregatorV3Fork { + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} + interface IDelegationManagerFork { struct SignatureWithExpiry { bytes signature; @@ -115,6 +122,9 @@ abstract contract ForkCoverageSetup is Test { /// @dev Chainlink aggregators address internal constant ETH_USD_AGGREGATOR = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; address internal constant USDC_USD_AGGREGATOR = 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6; + /// @dev wstETH/USD aggregator registered in the live OraclePriceFeed at FORK_BLOCK. + /// Source: confirmed from fileClaim trace (hasPriceFeed(wstETH) == true at FORK_BLOCK). + address internal constant WSTETH_USD_AGGREGATOR = 0xe4aE88743c3834d0c492eAbC47384c84BcADC6a6; /// @dev All live Core addresses are non-empty at this block (Core deployed ~24_875_000). uint256 internal constant FORK_BLOCK = 24_900_000; @@ -232,6 +242,8 @@ abstract contract ForkCoverageSetup is Test { )) ); claimManager.setPremiumManager(address(premiumManager)); + claimManager.setOraclePriceFeed(ORACLE_PRICEFEED); + claimManager.setPriceDeviationToleranceBps(1000); // 10% tolerance for fork tests policyManager.setClaimManager(address(claimManager)); swapper.setClaimManager(address(claimManager)); @@ -308,6 +320,37 @@ abstract contract ForkCoverageSetup is Test { } } + /** + * @notice Drop-in replacement for `skip()` in fork tests that invoke ClaimManager after a + * long time advance. Reads the pre-skip round data for the wstETH/USD and USDC/USD + * Chainlink aggregators, then after the skip mocks each aggregator's + * `latestRoundData()` to return the same answer with `updatedAt = block.timestamp`. + * @dev Without this, the Chainlink aggregators revert with a staleness error (their internal + * check fires when `block.timestamp` moves more than their heartbeat ahead of the last + * on-chain update), causing OraclePriceFeed.getUSDValue to revert and ClaimManager to + * surface OracleUnavailable. The mock is scoped to the test's vm instance and is + * cleared automatically when Forge resets state between tests. + */ + function _skipFork(uint256 duration) internal { + (uint80 wstRoundId, int256 wstAnswer, uint256 wstStartedAt,, uint80 wstAnsweredInRound) = + IAggregatorV3Fork(WSTETH_USD_AGGREGATOR).latestRoundData(); + (uint80 usdcRoundId, int256 usdcAnswer, uint256 usdcStartedAt,, uint80 usdcAnsweredInRound) = + IAggregatorV3Fork(USDC_USD_AGGREGATOR).latestRoundData(); + + skip(duration); + + vm.mockCall( + WSTETH_USD_AGGREGATOR, + abi.encodeWithSignature("latestRoundData()"), + abi.encode(wstRoundId, wstAnswer, wstStartedAt, block.timestamp, wstAnsweredInRound) + ); + vm.mockCall( + USDC_USD_AGGREGATOR, + abi.encodeWithSignature("latestRoundData()"), + abi.encode(usdcRoundId, usdcAnswer, usdcStartedAt, block.timestamp, usdcAnsweredInRound) + ); + } + /// @notice Whitelists a token as both a payout token and an approved premium token. function _whitelistPayoutToken(address token) internal { policyManager.setSupportedPayoutToken(token, true); diff --git a/test/unit/StorageLayout.t.sol b/test/unit/StorageLayout.t.sol new file mode 100644 index 00000000..aaf9f25d --- /dev/null +++ b/test/unit/StorageLayout.t.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {Test} from "forge-std/Test.sol"; + +/** + * @title StorageLayoutTest + * @notice Regression test that pins the storage layout of every upgradeable proxy currently + * deployed on Ethereum mainnet (see docs/deployment/Ethereum-Mainnet.md). + * @dev Forge writes each contract's storage layout to `out/.sol/.json` because + * `extra_output = ["storageLayout"]` is set in foundry.toml. This test reads that JSON for + * each deployed UUPS proxy and asserts (label, slot, offset, type) for every storage entry + * against a pinned baseline captured from the deployed implementation source. + * + * The test FAILS if any future PR inserts, removes, reorders, or retypes a state variable + * in a contract that already has a live proxy. Backward-compatible additions must extend + * the contract by appending new variables AND must update the corresponding baseline in + * this file in the same commit. + * + * Compiler-generated AST node IDs that appear inside parenthesised type identifiers + * (e.g. `t_struct(PolicyDraft)12345_storage`) are stripped before comparison, so unrelated + * changes elsewhere in the codebase that shift AST IDs do not produce spurious failures. + * + * The OpenZeppelin v5 upgradeable bases used by these contracts (AccessControlUpgradeable, + * PausableUpgradeable, UUPSUpgradeable, etc.) use ERC-7201 namespaced storage, so they do + * not occupy slots 0+. The slots pinned below therefore correspond exactly to each child + * contract's own declared state variables. + * + * CoverPool is intentionally not pinned: it is deployed as EIP-1167 minimal-proxy clones + * via CoverPoolFactory.createCoverPool, and existing clones cannot be upgraded in place. + * UniswapV3Adapter is non-upgradeable and is also out of scope. + */ +contract StorageLayoutTest is Test { + /// @dev Single storage entry as decoded from the Foundry storageLayout JSON. + /// @dev Field names are chosen so Foundry's JSON struct decoder maps them to the + /// JSON keys (`astId`, `contract`, `label`, `offset`, `slot`, `type`). + struct RawSlot { + uint256 astId; + string _contract; + string label; + uint256 offset; + string slot; + string _type; + } + + /// @dev Pinned baseline entry. `slot` and `offset` are decoded from strings; `_type` is + /// compared after AST IDs are stripped (see `_stripAstIds`). + struct ExpectedSlot { + string label; + uint256 slot; + uint256 offset; + string typeName; + } + + /*////////////////////////////////////////////////////////////// + TESTS + //////////////////////////////////////////////////////////////*/ + + function test_PolicyManager_storageLayoutPinned() public view { + ExpectedSlot[] memory expected = new ExpectedSlot[](9); + expected[0] = ExpectedSlot("stakeManager", 0, 0, "t_address"); + expected[1] = ExpectedSlot("claimManager", 1, 0, "t_address"); + expected[2] = ExpectedSlot("coverPoolFactory", 2, 0, "t_address"); + expected[3] = ExpectedSlot("_nextPolicyId", 2, 20, "t_uint96"); + expected[4] = ExpectedSlot("_policyDrafts", 3, 0, "t_mapping(t_uint96,t_struct(PolicyDraft)_storage)"); + expected[5] = ExpectedSlot("_policies", 4, 0, "t_mapping(t_uint96,t_struct(PolicyMetadata)_storage)"); + expected[6] = ExpectedSlot("_registeredPolicyCommits", 5, 0, "t_mapping(t_bytes32,t_bool)"); + expected[7] = ExpectedSlot("supportedPayoutTokens", 6, 0, "t_mapping(t_address,t_bool)"); + expected[8] = ExpectedSlot("oraclePriceFeed", 7, 0, "t_address"); + _assertLayout("PolicyManager", expected); + } + + function test_ClaimManager_storageLayoutPinned() public view { + ExpectedSlot[] memory expected = new ExpectedSlot[](13); + expected[0] = ExpectedSlot("specRegistry", 0, 0, "t_address"); + expected[1] = ExpectedSlot("slashingManager", 1, 0, "t_address"); + expected[2] = ExpectedSlot("swapper", 2, 0, "t_address"); + expected[3] = ExpectedSlot("policyManager", 3, 0, "t_address"); + expected[4] = ExpectedSlot("premiumManager", 4, 0, "t_address"); + expected[5] = ExpectedSlot("maxSwapSlippageBps", 4, 20, "t_uint16"); + expected[6] = + ExpectedSlot("_claims", 5, 0, "t_mapping(t_uint96,t_mapping(t_uint256,t_struct(ClaimRecord)_storage))"); + expected[7] = ExpectedSlot("_policyClaims", 6, 0, "t_mapping(t_uint96,t_struct(PolicyClaim)_storage)"); + expected[8] = ExpectedSlot("_claimApprovalRequired", 7, 0, "t_mapping(t_uint96,t_bool)"); + expected[9] = ExpectedSlot("_usedEvidenceHashes", 8, 0, "t_mapping(t_uint96,t_mapping(t_bytes32,t_bool))"); + expected[10] = ExpectedSlot("oraclePriceFeed", 9, 0, "t_address"); + expected[11] = ExpectedSlot("priceDeviationToleranceBps", 9, 20, "t_uint16"); + expected[12] = ExpectedSlot("__gap", 10, 0, "t_array(t_uint256)48_storage"); + _assertLayout("ClaimManager", expected); + } + + function test_PremiumManager_storageLayoutPinned() public view { + ExpectedSlot[] memory expected = new ExpectedSlot[](5); + expected[0] = ExpectedSlot("rewardsManager", 0, 0, "t_address"); + expected[1] = ExpectedSlot("platformTreasury", 1, 0, "t_address"); + expected[2] = ExpectedSlot("platformFeeBps", 1, 20, "t_uint16"); + expected[3] = ExpectedSlot("_approvedPremiumTokens", 2, 0, "t_struct(AddressSet)_storage"); + expected[4] = ExpectedSlot("_distributionNonce", 4, 0, "t_uint256"); + _assertLayout("PremiumManager", expected); + } + + function test_SpecRegistry_storageLayoutPinned() public view { + ExpectedSlot[] memory expected = new ExpectedSlot[](3); + expected[0] = ExpectedSlot("coverPoolFactory", 0, 0, "t_contract(ICoverPoolFactory)"); + expected[1] = ExpectedSlot("_specs", 1, 0, "t_mapping(t_address,t_mapping(t_bytes32,t_contract(ISpec)))"); + expected[2] = ExpectedSlot("_approvedSpecs", 2, 0, "t_mapping(t_address,t_bool)"); + _assertLayout("SpecRegistry", expected); + } + + function test_Swapper_storageLayoutPinned() public view { + ExpectedSlot[] memory expected = new ExpectedSlot[](6); + expected[0] = ExpectedSlot("nativeWrapper", 0, 0, "t_address"); + expected[1] = ExpectedSlot("whitelistedTargets", 1, 0, "t_mapping(t_address,t_bool)"); + expected[2] = ExpectedSlot("_swapRoutes", 2, 0, "t_mapping(t_bytes32,t_struct(SwapRoute)_storage)"); + expected[3] = ExpectedSlot("claimManager", 3, 0, "t_address"); + expected[4] = ExpectedSlot("_routeLockedUntil", 4, 0, "t_mapping(t_bytes32,t_uint256)"); + expected[5] = ExpectedSlot("_targetLockedUntil", 5, 0, "t_mapping(t_address,t_uint256)"); + _assertLayout("Swapper", expected); + } + + function test_CoverPoolFactory_storageLayoutPinned() public view { + ExpectedSlot[] memory expected = new ExpectedSlot[](5); + expected[0] = ExpectedSlot("coverPoolImplementation", 0, 0, "t_address"); + expected[1] = ExpectedSlot("policyManager", 1, 0, "t_address"); + expected[2] = ExpectedSlot("stakeManager", 2, 0, "t_address"); + expected[3] = ExpectedSlot("specRegistry", 3, 0, "t_address"); + expected[4] = ExpectedSlot("_pools", 4, 0, "t_struct(AddressSet)_storage"); + _assertLayout("CoverPoolFactory", expected); + } + + /*////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Reads `out/.sol/.json` and asserts every storage + * entry matches the pinned baseline. + * @dev Fails on any of: count mismatch, label mismatch, slot mismatch, offset mismatch, + * or type mismatch (after AST-ID stripping). + */ + function _assertLayout(string memory contractName, ExpectedSlot[] memory expected) private view { + string memory artifactPath = string.concat("out/", contractName, ".sol/", contractName, ".json"); + string memory json = vm.readFile(artifactPath); + + bytes memory raw = vm.parseJson(json, ".storageLayout.storage"); + RawSlot[] memory actual = abi.decode(raw, (RawSlot[])); + + assertEq( + actual.length, + expected.length, + string.concat( + contractName, + ": storage entry count drifted from baseline. Update test/unit/StorageLayout.t.sol", + " ONLY after confirming the change is upgrade-safe (variables appended at the end)." + ) + ); + + for (uint256 i = 0; i < expected.length; ++i) { + string memory ctx = string.concat(contractName, "[", vm.toString(i), "]:", expected[i].label); + + assertEq(actual[i].label, expected[i].label, string.concat(ctx, " label")); + assertEq(vm.parseUint(actual[i].slot), expected[i].slot, string.concat(ctx, " slot")); + assertEq(actual[i].offset, expected[i].offset, string.concat(ctx, " offset")); + assertEq(_stripAstIds(actual[i]._type), expected[i].typeName, string.concat(ctx, " type")); + } + } + + /** + * @notice Strips compiler-generated AST node IDs from a Solidity storage type identifier. + * @dev Foundry/solc embed an AST node ID immediately after the closing paren of struct, + * contract, and enum type names (e.g. `t_struct(PolicyDraft)12345_storage`, + * `t_contract(ISpec)6789`). The numeric ID is build-dependent and shifts when unrelated + * contracts are added or reordered, even when the actual storage layout is unchanged. + * Pinning the canonical name without the ID makes the regression test resilient to + * cosmetic AST drift while still catching real layout changes. + * + * Digits after `)` are only stripped when the corresponding `(` was preceded by a + * named-type keyword — "struct", "contract", "enum", or "userDefinedValueType" — + * which are the only contexts where solc emits an AST ID. For `t_array(type)N_storage` + * the `N` encodes the array length and must be preserved so that a `uint256[32]` → + * `uint256[64]` change is visible. A per-paren-depth boolean stack records the decision + * made at each `(`. + * @param s Original type identifier as emitted by solc. + * @return Normalised identifier with named-type `(...)` collapsed to `(...)`. + */ + function _stripAstIds(string memory s) private pure returns (string memory) { + bytes memory b = bytes(s); + bytes memory buf = new bytes(b.length); + uint256 outLen = 0; + + bool[] memory stripAfterClose = new bool[](32); + uint256 depth = 0; + bool skipDigits = false; + + for (uint256 i = 0; i < b.length; ++i) { + bytes1 c = b[i]; + if (skipDigits) { + if (c >= 0x30 && c <= 0x39) continue; + skipDigits = false; + } + if (c == 0x28) { + stripAfterClose[depth] = _bufEndsWithNamedType(buf, outLen); + depth++; + buf[outLen++] = c; + } else if (c == 0x29) { + buf[outLen++] = c; + if (depth > 0) { + depth--; + skipDigits = stripAfterClose[depth]; + } + } else { + buf[outLen++] = c; + } + } + + bytes memory trimmed = new bytes(outLen); + for (uint256 i = 0; i < outLen; ++i) { + trimmed[i] = buf[i]; + } + return string(trimmed); + } + + /** + * @dev Returns true when `buf[0..len-1]` ends with a solc named-type keyword whose closing + * `)` is followed by an AST node ID: "struct", "contract", "enum", or + * "userDefinedValueType" (the last covers `type Foo is uint128` style declarations, + * added in Solidity 0.8.8). + */ + function _bufEndsWithNamedType(bytes memory buf, uint256 len) private pure returns (bool) { + return _bufEndsWith(buf, len, "struct") || _bufEndsWith(buf, len, "contract") || _bufEndsWith(buf, len, "enum") + || _bufEndsWith(buf, len, "userDefinedValueType"); + } + + /// @dev Returns true when `buf[0..len-1]` ends with the bytes of `suffix`. + function _bufEndsWith(bytes memory buf, uint256 len, string memory suffix) private pure returns (bool) { + bytes memory s = bytes(suffix); + if (len < s.length) return false; + for (uint256 i = 0; i < s.length; ++i) { + if (buf[len - s.length + i] != s[i]) return false; + } + return true; + } +}