Skip to content

Commit d35485d

Browse files
committed
fix: gate Midas bond config on the operator role (Cantina #45)
setBondConfig/removeBondConfig move from VAULT_MANAGER_ROLE to OPERATOR_ROLE. The bond is paid with borrowed assets before the full input is committed, so the fund cannot bound the bond leg against held balances: the recipient address is the only guardrail on bond payments, and it is now defined by the most-trusted role. An overstated redeem input can still consume shares as bond, but only towards an operator-vouched Midas recipient, recoverable off-band. Refs 3F-509.
1 parent f6bcfce commit d35485d

3 files changed

Lines changed: 47 additions & 33 deletions

File tree

‎src/funds/midas/MidasFund.sol‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ contract MidasFund is IMidasFund, OwnableRoles, Initializable {
574574
}
575575

576576
/// @inheritdoc IMidasFund
577-
function setBondConfig(BondConfig calldata bondConfig_) external override onlyOwnerOrRoles(VAULT_MANAGER_ROLE) {
577+
function setBondConfig(BondConfig calldata bondConfig_) external override onlyOwnerOrRoles(OPERATOR_ROLE) {
578578
if (bondConfig_.amount == 0 || bondConfig_.amount > MAX_BOND_AMOUNT || bondConfig_.recipient == address(0)) {
579579
revert LibFundsErrors.InvalidBondConfig();
580580
}
@@ -588,7 +588,7 @@ contract MidasFund is IMidasFund, OwnableRoles, Initializable {
588588
}
589589

590590
/// @inheritdoc IMidasFund
591-
function removeBondConfig() external override onlyOwnerOrRoles(VAULT_MANAGER_ROLE) {
591+
function removeBondConfig() external override onlyOwnerOrRoles(OPERATOR_ROLE) {
592592
MidasFundStorage storage $ = _midasFundStorage();
593593
_checkNoLiveOrder($);
594594

‎src/interfaces/funds/midas/IMidasFund.sol‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ interface IMidasFund is IFund {
254254
function setRedemptionVault(address redemptionVault_) external;
255255

256256
/// @notice Sets the bond configuration for the Repay-and-Redeem flow.
257-
/// @dev Can only be called by an account with the VAULT_MANAGER_ROLE or the owner, and only
257+
/// @dev Can only be called by an account with the OPERATOR_ROLE or the owner, and only
258258
/// while no order is live (internal state EMPTY or ENDED), so the bond terms are stable
259259
/// for an order's life.
260260
/// Reverts with InvalidBondConfig if the amount is zero or greater than MAX_BOND_AMOUNT
@@ -266,7 +266,7 @@ interface IMidasFund is IFund {
266266
function setBondConfig(BondConfig calldata bondConfig_) external;
267267

268268
/// @notice Removes the bond configuration (no bond payment required).
269-
/// @dev Can only be called by an account with the VAULT_MANAGER_ROLE or the owner, and only
269+
/// @dev Can only be called by an account with the OPERATOR_ROLE or the owner, and only
270270
/// while no order is live (internal state EMPTY or ENDED). Subsequent redeem orders
271271
/// still follow the two-leg bond flow, but their bond leg moves nothing; the
272272
/// unlockInstantRedeem() confirmation is required regardless.

‎test/funds/midas/MidasFund.t.sol‎

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2114,9 +2114,12 @@ contract MidasFundTest is Test {
21142114
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
21152115

21162116
function test_SetBondConfig_Success() public {
2117-
vm.prank(vaultManager);
2117+
vm.prank(owner);
2118+
fund.grantRoles(operator, OPERATOR_ROLE);
2119+
2120+
vm.prank(operator);
21182121
vm.expectEmit(true, true, true, true);
2119-
emit BondConfigUpdated(BOND_BPS, bondRecipient, vaultManager);
2122+
emit BondConfigUpdated(BOND_BPS, bondRecipient, operator);
21202123
fund.setBondConfig(BondConfig({amount: BOND_BPS, recipient: bondRecipient}));
21212124

21222125
BondConfig memory config = fund.bondConfig();
@@ -2130,7 +2133,7 @@ contract MidasFundTest is Test {
21302133
assertEq(fund.bondConfig().amount, BOND_BPS, "amount");
21312134
}
21322135

2133-
function test_SetBondConfig_OnlyOwnerOrVaultManager() public {
2136+
function test_SetBondConfig_OnlyOwnerOrOperator() public {
21342137
BondConfig memory config = BondConfig({amount: BOND_BPS, recipient: bondRecipient});
21352138

21362139
vm.prank(outsider);
@@ -2141,62 +2144,68 @@ contract MidasFundTest is Test {
21412144
vm.expectRevert(Unauthorized.selector);
21422145
fund.setBondConfig(config);
21432146

2144-
// The operator role does not include bond management
2145-
vm.prank(owner);
2146-
fund.grantRoles(operator, OPERATOR_ROLE);
2147-
vm.prank(operator);
2147+
// The vault manager role does not include bond management: the recipient is the only
2148+
// guardrail on bond payments, so it is defined by the most-trusted role.
2149+
vm.prank(vaultManager);
21482150
vm.expectRevert(Unauthorized.selector);
21492151
fund.setBondConfig(config);
21502152

21512153
// The payment role does not either
21522154
vm.prank(paymentOperator);
21532155
vm.expectRevert(Unauthorized.selector);
21542156
fund.setBondConfig(config);
2157+
2158+
// The operator can set
2159+
vm.prank(owner);
2160+
fund.grantRoles(operator, OPERATOR_ROLE);
2161+
vm.prank(operator);
2162+
fund.setBondConfig(config);
2163+
assertEq(fund.bondConfig().amount, BOND_BPS, "set by operator");
21552164
}
21562165

21572166
function test_SetBondConfig_RevertsInvalidAmount() public {
21582167
uint256 overCap = fund.MAX_BOND_AMOUNT() + 1;
21592168

2160-
vm.prank(vaultManager);
2169+
vm.prank(owner);
21612170
vm.expectRevert(LibFundsErrors.InvalidBondConfig.selector);
21622171
fund.setBondConfig(BondConfig({amount: overCap, recipient: bondRecipient}));
21632172

2164-
vm.prank(vaultManager);
2173+
vm.prank(owner);
21652174
vm.expectRevert(LibFundsErrors.InvalidBondConfig.selector);
21662175
fund.setBondConfig(BondConfig({amount: BPS, recipient: bondRecipient}));
21672176
}
21682177

21692178
function test_SetBondConfig_MaxAmountSucceeds() public {
21702179
uint256 maxBond = fund.MAX_BOND_AMOUNT();
21712180

2172-
vm.prank(vaultManager);
2181+
vm.prank(owner);
21732182
fund.setBondConfig(BondConfig({amount: maxBond, recipient: bondRecipient}));
21742183
assertEq(fund.bondConfig().amount, maxBond, "amount");
21752184
}
21762185

21772186
function test_SetBondConfig_RevertsZeroRecipient() public {
2178-
vm.prank(vaultManager);
2187+
vm.prank(owner);
21792188
vm.expectRevert(LibFundsErrors.InvalidBondConfig.selector);
21802189
fund.setBondConfig(BondConfig({amount: 1, recipient: address(0)}));
21812190
}
21822191

21832192
function test_SetBondConfig_RevertsZeroAmount() public {
21842193
// Disabling the bond flow goes through removeBondConfig, not a zero-amount set.
2185-
vm.prank(vaultManager);
2194+
vm.prank(owner);
21862195
vm.expectRevert(LibFundsErrors.InvalidBondConfig.selector);
21872196
fund.setBondConfig(BondConfig({amount: 0, recipient: bondRecipient}));
21882197

2189-
vm.prank(vaultManager);
2198+
vm.prank(owner);
21902199
vm.expectRevert(LibFundsErrors.InvalidBondConfig.selector);
21912200
fund.setBondConfig(BondConfig({amount: 0, recipient: address(0)}));
21922201
}
21932202

21942203
function test_RemoveBondConfig_Success() public {
21952204
_setBondConfig(BOND_BPS);
21962205

2197-
vm.prank(vaultManager);
2206+
vm.prank(owner);
21982207
vm.expectEmit(true, true, true, true);
2199-
emit BondConfigUpdated(0, address(0), vaultManager);
2208+
emit BondConfigUpdated(0, address(0), owner);
22002209
fund.removeBondConfig();
22012210

22022211
BondConfig memory config = fund.bondConfig();
@@ -2215,12 +2224,12 @@ contract MidasFundTest is Test {
22152224

22162225
function test_RemoveBondConfig_Idempotent() public {
22172226
// Removing an already-zero config succeeds.
2218-
vm.prank(vaultManager);
2227+
vm.prank(owner);
22192228
fund.removeBondConfig();
22202229
assertEq(fund.bondConfig().amount, 0, "still disabled");
22212230
}
22222231

2223-
function test_RemoveBondConfig_OnlyOwnerOrVaultManager() public {
2232+
function test_RemoveBondConfig_OnlyOwnerOrOperator() public {
22242233
_setBondConfig(BOND_BPS);
22252234

22262235
vm.prank(outsider);
@@ -2231,10 +2240,8 @@ contract MidasFundTest is Test {
22312240
vm.expectRevert(Unauthorized.selector);
22322241
fund.removeBondConfig();
22332242

2234-
// The operator role does not include bond management
2235-
vm.prank(owner);
2236-
fund.grantRoles(operator, OPERATOR_ROLE);
2237-
vm.prank(operator);
2243+
// The vault manager role does not include bond management
2244+
vm.prank(vaultManager);
22382245
vm.expectRevert(Unauthorized.selector);
22392246
fund.removeBondConfig();
22402247

@@ -2243,7 +2250,14 @@ contract MidasFundTest is Test {
22432250
vm.expectRevert(Unauthorized.selector);
22442251
fund.removeBondConfig();
22452252

2246-
// The owner can always remove
2253+
// The operator can remove
2254+
vm.prank(owner);
2255+
fund.grantRoles(operator, OPERATOR_ROLE);
2256+
vm.prank(operator);
2257+
fund.removeBondConfig();
2258+
assertEq(fund.bondConfig().amount, 0, "removed by operator");
2259+
2260+
// The owner can always remove (idempotent)
22472261
vm.prank(owner);
22482262
fund.removeBondConfig();
22492263
assertEq(fund.bondConfig().amount, 0, "removed by owner");
@@ -2254,12 +2268,12 @@ contract MidasFundTest is Test {
22542268
Order memory order = _depositOrder(ONE_USDC, ONE_MTOKEN);
22552269
fund.create(order);
22562270

2257-
vm.prank(vaultManager);
2271+
vm.prank(owner);
22582272
vm.expectRevert(abi.encodeWithSelector(LibFundsErrors.InvalidState.selector, State.ACCEPTED));
22592273
fund.removeBondConfig();
22602274

22612275
_commitDeposit(order);
2262-
vm.prank(vaultManager);
2276+
vm.prank(owner);
22632277
vm.expectRevert(abi.encodeWithSelector(LibFundsErrors.InvalidState.selector, State.PROCESSING));
22642278
fund.removeBondConfig();
22652279
}
@@ -2268,12 +2282,12 @@ contract MidasFundTest is Test {
22682282
Order memory order = _depositOrder(ONE_USDC, ONE_MTOKEN);
22692283
fund.create(order);
22702284

2271-
vm.prank(vaultManager);
2285+
vm.prank(owner);
22722286
vm.expectRevert(abi.encodeWithSelector(LibFundsErrors.InvalidState.selector, State.ACCEPTED));
22732287
fund.setBondConfig(BondConfig({amount: BOND_BPS, recipient: bondRecipient}));
22742288

22752289
_commitDeposit(order);
2276-
vm.prank(vaultManager);
2290+
vm.prank(owner);
22772291
vm.expectRevert(abi.encodeWithSelector(LibFundsErrors.InvalidState.selector, State.PROCESSING));
22782292
fund.setBondConfig(BondConfig({amount: BOND_BPS, recipient: bondRecipient}));
22792293
}
@@ -2782,7 +2796,7 @@ contract MidasFundTest is Test {
27822796

27832797
// ...then reissue the remaining shares as a fresh redeem (bond payment removed) through
27842798
// the usual two-phase flow.
2785-
vm.prank(vaultManager);
2799+
vm.prank(owner);
27862800
fund.removeBondConfig();
27872801

27882802
uint256 remainder = ONE_MTOKEN - ONE_MTOKEN * BOND_BPS / BPS;
@@ -2990,9 +3004,9 @@ contract MidasFundTest is Test {
29903004
newVault.setTokenConfig(address(usdc), address(redemptionAssetFeed), 0, type(uint256).max, true);
29913005
}
29923006

2993-
/// @dev Sets the bond config as the vault manager (bondRecipient as recipient).
3007+
/// @dev Sets the bond config as the owner (bondRecipient as recipient).
29943008
function _setBondConfig(uint256 amountBps) internal {
2995-
vm.prank(vaultManager);
3009+
vm.prank(owner);
29963010
fund.setBondConfig(BondConfig({amount: amountBps, recipient: bondRecipient}));
29973011
}
29983012

0 commit comments

Comments
 (0)