diff --git a/test/unit/B20Security/redeem/redeemWithMemo_revertOrder.t.sol b/test/unit/B20Security/redeem/redeemWithMemo_revertOrder.t.sol new file mode 100644 index 00000000..3005f696 --- /dev/null +++ b/test/unit/B20Security/redeem/redeemWithMemo_revertOrder.t.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IB20} from "src/interfaces/IB20.sol"; +import {IB20Security} from "src/interfaces/IB20Security.sol"; + +import {B20Constants} from "src/lib/B20Constants.sol"; + +import {B20SecurityTest} from "test/lib/B20SecurityTest.sol"; +import {PolicyRegistryConstants} from "test/lib/mocks/MockPolicyRegistry.sol"; + +/// @title Sequential revert-order test for `redeemWithMemo` (security variant). +/// +/// @notice **Canonical order (Solidity reference `_redeemBurn`, shared with `redeem`):** +/// 1. PAUSE (`_isPaused(REDEEM)`) → `ContractPaused` +/// 2. POLICY (`isAuthorized(REDEEMSenderPolicyId, msg.sender)`) → `PolicyForbids` +/// 3. BELOW-MIN (`shares == 0 || shares < minimum`) → `BelowMinimumRedeemable` +/// 4. BALANCE (`fromBalance < amount` in `_burnRaw`) → `InsufficientBalance` +/// +/// A single test activates all four conditions at once and fixes them from +/// highest to lowest priority, asserting each revert in turn then completing +/// a successful call once all conditions are resolved. +contract B20SecurityRedeemWithMemoRevertOrderTest is B20SecurityTest { + /// @dev Unpauses a single feature via the `unpauser` actor, lazily granting + /// `UNPAUSE_ROLE` on first call. Mirrors the inherited `_pause` helper. + function _unpause(IB20.PausableFeature feature) private { + if (!token.hasRole(B20Constants.UNPAUSE_ROLE, unpauser)) _grantRole(B20Constants.UNPAUSE_ROLE, unpauser); + vm.prank(unpauser); + token.unpause(_singleFeature(feature)); + } + + function test_redeemWithMemo_revertOrder(uint256 amount, uint256 minimum, bytes32 memo) public { + amount = bound(amount, 1, type(uint64).max); + minimum = bound(minimum, amount + 1, type(uint256).max); + + // Setup: activate ALL revert conditions simultaneously. + // REDEEM is paused, the sender policy blocks all callers, + // minimumRedeemable > amount (so shares == amount < minimum with the + // default 1:1 WAD ratio), and alice has zero balance. + _pause(IB20.PausableFeature.REDEEM); + _setRedeemPolicy(PolicyRegistryConstants.ALWAYS_BLOCK_ID); + _updateMinimumRedeemable(minimum); + // alice balance is zero by default. + + // 1. PAUSE fires first. + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IB20.ContractPaused.selector, IB20.PausableFeature.REDEEM)); + security().redeemWithMemo(amount, memo); + + // Fix: unpause REDEEM. + _unpause(IB20.PausableFeature.REDEEM); + + // 2. POLICY fires next (pause fixed). + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector( + IB20.PolicyForbids.selector, security().REDEEM_SENDER_POLICY(), PolicyRegistryConstants.ALWAYS_BLOCK_ID + ) + ); + security().redeemWithMemo(amount, memo); + + // Fix: allow alice through the redeem-sender policy. + _setRedeemPolicy(PolicyRegistryConstants.ALWAYS_ALLOW_ID); + + // 3. BELOW-MIN fires next (pause and policy fixed). + // With the default 1:1 WAD ratio shares == amount, and amount < minimum. + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IB20Security.BelowMinimumRedeemable.selector, amount, minimum)); + security().redeemWithMemo(amount, memo); + + // Fix: clear the minimum floor so shares == amount satisfies the check. + _updateMinimumRedeemable(0); + + // 4. BALANCE fires next (pause, policy, and minimum fixed). + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IB20.InsufficientBalance.selector, alice, 0, amount)); + security().redeemWithMemo(amount, memo); + + // Fix: give alice exactly enough balance to cover the redemption. + _mint(alice, amount); + + // Success: all conditions resolved. + vm.prank(alice); + security().redeemWithMemo(amount, memo); + } +} diff --git a/test/unit/B20Security/redeem/updateMinimumRedeemable_revertOrder.t.sol b/test/unit/B20Security/redeem/updateMinimumRedeemable_revertOrder.t.sol new file mode 100644 index 00000000..6142b2d7 --- /dev/null +++ b/test/unit/B20Security/redeem/updateMinimumRedeemable_revertOrder.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IB20} from "src/interfaces/IB20.sol"; + +import {B20Constants} from "src/lib/B20Constants.sol"; + +import {B20SecurityTest} from "test/lib/B20SecurityTest.sol"; + +/// @title Sequential revert-order test for `updateMinimumRedeemable`. +/// +/// @notice **Canonical order (Solidity reference):** +/// 1. ROLE (`onlyRole(DEFAULT_ADMIN_ROLE)` modifier) → `AccessControlUnauthorizedAccount` +/// +/// A single test verifies the guard fires for an unauthorized caller and then +/// confirms the call succeeds once the role is granted. +contract B20SecurityUpdateMinimumRedeemableRevertOrderTest is B20SecurityTest { + function test_updateMinimumRedeemable_revertOrder(address caller, uint256 newMinimum) public { + // Exclude precompiles (which can distort msg.sender) and admin (who already + // holds DEFAULT_ADMIN_ROLE, which would make the initial call succeed). + _assumeValidCaller(caller); + vm.assume(caller != admin); + + // 1. ROLE fires. + vm.prank(caller); + vm.expectRevert( + abi.encodeWithSelector( + IB20.AccessControlUnauthorizedAccount.selector, caller, B20Constants.DEFAULT_ADMIN_ROLE + ) + ); + security().updateMinimumRedeemable(newMinimum); + + // Fix: grant DEFAULT_ADMIN_ROLE to caller. + _grantRole(B20Constants.DEFAULT_ADMIN_ROLE, caller); + + // Success: all conditions resolved. + vm.prank(caller); + security().updateMinimumRedeemable(newMinimum); + } +} diff --git a/test/unit/B20Security/shareRatio/updateShareRatio_revertOrder.t.sol b/test/unit/B20Security/shareRatio/updateShareRatio_revertOrder.t.sol new file mode 100644 index 00000000..080ab0f2 --- /dev/null +++ b/test/unit/B20Security/shareRatio/updateShareRatio_revertOrder.t.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IB20} from "src/interfaces/IB20.sol"; + +import {B20SecurityTest} from "test/lib/B20SecurityTest.sol"; + +/// @title Sequential revert-order test for `updateShareRatio`. +/// +/// @notice **Canonical order (Solidity reference):** +/// 1. ROLE (`onlyRole(SECURITY_OPERATOR_ROLE)` modifier) → `AccessControlUnauthorizedAccount` +/// +/// A single test verifies the guard fires for an unauthorized caller and then +/// confirms the call succeeds once the role is granted. +contract B20SecurityUpdateShareRatioRevertOrderTest is B20SecurityTest { + function test_updateShareRatio_revertOrder(address caller, uint256 newRatio) public { + // Exclude precompiles (which can distort msg.sender) and admin (needed + // internally by _grantRole to approve the role grant). + _assumeValidCaller(caller); + vm.assume(caller != admin); + + // Resolve the role identifier before setting the prank; an external view + // call inside abi.encodeWithSelector would otherwise consume vm.prank + // before the state-changing call, sending it as address(this) instead. + bytes32 operatorRole = security().SECURITY_OPERATOR_ROLE(); + + // 1. ROLE fires. + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, operatorRole)); + security().updateShareRatio(newRatio); + + // Fix: grant SECURITY_OPERATOR_ROLE to caller. + _grantRole(operatorRole, caller); + + // Success: all conditions resolved. + vm.prank(caller); + security().updateShareRatio(newRatio); + } +}