Skip to content
Open
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
14 changes: 14 additions & 0 deletions src/interfaces/IB20Asset.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sold on this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Even if we wanted the state, it's not clear any external consumer would ever be able to use this given internal calls are internal so no external entity gets control flow when announcement is active

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

But I'm also not sold the state is even useful given no code is actually using it seemingly?

/// 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
//////////////////////////////////////////////////////////////*/
Expand Down
48 changes: 28 additions & 20 deletions test/lib/mocks/MockB20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -737,18 +738,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();
Expand Down
38 changes: 38 additions & 0 deletions test/lib/mocks/MockB20Asset.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 internal transient _announcementActive;
Copy link
Copy Markdown
Member Author

@stevieraykatz stevieraykatz Jun 3, 2026

Choose a reason for hiding this comment

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

the transient storage location is only available in solidity as of 0.8.28 but our pragma is open to 0.8.20 (not good). we could:

  1. bump the required version for the whole repo to 0.8.28, or
  2. use an assembly tstore pattern (supported as of 0.8.24)
  3. use a regular storage param

I'm partial to bumping the pragma to at least 0.8.24 and using tstorage (more closely reflects the rust revm memory impl), but I could also see us not bringing this feature to parity.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think bumping the pragma to support the most expressive/accurate mock implementation makes sense to me? If there were genuine compatibility concerns for other devs importing/working with this source code I'd hesitate more but given that these aren't production implementations I don't think it matters?


// ============================================================
// DECIMALS
// ============================================================
Expand Down Expand Up @@ -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++) {
Expand All @@ -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
// ============================================================
Expand Down
53 changes: 53 additions & 0 deletions test/unit/B20Asset/announcement/isAnnouncementActive.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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");
}
}
Loading