From c7e2ebe27bc744ce9ed4cf86dae746481be4ded3 Mon Sep 17 00:00:00 2001 From: katzman Date: Wed, 3 Jun 2026 15:08:02 -0700 Subject: [PATCH 1/4] fix(b20-mock): enforce MINT_RECEIVER_POLICY during bootstrap (BOP-274) Rust precompile enforces MINT_RECEIVER_POLICY unconditionally on every mint, including factory-originated mints in the bootstrap window. The Solidity mock previously bypassed the check whenever `_isPrivileged()` held, which let initCalls mint to non-authorized accounts. Drop the bypass so the mock matches Rust semantics. An unconfigured slot still reads as ALWAYS_ALLOW_ID, so default-deploy bootstrap flows are unaffected. --- test/lib/mocks/MockB20.sol | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 93f0240..50041cb 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -737,18 +737,25 @@ abstract contract MockB20 is IB20 { emit Transfer(from, to, amount); } - /// @dev Pure mechanics: policy (with bootstrap bypass) + supply cap - /// + effects. Pause, role, and the zero-receiver check are - /// enforced upstream by `mint` / `mintWithMemo`. The asset - /// variant's `batchMint` carries the same `whenNotPaused` + - /// `onlyRole` modifiers ONCE for the whole batch and validates - /// per-element receivers inline before invoking this helper. + /// @dev Pure mechanics: policy + supply cap + effects. Pause, role, + /// and the zero-receiver check are enforced upstream by `mint` + /// / `mintWithMemo`. The asset variant's `batchMint` carries + /// the same `whenNotPaused` + `onlyRole` modifiers ONCE for the + /// whole batch and validates per-element receivers inline + /// before invoking this helper. + /// + /// MINT_RECEIVER_POLICY is enforced unconditionally — including + /// for factory-originated mints during the bootstrap window — + /// matching the Rust precompile, which carves no `privileged` + /// exception for the receiver-policy check. An unconfigured + /// slot reads as `ALWAYS_ALLOW_ID`, so the default-deploy + /// bootstrap flow is unaffected; only initCalls that set a + /// restrictive `MINT_RECEIVER_POLICY` first AND then mint to a + /// non-authorized account see the (correct) revert. function _mint(address to, uint256 amount) internal { - if (!_isPrivileged()) { - uint64 mintReceiverPolicyId = MockB20Storage.layout().mintPolicyIds.receiver; - if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(mintReceiverPolicyId, to)) { - revert PolicyForbids(MINT_RECEIVER_POLICY, mintReceiverPolicyId); - } + uint64 mintReceiverPolicyId = MockB20Storage.layout().mintPolicyIds.receiver; + if (!IPolicyRegistry(POLICY_REGISTRY).isAuthorized(mintReceiverPolicyId, to)) { + revert PolicyForbids(MINT_RECEIVER_POLICY, mintReceiverPolicyId); } MockB20Storage.Layout storage $ = MockB20Storage.layout(); From b8e718dd44b81479ad2a246b528998a1d344a6c2 Mon Sep 17 00:00:00 2001 From: katzman Date: Wed, 3 Jun 2026 15:09:07 -0700 Subject: [PATCH 2/4] fix(b20-mock): enforce burnBlocked target-blocked check during bootstrap (BOP-274) Rust precompile enforces the "target account must be blocked under TRANSFER_SENDER_POLICY" guard unconditionally on burnBlocked. The Solidity mock previously bypassed the check whenever `_isPrivileged()` held. Drop the bypass so factory-originated burnBlocked calls during the bootstrap window also revert AccountNotBlocked unless the target is actually blocked, matching Rust. --- test/lib/mocks/MockB20.sol | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/test/lib/mocks/MockB20.sol b/test/lib/mocks/MockB20.sol index 50041cb..72e2e07 100644 --- a/test/lib/mocks/MockB20.sol +++ b/test/lib/mocks/MockB20.sol @@ -304,15 +304,16 @@ abstract contract MockB20 is IB20 { whenNotPaused(PausableFeature.BURN) onlyRole(BURN_BLOCKED_ROLE) { - if (!_isPrivileged()) { - // The point of burnBlocked is to seize from policy-blocked - // accounts. Read the transfer-sender policy ID out of the - // transfer-side packed slot and reject if the target is - // currently authorized. - uint64 senderPolicyId = MockB20Storage.layout().transferPolicyIds.sender; - if (IPolicyRegistry(POLICY_REGISTRY).isAuthorized(senderPolicyId, from)) { - revert AccountNotBlocked(from); - } + // The point of burnBlocked is to seize from policy-blocked + // accounts. Read the transfer-sender policy ID out of the + // transfer-side packed slot and reject if the target is + // currently authorized. Enforced unconditionally — including + // for factory-originated calls during the bootstrap window — + // matching the Rust precompile, which carves no `privileged` + // exception for this guard. + uint64 senderPolicyId = MockB20Storage.layout().transferPolicyIds.sender; + if (IPolicyRegistry(POLICY_REGISTRY).isAuthorized(senderPolicyId, from)) { + revert AccountNotBlocked(from); } _burnRaw(from, amount); emit BurnedBlocked(msg.sender, from, amount); From ad449788aaf7ba83ac37030b5c0ea07e2641a678 Mon Sep 17 00:00:00 2001 From: katzman Date: Wed, 3 Jun 2026 15:14:40 -0700 Subject: [PATCH 3/4] feat(b20-mock): add isAnnouncementActive view for parity with Rust (BOP-274) The Rust precompile exposes an is_announcement_active runtime flag that flips true for the lifetime of an announce(...) bracket and false at all other times. The Solidity mock did not surface the same view, leaving inner-call contracts no way to detect they were executing inside a disclosed corp action. Add the IB20Asset.isAnnouncementActive() interface entry and back it in MockB20Asset with an EIP-1153 transient bool set at the top of announce and cleared at the bottom. Transient storage auto-clears at tx end, so a revert anywhere in the bracket cannot leave the flag stuck true. --- src/interfaces/IB20Asset.sol | 14 +++++ test/lib/mocks/MockB20Asset.sol | 38 +++++++++++++ .../announcement/isAnnouncementActive.t.sol | 57 +++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 test/unit/B20Asset/announcement/isAnnouncementActive.t.sol diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index c8d4900..a502796 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -109,6 +109,20 @@ interface IB20Asset is IB20 { /// @return Whether `id` is used. function isAnnouncementIdUsed(string calldata id) external view returns (bool); + /// @notice Whether an announcement bracket is currently open on this token. True from the + /// start of `announce` through the dispatch of every entry in `internalCalls` and + /// until `EndAnnouncement` fires; false at all other times, including before the + /// first `announce` call and after the bracket closes. Resets per transaction: + /// a revert anywhere in the bracket leaves no observable side effect on this view. + /// + /// @dev Intended for inner-call contracts dispatched via `internalCalls` (or other + /// contracts they reach) to detect they are executing inside an announcement + /// bracket — useful for issuance, multiplier, and metadata flows whose policy + /// differs when run as part of a disclosed corp action vs. ad-hoc. + /// + /// @return Whether an announcement is currently active. + function isAnnouncementActive() external view returns (bool); + /*////////////////////////////////////////////////////////////// MULTIPLIER //////////////////////////////////////////////////////////////*/ diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 6970362..5fce83e 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -64,6 +64,28 @@ contract MockB20Asset is MockB20, IB20Asset { /// by this before dividing. uint256 public constant WAD_PRECISION = 1e18; + // ============================================================ + // ANNOUNCEMENT-ACTIVE FLAG + // ============================================================ + + /// @dev Per-transaction flag set true at the start of `announce` + /// and false at the end, surfaced via `isAnnouncementActive()`. + /// Lives in transient storage (EIP-1153) because the value is + /// meaningful only within the bracket's call frame and MUST + /// reset between transactions; transient storage also means + /// the slot is reclaimed automatically on a revert anywhere in + /// the bracket, so the flag never gets stuck `true`. + /// + /// Declared as a contract-level state variable (not an + /// ERC-7201 namespaced struct field) because transient storage + /// lives in its own opcode-distinct address space — slot + /// indices here can't collide with the regular-storage layout + /// written by the factory bootstrap. The Rust precompile + /// exposes the same value via a runtime context flag rather + /// than a storage slot, so there is no persistent layout the + /// Rust impl needs to mirror. + bool transient internal _announcementActive; + // ============================================================ // DECIMALS // ============================================================ @@ -97,6 +119,13 @@ contract MockB20Asset is MockB20, IB20Asset { // selector check were ever weakened. $.usedAnnouncementIds[id] = true; + // Open the bracket — flip the transient flag BEFORE emitting + // `Announcement` and before dispatching any inner call, so any + // contract reached transitively through `internalCalls` sees + // `isAnnouncementActive() == true` for the full lifetime of the + // bracket. + _announcementActive = true; + emit Announcement(msg.sender, id, description, uri); for (uint256 i = 0; i < internalCalls.length; i++) { @@ -106,12 +135,21 @@ contract MockB20Asset is MockB20, IB20Asset { } emit EndAnnouncement(id); + + // Close the bracket. A revert above leaves transient storage + // untouched at tx end (per EIP-1153), so an aborted bracket + // also resets the flag implicitly. + _announcementActive = false; } function isAnnouncementIdUsed(string calldata id) external view returns (bool) { return MockB20AssetStorage.layout().usedAnnouncementIds[id]; } + function isAnnouncementActive() external view returns (bool) { + return _announcementActive; + } + // ============================================================ // MULTIPLIER // ============================================================ diff --git a/test/unit/B20Asset/announcement/isAnnouncementActive.t.sol b/test/unit/B20Asset/announcement/isAnnouncementActive.t.sol new file mode 100644 index 0000000..b502849 --- /dev/null +++ b/test/unit/B20Asset/announcement/isAnnouncementActive.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20AssetTest} from "test/lib/B20AssetTest.sol"; + +contract B20AssetIsAnnouncementActiveTest is B20AssetTest { + /// @notice Verifies isAnnouncementActive is false before any announce + /// @dev Default transient value is false; readback on a freshly bootstrapped token + /// with no announce call yet must report false. Fuzz parameter would add + /// no value here — the view takes no input. + function test_isAnnouncementActive_success_falseBeforeAnnounce() public view { + assertFalse(asset().isAnnouncementActive(), "must read false before any announce"); + } + + /// @notice Verifies isAnnouncementActive resets to false after a completed announce + /// @dev The bracket flips the flag true at open and false at close. After a + /// successful announce returns, the next external view call must observe false. + /// Fuzz over `id` to exercise the flag-reset path independently of the consumed-id + /// bookkeeping that `isAnnouncementIdUsed` already covers. + function test_isAnnouncementActive_success_falseAfterAnnounce(string calldata id) public { + _announce(id); + assertFalse(asset().isAnnouncementActive(), "must read false after announce closes"); + } + + /// @notice Verifies the flag also resets when announce reverts mid-bracket + /// @dev Per EIP-1153, transient storage is cleared at transaction end regardless of + /// whether the top-level call succeeds. A revert inside `internalCalls` therefore + /// cannot leave the flag stuck `true` across transactions. We trigger the + /// revert path via the existing recursion guard (inner call re-invoking `announce` + /// reverts AnnouncementInProgress), then in a *separate* transaction observe the + /// view reads false. + function test_isAnnouncementActive_success_falseAfterRevertedAnnounce() public { + _grantOperator(); + + // Inner call that the recursion guard will reject — forces the outer + // announce to revert AFTER the flag has been set true at the top of the body. + bytes[] memory inner = _singletonBytes( + abi.encodeWithSelector( + bytes4(keccak256("announce(bytes[],string,string,string)")), + new bytes[](0), + "inner", + "desc", + "uri" + ) + ); + + vm.prank(operator); + // Don't care about the specific revert selector here; any revert during the + // bracket exercises the "did the flag survive the abort?" property. + try asset().announce(inner, "id-revert", "desc", "uri") { + revert("announce was expected to revert"); + } catch {} + + // New top-level call — transient storage from the prior reverted tx is gone. + assertFalse(asset().isAnnouncementActive(), "transient flag must reset after a reverted announce"); + } +} From ea1b5b0c9e50c28b00354965739e2580c2685ac2 Mon Sep 17 00:00:00 2001 From: katzman Date: Wed, 3 Jun 2026 15:16:52 -0700 Subject: [PATCH 4/4] chore(fmt): forge fmt fixes for BOP-274 --- test/lib/mocks/MockB20Asset.sol | 2 +- test/unit/B20Asset/announcement/isAnnouncementActive.t.sol | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 5fce83e..bdd8e18 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -84,7 +84,7 @@ contract MockB20Asset is MockB20, IB20Asset { /// exposes the same value via a runtime context flag rather /// than a storage slot, so there is no persistent layout the /// Rust impl needs to mirror. - bool transient internal _announcementActive; + bool internal transient _announcementActive; // ============================================================ // DECIMALS diff --git a/test/unit/B20Asset/announcement/isAnnouncementActive.t.sol b/test/unit/B20Asset/announcement/isAnnouncementActive.t.sol index b502849..69e70fb 100644 --- a/test/unit/B20Asset/announcement/isAnnouncementActive.t.sol +++ b/test/unit/B20Asset/announcement/isAnnouncementActive.t.sol @@ -36,11 +36,7 @@ contract B20AssetIsAnnouncementActiveTest is B20AssetTest { // announce to revert AFTER the flag has been set true at the top of the body. bytes[] memory inner = _singletonBytes( abi.encodeWithSelector( - bytes4(keccak256("announce(bytes[],string,string,string)")), - new bytes[](0), - "inner", - "desc", - "uri" + bytes4(keccak256("announce(bytes[],string,string,string)")), new bytes[](0), "inner", "desc", "uri" ) );