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
35 changes: 35 additions & 0 deletions docs/architecture/06-premiums-claims.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 33 additions & 12 deletions script/WireCoreContracts.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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)
*
Expand Down Expand Up @@ -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(
Expand All @@ -144,7 +154,8 @@ contract WireCoreContracts is Script {
address coverPoolFactory,
address policyManager,
address claimManager,
address premiumManager
address premiumManager,
address swapper
);

constructor() {
Expand All @@ -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");
}

Expand All @@ -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();
Expand All @@ -220,7 +236,8 @@ contract WireCoreContracts is Script {
coverPoolFactory,
policyManager,
claimManager,
premiumManager
premiumManager,
swapper
);
}

Expand Down Expand Up @@ -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");
}
}
34 changes: 34 additions & 0 deletions src/ClaimManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
40 changes: 33 additions & 7 deletions src/PolicyManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why you moved total stake calculation inside this function?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stake computation was moved inside precisely to enforce the invariant from the audit: collateral should only count toward backing if it can actually be used for settlement. If the stake loop lived separately in bindPolicy(), we would have a logical gap where totalStakeUSD gets computed unconditionally for all tokens, but route validation could revert later.

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.
Expand Down
63 changes: 63 additions & 0 deletions src/Swapper.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +52 to +54

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these change will not collide with the existing storage variables, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we will just extend the storage, not replace it.


/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
*/
Expand All @@ -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;
Expand Down
Loading
Loading