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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<Contract>.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.
10 changes: 6 additions & 4 deletions test/defi/CoveredVaultWrapper.fork.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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");
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions test/defi/helpers/ForkCoverageSetup.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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);
Expand Down
243 changes: 243 additions & 0 deletions test/unit/StorageLayout.t.sol
Original file line number Diff line number Diff line change
@@ -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/<Contract>.sol/<Contract>.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/<contractName>.sol/<contractName>.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 `(...)<digits>` 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);
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* @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;
}
}
Loading