diff --git a/docs/architecture/06-premiums-claims.md b/docs/architecture/06-premiums-claims.md index 2556f7a3..45baa5e4 100644 --- a/docs/architecture/06-premiums-claims.md +++ b/docs/architecture/06-premiums-claims.md @@ -258,6 +258,41 @@ Vault slashes are pre-inflated in `_computeVaultSlashes` (by `1 / (1 - maxSwapSl This supports heterogeneous slash collateral while preserving payout-token settlement without relying on an oracle. +## Bind-Time Route Validation and Active-Policy Route Lock (CYS3-06) + +### Invariant + +`PolicyManager.bindPolicy` enforces that **every non-payout collateral token** backing a policy +has an enabled, non-zero `Swapper` quote route to the policy `payoutToken` at bind time. This +check runs before the USD-value sufficiency check so that collateral without a settlement path +can never count toward coverage backing. + +### How it works + +1. `PolicyManager.bindPolicy` calls `IClaimManager.validateAndLockCollateralRoutes(policyId, payoutToken, maturityTime, tokens, tokenStakes)` once per policy, immediately after fetching committee token stakes. +2. `ClaimManager.validateAndLockCollateralRoutes` (only callable by `policyManager`) iterates the token array: + - Tokens that equal `payoutToken` or have zero stake are skipped (no route needed). + - For each remaining token: `Swapper.quoteSwap(token, payoutToken, stake)` must return a non-zero value; any zero return or revert causes an immediate `MissingSwapRouteForCollateral` revert. + - After passing the quote check, `Swapper.lockRouteUntil(token, payoutToken, maturityTime)` locks the route. +3. `Swapper.lockRouteUntil` (only callable by `claimManager`) records the lock monotonically for both the `(tokenIn, tokenOut)` route key and the current swap target. The lock timestamp only moves forward — a later policy binding can extend a lock but never shorten it. + +### Lock enforcement + +While a route lock is active (`block.timestamp < lockedUntil`): + +- `Swapper.setSwapRoute` rejects any **material** change: disabling the route or replacing its swap target reverts with `RouteLockedByActivePolicies`. Updating only `swapCalldata` (same target, same `enabled = true`) is still permitted so admins can patch DEX-side calldata. +- `Swapper.setSwapTargetWhitelist(target, false)` reverts with `TargetLockedByActivePolicies` while the target lock is active. + +Locks expire at `maturityTime`. Once the last dependent policy matures, swap managers can freely modify or disable the route. + +### Wiring requirement + +`Swapper.setClaimManager(claimManager)` must be called as part of the cross-system wiring +(step 9 in `WireCoreContracts.s.sol`) before any policy is bound. Without this, `lockRouteUntil` +will revert with `OnlyClaimManager` during the first `bindPolicy` call. + +--- + ## Swapper Configuration for Cross-Token Payouts When the vault collateral token differs from the policy `payoutToken`, a swap route must be diff --git a/script/WireCoreContracts.s.sol b/script/WireCoreContracts.s.sol index ed91466e..e38b63ac 100644 --- a/script/WireCoreContracts.s.sol +++ b/script/WireCoreContracts.s.sol @@ -37,6 +37,11 @@ interface IClaimManagerWire { function setPremiumManager(address premiumManager_) external; } +interface ISwapperWire { + function setClaimManager(address claimManager_) external; + function claimManager() external view returns (address); +} + interface IAccessControlGrant { function grantRole(bytes32 role, address account) external; function hasRole(bytes32 role, address account) external view returns (bool); @@ -50,7 +55,7 @@ interface IClaimManagerPremiumView { * @title WireCoreContracts * @notice Performs all cross-system wiring between Catalysis Core and Coverage contracts. * @dev Must be run after both Core and Coverage contracts are deployed. - * Executes eight calls that bridge the two systems: + * Executes nine calls that bridge the two systems: * * 1. StakeManager.setCoverPoolFactory — unblocks createCommittee() during requestCoverage() * 2. StakeManager.grantRole(POLICY_MANAGER_ROLE, policyManager) — unblocks requestCoverage() @@ -65,6 +70,9 @@ interface IClaimManagerPremiumView { * 8. SSPRouter.setStakeRecipient — sets the EigenLayer redistribution recipient so * slashed collateral flows to ClaimManager, not the * deployer; must run before any committee is created + * 9. Swapper.setClaimManager — authorises ClaimManager as the sole caller of + * lockRouteUntil(); must run before any policy is bound + * (CYS3-06 fix: bind-time route-availability check) * * Role requirements (all held by the ADMIN after Core and Coverage deploys): * - STAKE_MANAGER_CONFIG_ROLE on StakeManager (defaults to ADMIN) @@ -100,6 +108,7 @@ interface IClaimManagerPremiumView { * - CLAIM_MANAGER: ClaimManager proxy address * - PREMIUM_MANAGER: PremiumManager proxy address * - COVER_POOL_FACTORY: CoverPoolFactory proxy address + * - SWAPPER: Swapper proxy address * - PAYOUT_TOKEN: ERC-20 payout/premium token address (e.g. WETH); must already be an * approved premium token on PremiumManager (set via getInitialPremiumTokens() in Deploy.s.sol) * @@ -134,6 +143,7 @@ contract WireCoreContracts is Script { address public claimManager; address public premiumManager; address public coverPoolFactory; + address public swapper; address public payoutToken; event WiringCompleted( @@ -144,7 +154,8 @@ contract WireCoreContracts is Script { address coverPoolFactory, address policyManager, address claimManager, - address premiumManager + address premiumManager, + address swapper ); constructor() { @@ -158,6 +169,7 @@ contract WireCoreContracts is Script { claimManager = vm.envAddress("CLAIM_MANAGER"); premiumManager = vm.envAddress("PREMIUM_MANAGER"); coverPoolFactory = vm.envAddress("COVER_POOL_FACTORY"); + swapper = vm.envAddress("SWAPPER"); payoutToken = vm.envAddress("PAYOUT_TOKEN"); } @@ -176,37 +188,41 @@ contract WireCoreContracts is Script { console.log(" ClaimManager: ", claimManager); console.log(" PremiumManager: ", premiumManager); console.log(" CoverPoolFactory:", coverPoolFactory); + console.log(" Swapper: ", swapper); console.log(" PayoutToken: ", payoutToken); // All 6 calls require roles held by admin (DEFAULT_ADMIN_ROLE or config roles that default to ADMIN). // On testnet admin == deployer; on mainnet admin is the multisig — omit --broadcast and use Safe. vm.startBroadcast(admin); - console.log("\n[1/8] StakeManager.setCoverPoolFactory..."); + console.log("\n[1/9] StakeManager.setCoverPoolFactory..."); IStakeManagerWire(stakeManager).setCoverPoolFactory(coverPoolFactory); - console.log("[2/8] StakeManager.grantRole(POLICY_MANAGER_ROLE, policyManager)..."); + console.log("[2/9] StakeManager.grantRole(POLICY_MANAGER_ROLE, policyManager)..."); IStakeManagerWire(stakeManager).grantRole(POLICY_MANAGER_ROLE, policyManager); - console.log("[3/8] SlashingManager.setClaimManager..."); + console.log("[3/9] SlashingManager.setClaimManager..."); ISlashingManagerWire(slashingManager).setClaimManager(claimManager); - console.log("[4/8] RewardsManager.setPremiumManager..."); + console.log("[4/9] RewardsManager.setPremiumManager..."); IRewardsManagerWire(rewardsManager).setPremiumManager(premiumManager); - console.log("[5/8] PremiumManager.approveSpender(payoutToken, rewardsManager, max)..."); + console.log("[5/9] PremiumManager.approveSpender(payoutToken, rewardsManager, max)..."); IPremiumManagerWire(premiumManager).approveSpender(payoutToken, rewardsManager, type(uint256).max); - console.log("[6/8] ClaimManager.setPremiumManager..."); + console.log("[6/9] ClaimManager.setPremiumManager..."); IClaimManagerWire(claimManager).setPremiumManager(premiumManager); bytes32 claimManagerRoleOnPremium = IPremiumManagerWire(premiumManager).CLAIM_MANAGER_ROLE(); - console.log("[7/8] PremiumManager.grantRole(CLAIM_MANAGER_ROLE, claimManager)..."); + console.log("[7/9] PremiumManager.grantRole(CLAIM_MANAGER_ROLE, claimManager)..."); IAccessControlGrant(premiumManager).grantRole(claimManagerRoleOnPremium, claimManager); - console.log("[8/8] SSPRouter.setStakeRecipient(claimManager)..."); + console.log("[8/9] SSPRouter.setStakeRecipient(claimManager)..."); ISSPRouterWire(sspRouter).setStakeRecipient(claimManager); + console.log("[9/9] Swapper.setClaimManager(claimManager)..."); + ISwapperWire(swapper).setClaimManager(claimManager); + vm.stopBroadcast(); _verify(); @@ -220,7 +236,8 @@ contract WireCoreContracts is Script { coverPoolFactory, policyManager, claimManager, - premiumManager + premiumManager, + swapper ); } @@ -260,6 +277,10 @@ contract WireCoreContracts is Script { console.log("SSPRouter.stakeRecipient:", gotStakeRecipient); require(gotStakeRecipient == claimManager, "SSPRouter.stakeRecipient mismatch"); - console.log("[SUCCESS] All 8 wiring calls verified"); + address gotSwapperClaimMgr = ISwapperWire(swapper).claimManager(); + console.log("Swapper.claimManager:", gotSwapperClaimMgr); + require(gotSwapperClaimMgr == claimManager, "Swapper.claimManager mismatch"); + + console.log("[SUCCESS] All 9 wiring calls verified"); } } diff --git a/src/ClaimManager.sol b/src/ClaimManager.sol index 5a08b087..41278ee0 100644 --- a/src/ClaimManager.sol +++ b/src/ClaimManager.sol @@ -252,6 +252,40 @@ contract ClaimManager is return _claimApprovalRequired[policyId]; } + /** + * @inheritdoc IClaimManager + */ + function validateAndLockCollateralRoutes( + uint96 policyId, + address payoutToken, + uint256 maturityTime, + address[] calldata tokens, + uint256[] calldata tokenStakes + ) + external + override + { + require(msg.sender == policyManager, OnlyPolicyManager()); + + uint256 length = tokens.length; + for (uint256 i = 0; i < length; ++i) { + address token = tokens[i]; + if (token == address(0) || token == payoutToken || tokenStakes[i] == 0) { + continue; + } + + // slither-disable-next-line calls-loop + try ISwapper(swapper).quoteSwap(token, payoutToken, tokenStakes[i]) returns (uint256 quoted) { + if (quoted == 0) revert MissingSwapRouteForCollateral(token, payoutToken); + } catch { + revert MissingSwapRouteForCollateral(token, payoutToken); + } + + // slither-disable-next-line calls-loop + ISwapper(swapper).lockRouteUntil(token, payoutToken, maturityTime); + } + } + /** * @inheritdoc IClaimManager */ diff --git a/src/PolicyManager.sol b/src/PolicyManager.sol index 4e080901..e7448a98 100644 --- a/src/PolicyManager.sol +++ b/src/PolicyManager.sol @@ -12,6 +12,7 @@ import {ICoverPoolFactory} from "./interfaces/ICoverPoolFactory.sol"; import {IStakeManager} from "./interfaces/IStakeManager.sol"; import {IBindPolicyHook} from "./interfaces/IBindPolicyHook.sol"; import {IOraclePriceFeed} from "./interfaces/IOraclePriceFeed.sol"; +import {IClaimManager} from "./interfaces/IClaimManager.sol"; /** * @title PolicyManager @@ -222,6 +223,9 @@ contract PolicyManager is /** * @inheritdoc IPolicyManager + * @dev Route availability is validated via `ClaimManager.validateAndLockCollateralRoutes` before + * the USD-value sufficiency check, ensuring that no collateral token counts as backing unless + * it also has an enabled swap route to the policy payout token (CYS3-06 fix). */ function bindPolicy( BindPolicyRequest calldata request, @@ -258,13 +262,10 @@ contract PolicyManager is (, address[] memory tokens, uint256[] memory tokenStakes) = IStakeManager(stakeManager).getCommitteeTokenStakes(policyId); - uint256 totalStakeUSD = 0; - for (uint256 i = 0; i < tokens.length; ++i) { - if (tokenStakes[i] > 0) { - // slither-disable-next-line calls-loop - totalStakeUSD += IOraclePriceFeed(oraclePriceFeed).getUSDValue(tokens[i], tokenStakes[i]); - } - } + uint256 totalStakeUSD = _validateRoutesAndComputeStake( + policyId, request.payoutToken, uint32(block.timestamp) + draft.duration, tokens, tokenStakes + ); + uint256 coverageLimitUSD = IOraclePriceFeed(oraclePriceFeed).getUSDValue(request.payoutToken, boundPolicy.coverageLimit); require(coverageLimitUSD > 0, ZeroCoverageLimitUSD()); @@ -349,6 +350,31 @@ contract PolicyManager is */ function _authorizeUpgrade(address) internal view override onlyRole(DEFAULT_ADMIN_ROLE) {} + /** + * @notice Validates that every non-payout collateral token has an enabled swap route to + * `payoutToken` (via `ClaimManager.validateAndLockCollateralRoutes`), locks those routes + * until `maturityTime`, and returns the aggregate USD value of all staked collateral. + */ + function _validateRoutesAndComputeStake( + uint96 policyId, + address payoutToken, + uint32 maturityTime, + address[] memory tokens, + uint256[] memory tokenStakes + ) + private + returns (uint256 totalStakeUSD) + { + IClaimManager(claimManager) + .validateAndLockCollateralRoutes(policyId, payoutToken, maturityTime, tokens, tokenStakes); + for (uint256 i = 0; i < tokens.length; ++i) { + if (tokenStakes[i] > 0) { + // slither-disable-next-line calls-loop + totalStakeUSD += IOraclePriceFeed(oraclePriceFeed).getUSDValue(tokens[i], tokenStakes[i]); + } + } + } + /** * @notice Validates bind policy request data before processing. * @param request Bind policy request to validate. diff --git a/src/Swapper.sol b/src/Swapper.sol index 1dfcc2da..9956b050 100644 --- a/src/Swapper.sol +++ b/src/Swapper.sol @@ -49,6 +49,10 @@ contract Swapper is UUPSUpgradeable, AccessControlUpgradeable, PausableUpgradeab mapping(bytes32 routeKey => SwapRoute) private _swapRoutes; + address public override claimManager; + mapping(bytes32 routeKey => uint256 lockedUntil) private _routeLockedUntil; + mapping(address target => uint256 lockedUntil) private _targetLockedUntil; + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); @@ -113,6 +117,10 @@ contract Swapper is UUPSUpgradeable, AccessControlUpgradeable, PausableUpgradeab */ function setSwapTargetWhitelist(address target, bool whitelisted) external override onlyRole(DEFAULT_ADMIN_ROLE) { require(target != address(0), ZeroAddress()); + if (!whitelisted && whitelistedTargets[target]) { + uint256 lockExpiry = _targetLockedUntil[target]; + require(block.timestamp >= lockExpiry, TargetLockedByActivePolicies(target, lockExpiry)); + } whitelistedTargets[target] = whitelisted; emit SwapTargetWhitelistSet(target, whitelisted); } @@ -140,6 +148,54 @@ contract Swapper is UUPSUpgradeable, AccessControlUpgradeable, PausableUpgradeab } } + /** + * @inheritdoc ISwapper + */ + function setClaimManager(address claimManager_) external override onlyRole(DEFAULT_ADMIN_ROLE) { + require(claimManager_ != address(0), ZeroAddress()); + claimManager = claimManager_; + emit ClaimManagerSet(claimManager_); + } + + /** + * @inheritdoc ISwapper + */ + function lockRouteUntil(address tokenIn, address tokenOut, uint256 lockedUntil) external override { + require(msg.sender == claimManager, OnlyClaimManager()); + + address actualTokenIn = tokenIn == NATIVE_ETH ? nativeWrapper : tokenIn; + address actualTokenOut = tokenOut == NATIVE_ETH ? nativeWrapper : tokenOut; + + if (actualTokenIn == actualTokenOut) return; + + bytes32 routeKey = _getRouteKey(actualTokenIn, actualTokenOut); + SwapRoute storage route = _swapRoutes[routeKey]; + require(route.swapTarget != address(0), SwapRouteNotEnabled(tokenIn, tokenOut)); + + if (lockedUntil > _routeLockedUntil[routeKey]) { + _routeLockedUntil[routeKey] = lockedUntil; + } + if (lockedUntil > _targetLockedUntil[route.swapTarget]) { + _targetLockedUntil[route.swapTarget] = lockedUntil; + } + + emit RouteLocked(actualTokenIn, actualTokenOut, route.swapTarget, _routeLockedUntil[routeKey]); + } + + /** + * @inheritdoc ISwapper + */ + function routeLockedUntil(address tokenIn, address tokenOut) external view override returns (uint256) { + return _routeLockedUntil[_getRouteKey(tokenIn, tokenOut)]; + } + + /** + * @inheritdoc ISwapper + */ + function targetLockedUntil(address target) external view override returns (uint256) { + return _targetLockedUntil[target]; + } + /** * @inheritdoc ISwapper */ @@ -163,6 +219,13 @@ contract Swapper is UUPSUpgradeable, AccessControlUpgradeable, PausableUpgradeab bytes32 routeKey = _getRouteKey(tokenIn, tokenOut); SwapRoute storage route = _swapRoutes[routeKey]; + bool material = + route.swapTarget != address(0) && (route.swapTarget != swapTarget || (route.enabled && !enabled)); + if (material) { + uint256 lockExpiry = _routeLockedUntil[routeKey]; + require(block.timestamp >= lockExpiry, RouteLockedByActivePolicies(tokenIn, tokenOut, lockExpiry)); + } + route.tokenIn = tokenIn; route.tokenOut = tokenOut; route.swapTarget = swapTarget; diff --git a/src/interfaces/IClaimManager.sol b/src/interfaces/IClaimManager.sol index 9b91888f..705b6d91 100644 --- a/src/interfaces/IClaimManager.sol +++ b/src/interfaces/IClaimManager.sol @@ -371,6 +371,20 @@ interface IClaimManager is IAccessControl { */ error InvalidDeviationBps(uint16 bps); + /** + * @notice Reverts when a non-payout collateral token has no enabled, non-zero quote route to + * the policy payout token at bind time. + * @param tokenIn Collateral token address lacking a route. + * @param tokenOut Payout token address that cannot be reached. + */ + error MissingSwapRouteForCollateral(address tokenIn, address tokenOut); + + /** + * @notice Reverts when a caller other than the configured PolicyManager invokes a + * PolicyManager-only function. + */ + error OnlyPolicyManager(); + /** * @notice Reverts when previewSlashing returns arrays of different lengths. * @param vaultsLength Length of the vaults array. @@ -439,6 +453,29 @@ interface IClaimManager is IAccessControl { */ function setPremiumManager(address premiumManager_) external; + /** + * @notice Validates that every non-payout collateral token has an enabled, non-zero swap route to + * `payoutToken`, and locks each qualifying route (and its swap target) in the Swapper until + * `maturityTime`. + * @dev Only callable by the configured PolicyManager. Called inside `PolicyManager.bindPolicy` + * before the USD-value sufficiency check so that collateral lacking a settlement path can + * never contribute to backing. Reverts with `MissingSwapRouteForCollateral` if any + * non-payout, non-zero-stake token has no working quote route. + * @param policyId Identifier of the policy being bound (for event attribution). + * @param payoutToken ERC-20 token used for claim payouts. + * @param maturityTime Policy end timestamp; routes are locked until at least this timestamp. + * @param tokens Collateral token array from `getCommitteeTokenStakes`. + * @param tokenStakes Per-vault stake array aligned with `tokens`. + */ + function validateAndLockCollateralRoutes( + uint96 policyId, + address payoutToken, + uint256 maturityTime, + address[] calldata tokens, + uint256[] calldata tokenStakes + ) + external; + /** * @notice Files and resolves a claim atomically in a single transaction. * @dev Caller must be the policy's designated claimer address. Combines filing and resolution into one atomic diff --git a/src/interfaces/ISwapper.sol b/src/interfaces/ISwapper.sol index 00021f60..bc471f93 100644 --- a/src/interfaces/ISwapper.sol +++ b/src/interfaces/ISwapper.sol @@ -58,6 +58,21 @@ interface ISwapper is IAccessControl { address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut, address recipient ); + /** + * @notice Emitted when a swap route is locked by an active policy. + * @param tokenIn Source token address. + * @param tokenOut Destination token address. + * @param target Swap target address locked alongside the route. + * @param lockedUntil New lock expiry timestamp. + */ + event RouteLocked(address indexed tokenIn, address indexed tokenOut, address indexed target, uint256 lockedUntil); + + /** + * @notice Emitted when the ClaimManager dependency is set or updated. + * @param claimManager Address of the ClaimManager contract. + */ + event ClaimManagerSet(address indexed claimManager); + /** * @notice Emitted when a swap route is set or updated. * @param tokenIn Source token address. @@ -159,6 +174,26 @@ interface ISwapper is IAccessControl { */ error InvalidRouteToken(address token); + /** + * @notice Reverts when attempting to materially change a route that is locked by one or more active policies. + * @param tokenIn Source token address. + * @param tokenOut Destination token address. + * @param lockedUntil Timestamp until which the route is locked. + */ + error RouteLockedByActivePolicies(address tokenIn, address tokenOut, uint256 lockedUntil); + + /** + * @notice Reverts when attempting to de-whitelist a swap target that is still locked by active policies. + * @param target Swap target address. + * @param lockedUntil Timestamp until which the target is locked. + */ + error TargetLockedByActivePolicies(address target, uint256 lockedUntil); + + /** + * @notice Reverts when a caller other than the configured ClaimManager invokes a ClaimManager-only function. + */ + error OnlyClaimManager(); + /** * @notice Pauses swap operations. * @dev Requires DEFAULT_ADMIN_ROLE. @@ -260,6 +295,24 @@ interface ISwapper is IAccessControl { */ function executeSwap(SwapParams calldata params) external payable returns (uint256 amountOut); + /** + * @notice Sets the ClaimManager contract address. + * @dev Requires DEFAULT_ADMIN_ROLE. ClaimManager is the sole authorized caller of lockRouteUntil. + * @param claimManager_ Address of the ClaimManager contract. + */ + function setClaimManager(address claimManager_) external; + + /** + * @notice Locks a swap route and its current swap target until `lockedUntil`. + * @dev Only callable by the configured ClaimManager. Called once per bound policy for every + * non-payout collateral token. Pushes the lock timestamp strictly forward — never shrinks it. + * The tokenIn/tokenOut pair undergoes the same NATIVE_ETH → nativeWrapper resolution as quoteSwap. + * @param tokenIn Source token address (may be NATIVE_ETH constant). + * @param tokenOut Destination token address (may be NATIVE_ETH constant). + * @param lockedUntil Timestamp until which material route changes are prohibited. + */ + function lockRouteUntil(address tokenIn, address tokenOut, uint256 lockedUntil) external; + /** * @notice AccessControl identifier for the swap manager role. */ @@ -283,6 +336,26 @@ interface ISwapper is IAccessControl { */ function whitelistedTargets(address target) external view returns (bool whitelisted); + /** + * @notice Returns the configured ClaimManager address. + */ + function claimManager() external view returns (address); + + /** + * @notice Returns the timestamp until which material changes to a route are prohibited. + * @param tokenIn Source token address (resolved, not NATIVE_ETH constant). + * @param tokenOut Destination token address (resolved, not NATIVE_ETH constant). + * @return Timestamp (0 if never locked). + */ + function routeLockedUntil(address tokenIn, address tokenOut) external view returns (uint256); + + /** + * @notice Returns the timestamp until which a swap target may not be de-whitelisted. + * @param target Swap target address. + * @return Timestamp (0 if never locked). + */ + function targetLockedUntil(address target) external view returns (uint256); + /** * @notice Returns the swap route configuration for a token pair. * @param tokenIn Source token address. diff --git a/test/defi/helpers/ForkCoverageSetup.sol b/test/defi/helpers/ForkCoverageSetup.sol index 5eda1de6..f0bba570 100644 --- a/test/defi/helpers/ForkCoverageSetup.sol +++ b/test/defi/helpers/ForkCoverageSetup.sol @@ -233,6 +233,7 @@ abstract contract ForkCoverageSetup is Test { ); claimManager.setPremiumManager(address(premiumManager)); policyManager.setClaimManager(address(claimManager)); + swapper.setClaimManager(address(claimManager)); coverPoolImplementation = new CoverPool(); diff --git a/test/defi/mocks/Mocks.sol b/test/defi/mocks/Mocks.sol index 5353353d..0c1c027f 100644 --- a/test/defi/mocks/Mocks.sol +++ b/test/defi/mocks/Mocks.sol @@ -303,6 +303,15 @@ contract MockClaimManager { return _policyClaims[policyId]; } + function validateAndLockCollateralRoutes( + uint96, + address, + uint256, + address[] calldata, + uint256[] calldata + ) + external {} + function fileClaim( uint96 policyId, uint256, /* claimAmount */ diff --git a/test/unit/ClaimManager.t.sol b/test/unit/ClaimManager.t.sol index 08f6c6d7..44276794 100644 --- a/test/unit/ClaimManager.t.sol +++ b/test/unit/ClaimManager.t.sol @@ -4022,6 +4022,120 @@ contract ClaimManagerTest is Test { assertEq(claimManager.policyManager(), policy); } + /*////////////////////////////////////////////////////////////// + VALIDATE AND LOCK COLLATERAL ROUTES + //////////////////////////////////////////////////////////////*/ + + function test_validateAndLockCollateralRoutes_RevertsForNonPolicyManagerCaller() public { + address[] memory tokens = new address[](1); + uint256[] memory stakes = new uint256[](1); + tokens[0] = collateralToken; + stakes[0] = 100 ether; + + vm.expectRevert(IClaimManager.OnlyPolicyManager.selector); + claimManager.validateAndLockCollateralRoutes(POLICY_ID, payoutToken, block.timestamp + 30 days, tokens, stakes); + } + + function test_validateAndLockCollateralRoutes_RevertsWhenRouteReturnsZero() public { + MockSwapperWithZeroQuote zeroSwapper = new MockSwapperWithZeroQuote(); + vm.prank(admin); + claimManager.setSwapper(address(zeroSwapper)); + + address[] memory tokens = new address[](1); + uint256[] memory stakes = new uint256[](1); + tokens[0] = collateralToken; + stakes[0] = 100 ether; + + vm.prank(policy); + vm.expectRevert( + abi.encodeWithSelector(IClaimManager.MissingSwapRouteForCollateral.selector, collateralToken, payoutToken) + ); + claimManager.validateAndLockCollateralRoutes(POLICY_ID, payoutToken, block.timestamp + 30 days, tokens, stakes); + } + + function test_validateAndLockCollateralRoutes_RevertsWhenRouteReverts() public { + MockSwapperWithRevertingQuote revertSwapper = new MockSwapperWithRevertingQuote(); + vm.prank(admin); + claimManager.setSwapper(address(revertSwapper)); + + address[] memory tokens = new address[](1); + uint256[] memory stakes = new uint256[](1); + tokens[0] = collateralToken; + stakes[0] = 100 ether; + + vm.prank(policy); + vm.expectRevert( + abi.encodeWithSelector(IClaimManager.MissingSwapRouteForCollateral.selector, collateralToken, payoutToken) + ); + claimManager.validateAndLockCollateralRoutes(POLICY_ID, payoutToken, block.timestamp + 30 days, tokens, stakes); + } + + function test_validateAndLockCollateralRoutes_SkipsPayoutToken() public { + MockSwapperWithZeroQuote zeroSwapper = new MockSwapperWithZeroQuote(); + vm.prank(admin); + claimManager.setSwapper(address(zeroSwapper)); + + address[] memory tokens = new address[](1); + uint256[] memory stakes = new uint256[](1); + tokens[0] = payoutToken; + stakes[0] = 100 ether; + + vm.prank(policy); + claimManager.validateAndLockCollateralRoutes(POLICY_ID, payoutToken, block.timestamp + 30 days, tokens, stakes); + } + + function test_validateAndLockCollateralRoutes_SkipsZeroStake() public { + MockSwapperWithZeroQuote zeroSwapper = new MockSwapperWithZeroQuote(); + vm.prank(admin); + claimManager.setSwapper(address(zeroSwapper)); + + address[] memory tokens = new address[](1); + uint256[] memory stakes = new uint256[](1); + tokens[0] = collateralToken; + stakes[0] = 0; + + vm.prank(policy); + claimManager.validateAndLockCollateralRoutes(POLICY_ID, payoutToken, block.timestamp + 30 days, tokens, stakes); + } + + function test_validateAndLockCollateralRoutes_LocksRouteForMaturity() public { + MockSwapperWithLockTracking lockSwapper = new MockSwapperWithLockTracking(); + vm.prank(admin); + claimManager.setSwapper(address(lockSwapper)); + + address[] memory tokens = new address[](1); + uint256[] memory stakes = new uint256[](1); + tokens[0] = collateralToken; + stakes[0] = 100 ether; + + uint256 maturity = block.timestamp + 60 days; + vm.prank(policy); + claimManager.validateAndLockCollateralRoutes(POLICY_ID, payoutToken, maturity, tokens, stakes); + + assertEq(lockSwapper.lockedUntilFor(collateralToken, payoutToken), maturity, "lock not set"); + } + + function test_validateAndLockCollateralRoutes_LockTimestampIsMonotonic() public { + MockSwapperWithLockTracking lockSwapper = new MockSwapperWithLockTracking(); + vm.prank(admin); + claimManager.setSwapper(address(lockSwapper)); + + address[] memory tokens = new address[](1); + uint256[] memory stakes = new uint256[](1); + tokens[0] = collateralToken; + stakes[0] = 100 ether; + + uint256 maturity1 = block.timestamp + 60 days; + uint256 maturity2 = block.timestamp + 30 days; + + vm.startPrank(policy); + claimManager.validateAndLockCollateralRoutes(POLICY_ID, payoutToken, maturity1, tokens, stakes); + claimManager.validateAndLockCollateralRoutes(POLICY_ID, payoutToken, maturity2, tokens, stakes); + vm.stopPrank(); + + assertEq(lockSwapper.lockedUntilFor(collateralToken, payoutToken), maturity1, "lock should not shrink"); + } + /*////////////////////////////////////////////////////////////// HELPERS //////////////////////////////////////////////////////////////*/ @@ -4614,6 +4728,55 @@ contract MockPremiumManagerReverting { } } +/// @dev Swapper mock that always returns 0 from quoteSwap (simulates missing route). +contract MockSwapperWithZeroQuote { + function quoteSwap(address, address, uint256) external pure returns (uint256) { + return 0; + } + + function lockRouteUntil(address, address, uint256) external {} + + function executeSwap(ISwapper.SwapParams calldata) external payable returns (uint256) { + return 0; + } +} + +/// @dev Swapper mock whose quoteSwap always reverts (simulates an adapter that throws). +contract MockSwapperWithRevertingQuote { + function quoteSwap(address, address, uint256) external pure returns (uint256) { + revert("MockSwapperWithRevertingQuote: always reverts"); + } + + function lockRouteUntil(address, address, uint256) external {} + + function executeSwap(ISwapper.SwapParams calldata) external payable returns (uint256) { + return 0; + } +} + +/// @dev Swapper mock that returns amountIn and tracks lockRouteUntil calls. +contract MockSwapperWithLockTracking { + mapping(address => mapping(address => uint256)) private _lockedUntil; + + function quoteSwap(address, address, uint256 amountIn) external pure returns (uint256) { + return amountIn; + } + + function lockRouteUntil(address tokenIn, address tokenOut, uint256 lockedUntil) external { + if (lockedUntil > _lockedUntil[tokenIn][tokenOut]) { + _lockedUntil[tokenIn][tokenOut] = lockedUntil; + } + } + + function lockedUntilFor(address tokenIn, address tokenOut) external view returns (uint256) { + return _lockedUntil[tokenIn][tokenOut]; + } + + function executeSwap(ISwapper.SwapParams calldata) external payable returns (uint256) { + return 0; + } +} + contract MockSwapperWithPerTokenDivisor { address private constant _NATIVE_ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; diff --git a/test/unit/PolicyManager.t.sol b/test/unit/PolicyManager.t.sol index 85b3caeb..7c437fb1 100644 --- a/test/unit/PolicyManager.t.sol +++ b/test/unit/PolicyManager.t.sol @@ -10,6 +10,7 @@ import {EfficientHashLib} from "@solady/utils/EfficientHashLib.sol"; import {Test} from "forge-std/Test.sol"; import {PolicyManager} from "../../src/PolicyManager.sol"; import {IPolicyManager} from "../../src/interfaces/IPolicyManager.sol"; +import {IClaimManager} from "../../src/interfaces/IClaimManager.sol"; import {ICoverPool} from "../../src/interfaces/ICoverPool.sol"; import {IBindPolicyHook} from "../../src/interfaces/IBindPolicyHook.sol"; @@ -1789,6 +1790,68 @@ contract PolicyManagerTest is Test { assertFalse(metadata.premiumDefaulted); } + /*////////////////////////////////////////////////////////////// + BIND POLICY — ROUTE VALIDATION (CYS3-06) + //////////////////////////////////////////////////////////////*/ + + function test_bindPolicy_CallsValidateAndLockCollateralRoutes() public { + IPolicyManager.BindPolicyRequest memory request = _defaultRequest(); + _seedCommitteeForTesting(request.quote.policyId, curator, true); + ICoverPool.BoundPolicy memory boundPolicy = _getBoundPolicy(request); + + uint256 callsBefore = mockClaimManager.validateCallCount(); + vm.prank(address(coverPool)); + policyManager.bindPolicy(request, boundPolicy); + + assertEq(mockClaimManager.validateCallCount(), callsBefore + 1, "validateAndLockCollateralRoutes not called"); + } + + function test_bindPolicy_PassesCorrectMaturityTimeToValidator() public { + IPolicyManager.BindPolicyRequest memory request = _defaultRequest(); + _seedCommitteeForTesting(request.quote.policyId, curator, true); + ICoverPool.BoundPolicy memory boundPolicy = _getBoundPolicy(request); + + uint256 expectedMaturity = block.timestamp + COVERAGE_DURATION; + + vm.prank(address(coverPool)); + policyManager.bindPolicy(request, boundPolicy); + + assertEq(mockClaimManager.lastMaturityTime(), expectedMaturity, "maturityTime mismatch"); + } + + function test_bindPolicy_MaturityTimeConsistentBetweenValidatorAndMetadata() public { + IPolicyManager.BindPolicyRequest memory request = _defaultRequest(); + _seedCommitteeForTesting(request.quote.policyId, curator, true); + ICoverPool.BoundPolicy memory boundPolicy = _getBoundPolicy(request); + + vm.prank(address(coverPool)); + policyManager.bindPolicy(request, boundPolicy); + + IPolicyManager.PolicyMetadata memory metadata = policyManager.policyMetadata(request.policyId); + assertEq( + mockClaimManager.lastMaturityTime(), + metadata.maturityTime, + "validator maturityTime and stored maturityTime diverge" + ); + } + + function test_bindPolicy_Reverts_WhenValidateRoutesFails() public { + IPolicyManager.BindPolicyRequest memory request = _defaultRequest(); + _seedCommitteeForTesting(request.quote.policyId, curator, true); + ICoverPool.BoundPolicy memory boundPolicy = _getBoundPolicy(request); + + address fakeToken = makeAddr("fakeCollateral"); + bytes memory encodedRevert = + abi.encodeWithSelector(IClaimManager.MissingSwapRouteForCollateral.selector, fakeToken, payoutToken); + mockClaimManager.setValidationRevert(true, encodedRevert); + + vm.prank(address(coverPool)); + vm.expectRevert( + abi.encodeWithSelector(IClaimManager.MissingSwapRouteForCollateral.selector, fakeToken, payoutToken) + ); + policyManager.bindPolicy(request, boundPolicy); + } + /*////////////////////////////////////////////////////////////// HELPERS //////////////////////////////////////////////////////////////*/ @@ -2014,7 +2077,36 @@ contract MockStakeManager { } } -contract MockClaimManager {} +contract MockClaimManager { + bool public shouldRevertValidation; + bytes public revertReason; + uint256 public validateCallCount; + uint256 public lastMaturityTime; + + function setValidationRevert(bool shouldRevert_, bytes calldata reason) external { + shouldRevertValidation = shouldRevert_; + revertReason = reason; + } + + function validateAndLockCollateralRoutes( + uint96, + address, + uint256 maturityTime, + address[] calldata, + uint256[] calldata + ) + external + { + validateCallCount++; + lastMaturityTime = maturityTime; + if (shouldRevertValidation) { + bytes memory reason = revertReason; + assembly { + revert(add(reason, 32), mload(reason)) + } + } + } +} contract MockCoverPoolFactory { mapping(address => bool) private _registeredPools; diff --git a/test/unit/Swapper.t.sol b/test/unit/Swapper.t.sol index da00c168..ad7ad9ea 100644 --- a/test/unit/Swapper.t.sol +++ b/test/unit/Swapper.t.sol @@ -29,6 +29,8 @@ contract SwapperTest is Test { event SwapRouteSet(address indexed tokenIn, address indexed tokenOut, address swapTarget, bool enabled); event SwapTargetWhitelistSet(address indexed target, bool whitelisted); event NativeWrapperSet(address indexed wrapper); + event RouteLocked(address indexed tokenIn, address indexed tokenOut, address indexed target, uint256 lockedUntil); + event ClaimManagerSet(address indexed claimManager); /*////////////////////////////////////////////////////////////// SETUP @@ -1269,6 +1271,241 @@ contract SwapperTest is Test { assertTrue(swapper.hasRole(swapper.SWAP_EXECUTOR_ROLE(), swapExecutor), "SwapExecutor role should be preserved"); } + /*////////////////////////////////////////////////////////////// + SET CLAIM MANAGER + //////////////////////////////////////////////////////////////*/ + + function test_setClaimManager_WhenCallerNotAdmin_Reverts() public { + address cm = makeAddr("claimManager"); + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, address(this), swapper.DEFAULT_ADMIN_ROLE() + ) + ); + swapper.setClaimManager(cm); + } + + function test_setClaimManager_WhenZeroAddress_Reverts() public { + vm.prank(admin); + vm.expectRevert(ISwapper.ZeroAddress.selector); + swapper.setClaimManager(address(0)); + } + + function test_setClaimManager_WhenValid_StoresAndEmits() public { + address cm = makeAddr("claimManager"); + vm.prank(admin); + vm.expectEmit(true, false, false, false); + emit ClaimManagerSet(cm); + swapper.setClaimManager(cm); + assertEq(swapper.claimManager(), cm, "claimManager mismatch"); + } + + /*////////////////////////////////////////////////////////////// + LOCK ROUTE UNTIL + //////////////////////////////////////////////////////////////*/ + + function test_lockRouteUntil_WhenCallerNotClaimManager_Reverts() public { + _setupRoute(); + vm.expectRevert(ISwapper.OnlyClaimManager.selector); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), block.timestamp + 1 days); + } + + function test_lockRouteUntil_WhenRouteNotConfigured_Reverts() public { + address cm = _setupClaimManager(); + MockToken other = new MockToken("Other", "OTH"); + vm.prank(cm); + vm.expectRevert( + abi.encodeWithSelector(ISwapper.SwapRouteNotEnabled.selector, address(other), address(tokenOut)) + ); + swapper.lockRouteUntil(address(other), address(tokenOut), block.timestamp + 1 days); + } + + function test_lockRouteUntil_SetsLockTimestamp() public { + _setupRoute(); + address cm = _setupClaimManager(); + uint256 lockExpiry = block.timestamp + 30 days; + + vm.prank(cm); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), lockExpiry); + + assertEq(swapper.routeLockedUntil(address(tokenIn), address(tokenOut)), lockExpiry, "route lock mismatch"); + assertEq(swapper.targetLockedUntil(dexTarget), lockExpiry, "target lock mismatch"); + } + + function test_lockRouteUntil_OnlyPushesForward() public { + _setupRoute(); + address cm = _setupClaimManager(); + uint256 firstExpiry = block.timestamp + 60 days; + uint256 earlierExpiry = block.timestamp + 10 days; + + vm.startPrank(cm); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), firstExpiry); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), earlierExpiry); + vm.stopPrank(); + + assertEq(swapper.routeLockedUntil(address(tokenIn), address(tokenOut)), firstExpiry, "lock should not shrink"); + } + + function test_lockRouteUntil_MultiplePoliciesPushLockForward() public { + _setupRoute(); + address cm = _setupClaimManager(); + uint256 expiry1 = block.timestamp + 30 days; + uint256 expiry2 = block.timestamp + 90 days; + + vm.startPrank(cm); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), expiry1); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), expiry2); + vm.stopPrank(); + + assertEq(swapper.routeLockedUntil(address(tokenIn), address(tokenOut)), expiry2, "lock should advance"); + } + + function test_lockRouteUntil_EmitsRouteLocked() public { + _setupRoute(); + address cm = _setupClaimManager(); + uint256 lockExpiry = block.timestamp + 30 days; + + vm.prank(cm); + vm.expectEmit(true, true, true, true); + emit RouteLocked(address(tokenIn), address(tokenOut), dexTarget, lockExpiry); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), lockExpiry); + } + + function test_lockRouteUntil_ResolvesNativeEth() public { + MockWETH mockWeth = new MockWETH(); + vm.startPrank(admin); + swapper.pause(); + swapper.setNativeWrapper(address(mockWeth)); + swapper.unpause(); + swapper.setSwapTargetWhitelist(dexTarget, true); + vm.stopPrank(); + vm.prank(swapManager); + swapper.setSwapRoute(address(mockWeth), address(tokenOut), dexTarget, true, ""); + + address cm = _setupClaimManager(); + uint256 lockExpiry = block.timestamp + 30 days; + // Cache NATIVE_ETH before vm.prank so the getter call does not consume the prank. + address nativeEth = swapper.NATIVE_ETH(); + + vm.prank(cm); + swapper.lockRouteUntil(nativeEth, address(tokenOut), lockExpiry); + + assertEq(swapper.routeLockedUntil(address(mockWeth), address(tokenOut)), lockExpiry, "WETH route not locked"); + } + + /*////////////////////////////////////////////////////////////// + SET SWAP ROUTE — LOCK GUARD + //////////////////////////////////////////////////////////////*/ + + function test_setSwapRoute_RevertsWhileLocked_WhenDisabling() public { + _setupRoute(); + address cm = _setupClaimManager(); + uint256 lockExpiry = block.timestamp + 30 days; + vm.prank(cm); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), lockExpiry); + + vm.prank(swapManager); + vm.expectRevert( + abi.encodeWithSelector( + ISwapper.RouteLockedByActivePolicies.selector, address(tokenIn), address(tokenOut), lockExpiry + ) + ); + swapper.setSwapRoute(address(tokenIn), address(tokenOut), dexTarget, false, ""); + } + + function test_setSwapRoute_RevertsWhileLocked_WhenChangingTarget() public { + _setupRoute(); + address cm = _setupClaimManager(); + uint256 lockExpiry = block.timestamp + 30 days; + vm.prank(cm); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), lockExpiry); + + address dexTarget2 = address(new MockDEX()); + vm.prank(admin); + swapper.setSwapTargetWhitelist(dexTarget2, true); + + vm.prank(swapManager); + vm.expectRevert( + abi.encodeWithSelector( + ISwapper.RouteLockedByActivePolicies.selector, address(tokenIn), address(tokenOut), lockExpiry + ) + ); + swapper.setSwapRoute(address(tokenIn), address(tokenOut), dexTarget2, true, ""); + } + + function test_setSwapRoute_AllowsCalldataOnlyUpdate_WhileLocked() public { + _setupRoute(); + address cm = _setupClaimManager(); + vm.prank(cm); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), block.timestamp + 30 days); + + bytes memory newCalldata = abi.encodeWithSelector(MockDEX.swap.selector, address(tokenIn), address(tokenOut)); + vm.prank(swapManager); + swapper.setSwapRoute(address(tokenIn), address(tokenOut), dexTarget, true, newCalldata); + + ISwapper.SwapRoute memory route = swapper.getSwapRoute(address(tokenIn), address(tokenOut)); + assertEq(route.swapCalldata, newCalldata, "calldata should be updated"); + assertEq(route.swapTarget, dexTarget, "target should be unchanged"); + } + + function test_setSwapRoute_AllowedAfterLockExpires() public { + _setupRoute(); + address cm = _setupClaimManager(); + uint256 lockExpiry = block.timestamp + 30 days; + vm.prank(cm); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), lockExpiry); + + vm.warp(lockExpiry + 1); + + vm.prank(swapManager); + swapper.setSwapRoute(address(tokenIn), address(tokenOut), dexTarget, false, ""); + + ISwapper.SwapRoute memory route = swapper.getSwapRoute(address(tokenIn), address(tokenOut)); + assertFalse(route.enabled, "route should be disabled after lock expires"); + } + + /*////////////////////////////////////////////////////////////// + SET SWAP TARGET WHITELIST + //////////////////////////////////////////////////////////////*/ + + function test_setSwapTargetWhitelist_RevertsWhileTargetLocked() public { + _setupRoute(); + address cm = _setupClaimManager(); + uint256 lockExpiry = block.timestamp + 30 days; + vm.prank(cm); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), lockExpiry); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(ISwapper.TargetLockedByActivePolicies.selector, dexTarget, lockExpiry)); + swapper.setSwapTargetWhitelist(dexTarget, false); + } + + function test_setSwapTargetWhitelist_AllowedAfterTargetLockExpires() public { + _setupRoute(); + address cm = _setupClaimManager(); + uint256 lockExpiry = block.timestamp + 30 days; + vm.prank(cm); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), lockExpiry); + + vm.warp(lockExpiry + 1); + + vm.prank(admin); + swapper.setSwapTargetWhitelist(dexTarget, false); + assertFalse(swapper.whitelistedTargets(dexTarget), "target should be de-whitelisted"); + } + + function test_setSwapTargetWhitelist_AllowsWhitelistingWhileLocked() public { + _setupRoute(); + address cm = _setupClaimManager(); + vm.prank(cm); + swapper.lockRouteUntil(address(tokenIn), address(tokenOut), block.timestamp + 30 days); + + address newTarget = address(new MockDEX()); + vm.prank(admin); + swapper.setSwapTargetWhitelist(newTarget, true); + assertTrue(swapper.whitelistedTargets(newTarget), "new target should be whitelisted"); + } + /*////////////////////////////////////////////////////////////// GET SWAP ROUTE //////////////////////////////////////////////////////////////*/ @@ -1298,6 +1535,12 @@ contract SwapperTest is Test { swapper.setSwapRoute(address(tokenIn), address(tokenOut), dexTarget, true, storedCalldata); } + function _setupClaimManager() internal returns (address cm) { + cm = makeAddr("claimManager"); + vm.prank(admin); + swapper.setClaimManager(cm); + } + function _createSwapParams( uint256 amountIn, uint256 amountOutMin