From 2a5e6bd442e46ec4b8b3c8c641d76006ee68762c Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 21 Jul 2026 02:17:41 -0400 Subject: [PATCH 1/2] tokenized stock splits: integrate erc-8056 --- docs/B20/Asset.md | 45 +++++-- src/interfaces/IB20Asset.sol | 65 ++++++++-- src/interfaces/IERC165.sol | 18 +++ src/interfaces/IScaledUIAmount.sol | 60 ++++++++++ src/lib/B20FactoryLib.sol | 12 ++ test/lib/B20AssetTest.sol | 16 +++ test/lib/mocks/MockB20Asset.sol | 113 +++++++++++++++++- test/lib/mocks/MockB20Storage.sol | 35 ++++++ test/regression/B20Renames.t.sol | 43 +++++++ .../B20Asset/erc165/supportsInterface.t.sol | 48 ++++++++ .../cancelScheduledMultiplier.t.sol | 83 +++++++++++++ .../B20Asset/multiplier/materialize.t.sol | 95 +++++++++++++++ .../unit/B20Asset/multiplier/multiplier.t.sol | 7 +- .../B20Asset/multiplier/newUIMultiplier.t.sol | 37 ++++++ test/unit/B20Asset/multiplier/reorder.t.sol | 41 +++++++ .../B20Asset/multiplier/scaledBalanceOf.t.sol | 2 +- .../B20Asset/multiplier/setUIMultiplier.t.sol | 99 +++++++++++++++ .../B20Asset/multiplier/toRawBalance.t.sol | 2 +- .../B20Asset/multiplier/toScaledBalance.t.sol | 7 +- .../B20Asset/multiplier/totalSupplyUI.t.sol | 27 +++++ .../multiplier/updateMultiplier.t.sol | 23 +++- test/unit/storage/B20AssetFullLayout.t.sol | 19 ++- 22 files changed, 858 insertions(+), 39 deletions(-) create mode 100644 src/interfaces/IERC165.sol create mode 100644 src/interfaces/IScaledUIAmount.sol create mode 100644 test/unit/B20Asset/erc165/supportsInterface.t.sol create mode 100644 test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol create mode 100644 test/unit/B20Asset/multiplier/materialize.t.sol create mode 100644 test/unit/B20Asset/multiplier/newUIMultiplier.t.sol create mode 100644 test/unit/B20Asset/multiplier/reorder.t.sol create mode 100644 test/unit/B20Asset/multiplier/setUIMultiplier.t.sol create mode 100644 test/unit/B20Asset/multiplier/totalSupplyUI.t.sol diff --git a/docs/B20/Asset.md b/docs/B20/Asset.md index b979224..20cbaa1 100644 --- a/docs/B20/Asset.md +++ b/docs/B20/Asset.md @@ -4,11 +4,42 @@ The Asset variant of B20 — designed for assets of all kinds. Everything in [B2 ## Multiplier -Each account's stored balance is the **raw** balance. A uniform on-chain **multiplier** scales that raw balance into a derived **scaled** view that consumers display. The multiplier applies to all accounts equally, which lets issuers rebase every balance at once — without rewriting individual balances — the shape is similar to wstETH wrapping stETH, where the stored unit is the unwrapped quantity and the derived unit is the rebased view. +Each account's stored balance is the **raw** balance. A uniform on-chain **multiplier** scales that raw balance into a derived **scaled** view that consumers display. The multiplier applies to all accounts equally, which lets issuers rebase every balance at once — without rewriting individual balances — the shape is similar to wstETH wrapping stETH, where the stored unit is the unwrapped quantity and the derived unit is the rebased view. Because it only rescales the *displayed* balance, the multiplier is purely cosmetic: `balanceOf`, `transfer`, and `totalSupply` stay raw, so raw-denominated venues (AMMs, etc.) are mechanically unaffected by an update. Read the current multiplier with `multiplier()`; the value is in WAD precision (`1e18`, exposed as `WAD_PRECISION()`). `toScaledBalance(rawBalance)` converts a raw amount to its scaled view, `toRawBalance(scaledBalance)` is the reverse converter (integer-floored, so the round-trip can lose up to one ULP), and `scaledBalanceOf(account)` is a convenience over ERC-20's `balanceOf` that returns the same account's raw balance in its scaled form. -`updateMultiplier(newMultiplier)` updates the multiplier and should be wrapped in an announcement (see [Announcements](#announcements)). +Both multiplier setters validate `newMultiplier` is non-zero and at most `type(uint128).max` (reverting `InvalidMultiplier` otherwise). The `uint128` ceiling is the overflow guard: with supply capped at `type(uint128).max`, a `uint128` multiplier keeps `raw * multiplier` inside `uint256`, so scaled reads can never overflow. + +### Scheduling multiplier updates + +The standard path for a corporate action (a stock split or reinvested stock dividend) is to **schedule** the change ahead of time with `setUIMultiplier(newMultiplier, effectiveAt)`, wrapped in an [announcement](#announcements). Evaluation is lazy, so `multiplier()` / `uiMultiplier()` flip on their own once `block.timestamp` reaches `effectiveAt`. + +Only **one pending update is live at a time**. Attempting to schedule over an existing pending update reverts `ScheduleOverlap`. To reorder overlapping corporate actions, explicitly cancel and re-schedule in a single announcement bracket using `announce([cancelScheduledMultiplier, setUIMultiplier(...)])`. `cancelScheduledMultiplier()` clears the live pending and restores the no-pending state (reverting `NoScheduledMultiplier` when nothing live is scheduled). + +`updateMultiplier(newMultiplier)` is retained as an **instant failsafe / emergency override**: it sets the multiplier immediately, stamping `effectiveAt = block.timestamp` and clearing any pending update. + +The pending schedule is observable through the ERC-8056 surface: `newUIMultiplier()` returns the scheduled target while it is live (otherwise it mirrors `uiMultiplier()`). + +### ERC-8056 conformance + +The Asset variant conforms to [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056) ("Scaled UI Amount"): + +- `uiMultiplier()` is the standard alias of `multiplier()` (core interface `0xa60bf13d`). +- `newUIMultiplier()` / `effectiveAt()` expose the pending schedule (required extension `0x4bd27648`). +- `balanceOfUI(account)` aliases `scaledBalanceOf`, and `totalSupplyUI()` returns `totalSupply() * uiMultiplier() / 1e18` (optional Balances extension `0xd890fd71`). +- `supportsInterface(bytes4)` (ERC-165, `0x01ffc9a7`) returns `true` for those three IDs and for ERC-165 itself. The optional Conversion extension (`0x57854fc3`) is **not** claimed — the native `toScaledBalance` / `toRawBalance` names are kept unaliased for backwards compatibility. + +**Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `setUIMultiplier` and from `updateMultiplier`. `MultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelScheduledMultiplier` and by `updateMultiplier` when it clears a live pending. + +### Precision & decimals + +All multiplier-derived reads (`toScaledBalance` / `scaledBalanceOf` / `totalSupplyUI` divide by `WAD_PRECISION`; `toRawBalance` divides by the multiplier) round **down**, and raw balances are never rewritten. This guarantees that rounding loss is rare and confined to the scaled view (and to `toRawBalance` conversions). In the rare case where rounding loss occurs, the loss cannot exceed 1 wei of the *scaled* amount only. + +**Thus, prefer 18 decimals for equities**: at 6 decimals, a deep reverse split on a very valuable stock could make 1-wei floor dust economically visible; at 18 it stays noise + +### Pause & market-halt policy + +Because a multiplier update is value-neutral to raw venues, forward splits and reinvested dividends need no halt on-chain. A reverse split, however, warrants halting via `PausableFeature.TRANSFER` across the flip window so trading windows are paused and re-enabled in orderly fashion. The instant `updateMultiplier` bypasses the scheduling window entirely, so it should likewise be pause-bracketed. ## Announcements @@ -25,14 +56,14 @@ Indexers should treat every `Announcement` log as the start of exactly one brack Wrap a set of operations in a single announcement by calling `announce(internalCalls, id, description, uri)`. The function (gated by `OPERATOR_ROLE`) emits `Announcement`, dispatches each internal call via self-`delegatecall` (which preserves `msg.sender` so the inner role checks see the operator), then emits `EndAnnouncement`. Inner reverts are wrapped in `InternalCallFailed` rather than bubbled — replay the call directly to debug. Nested calls to `announce` revert with `AnnouncementInProgress`; calls shorter than 4 bytes revert with `InternalCallMalformed`. ```solidity -// Disclose and apply a community-ratified rebase atomically. +// Disclose and schedule a 2:1 forward split, effective at the ex-date. bytes[] memory internalCalls = new bytes[](1); -internalCalls[0] = abi.encodeCall(IB20Asset.updateMultiplier, (newMultiplier)); +internalCalls[0] = abi.encodeCall(IB20Asset.setUIMultiplier, (2e18, exDateTimestamp)); IB20Asset(token).announce({ internalCalls: internalCalls, - id: "2026-Q3-rebase", - description: "Community governance proposal #42: ratified rebase", + id: "2026-Q3-split", + description: "2:1 forward split, effective at ex-date", uri: "https://disclosures.example.com/..." }); ``` @@ -51,7 +82,7 @@ Each Asset token can carry an arbitrary set of named metadata entries — a gene ### `OPERATOR_ROLE` -Gates the `updateMultiplier` and the `announce`. These are metadata-like operations — they post disclosures and rescale the displayed balance rather than moving raw balances directly — but a compromised operator carries materially higher severity than ordinary metadata edits, so the capability is elevated into its own independent role instead of being folded into `METADATA_ROLE`. Held separately from `DEFAULT_ADMIN_ROLE` so operators don't need full admin authority. +Gates `announce`, `setUIMultiplier`, `cancelScheduledMultiplier`, and `updateMultiplier`. These are metadata-like operations — they post disclosures and rescale the displayed balance rather than moving raw balances directly — but a compromised operator carries materially higher severity than ordinary metadata edits, so the capability is elevated into its own independent role instead of being folded into `METADATA_ROLE`. Held separately from `DEFAULT_ADMIN_ROLE` so operators don't need full admin authority. ## Configurable Decimals diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 216c8a8..05b6459 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -2,6 +2,8 @@ pragma solidity >=0.8.20 <0.9.0; import {IB20} from "./IB20.sol"; +import {IERC165} from "./IERC165.sol"; +import {IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances} from "./IScaledUIAmount.sol"; /// @title IB20Asset /// @author Coinbase @@ -9,7 +11,7 @@ import {IB20} from "./IB20.sol"; /// @notice A B-20 token variant for assets of all kinds. Extends `IB20` with announcements, /// multiplier-based scaling, batched mint for bulk issuance, and extra-metadata /// entries. -interface IB20Asset is IB20 { +interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances { /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ @@ -20,9 +22,30 @@ interface IB20Asset is IB20 { /// @notice `updateExtraMetadata` was called with an empty `key`. error InvalidMetadataKey(); - /// @notice `updateMultiplier` was called with a zero multiplier. + /// @notice A multiplier setter (`setUIMultiplier` or `updateMultiplier`) was called with a + /// multiplier of zero or above the `type(uint128).max` overflow guard. error InvalidMultiplier(); + /// @notice `setUIMultiplier` was called with an `effectiveAt` that is not in the future + /// (`effectiveAt <= block.timestamp`). + /// + /// @param effectiveAt Rejected effective-at timestamp. + error EffectiveAtInPast(uint256 effectiveAt); + + /// @notice `setUIMultiplier` was called with an `effectiveAt` above `type(uint64).max`, the + /// width of the on-chain `effectiveAt` field. + /// + /// @param effectiveAt Rejected effective-at timestamp. + error EffectiveAtTooFar(uint256 effectiveAt); + + /// @notice `setUIMultiplier` was called while a live pending update already exists + /// + /// @param pendingEffectiveAt The `effectiveAt` of the live pending update. + error ScheduleOverlap(uint256 pendingEffectiveAt); + + /// @notice `cancelScheduledMultiplier` was called when there is no live pending update + error NoScheduledMultiplier(); + /// @notice A batched function was called with parallel arrays of differing lengths. /// /// @param leftLen Length of the first array argument. @@ -49,8 +72,13 @@ interface IB20Asset is IB20 { EVENTS //////////////////////////////////////////////////////////////*/ - /// @notice Emitted by `updateMultiplier`. - event MultiplierUpdated(uint256 multiplier); + /// @notice Base-local companion to ERC-8056's `UIMultiplierUpdated`. Fills the standard's + /// cancellation gap: emitted by `cancelScheduledMultiplier`, and by `updateMultiplier` + /// whenever it clears a live pending update. + /// + /// @param cancelledMultiplier The pending multiplier that was cleared. + /// @param cancelledEffectiveAt The `effectiveAt` of the pending update that was cleared. + event MultiplierUpdateCancelled(uint256 cancelledMultiplier, uint256 cancelledEffectiveAt); /// @notice Emitted by `updateExtraMetadata`. An empty `value` indicates removal. event ExtraMetadataUpdated(string key, string value); @@ -65,9 +93,9 @@ interface IB20Asset is IB20 { ROLE CONSTANTS //////////////////////////////////////////////////////////////*/ - /// @notice Required to call `announce` and `updateMultiplier`. The metadata setters - /// (`updateName`, `updateSymbol`, `updateExtraMetadata`) are gated by the - /// inherited `METADATA_ROLE` instead. + /// @notice Required to call `announce`, `setUIMultiplier`, `cancelScheduledMultiplier`, and + /// `updateMultiplier`. The metadata setters (`updateName`, `updateSymbol`, + /// `updateExtraMetadata`) are gated by the inherited `METADATA_ROLE` instead. /// @return Role constant. function OPERATOR_ROLE() external view returns (bytes32); @@ -119,7 +147,9 @@ interface IB20Asset is IB20 { /// @notice The current multiplier, scaled to `WAD_PRECISION`. Holder balances are stored /// as raw units; the multiplier scales them into a derived "scaled" view, similar /// in shape to wstETH wrapping stETH. - /// @return Current multiplier. + /// + /// @dev Alias of the ERC-8056 `uiMultiplier()`. + /// @return Current (effective) multiplier. function multiplier() external view returns (uint256); /// @notice Converts a raw balance to its scaled view: `rawBalance * multiplier / WAD_PRECISION`. @@ -148,13 +178,22 @@ interface IB20Asset is IB20 { /// @return Scaled balance. function scaledBalanceOf(address account) external view returns (uint256); - /// @notice Sets a new multiplier. Holder raw balances are not rewritten; scaled balances - /// derive from the new multiplier at read time. Emits `MultiplierUpdated`. + /// @notice Schedules a multiplier update to take effect at `effectiveAt` — the standard path + /// for corporate actions (splits, reinvested dividends). /// - /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `OPERATOR_ROLE`. - /// @dev Reverts with `InvalidMultiplier` when `newMultiplier` is zero. + /// @param newMultiplier New multiplier scaled to `WAD_PRECISION` + /// @param effectiveAt Timestamp at which `newMultiplier` becomes effective; must be in the future. + function setUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) external; + + /// @notice Cancels the single live pending update, restoring the no-pending state + /// (`effectiveAt` resets to 0). + function cancelScheduledMultiplier() external; + + /// @notice Instant failsafe / emergency override — sets the current multiplier immediately and + /// cancels any live pending update without a scheduling window. + /// Prefer `setUIMultiplier` for routine corporate actions /// - /// @param newMultiplier New multiplier scaled to `WAD_PRECISION`; must be non-zero. + /// @param newMultiplier New multiplier scaled to `WAD_PRECISION`; must be in `(0, type(uint128).max]`. function updateMultiplier(uint256 newMultiplier) external; /*////////////////////////////////////////////////////////////// diff --git a/src/interfaces/IERC165.sol b/src/interfaces/IERC165.sol new file mode 100644 index 0000000..2c79d1f --- /dev/null +++ b/src/interfaces/IERC165.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.20 <0.9.0; + +/// @title IERC165 +/// @author Ethereum (ERC-165) +/// +/// @notice Standard interface-detection interface. +/// +/// @dev Interface ID: `0x01ffc9a7`. Declared locally because base-std carries no external +/// dependency that provides it (ERC-165 is greenfield here). +interface IERC165 { + /// @notice Whether this contract implements the interface defined by `interfaceId`. + /// + /// @param interfaceId The interface identifier, per ERC-165. + /// + /// @return `true` if the contract implements `interfaceId` and `interfaceId != 0xffffffff`. + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} diff --git a/src/interfaces/IScaledUIAmount.sol b/src/interfaces/IScaledUIAmount.sol new file mode 100644 index 0000000..017dd7d --- /dev/null +++ b/src/interfaces/IScaledUIAmount.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.20 <0.9.0; + +/// @title IScaledUIAmount +/// @author Ethereum (ERC-8056) +/// +/// @notice ERC-8056 core interface. A compliant token exposes an updatable, cosmetic +/// `uiMultiplier` (18-decimal WAD, `1e18 = 1.0`) that rescales the *displayed* +/// balance without minting, transferring, or rewriting any raw balance. +/// +/// @dev Interface ID: `0xa60bf13d` — self-checked via `type(IScaledUIAmount).interfaceId`, which +/// is derived solely from `uiMultiplier()`. ERC-8056 also defines an OPTIONAL +/// `TransferWithUIAmount(address,address,uint256,uint256)` event; B20 deliberately omits it +/// (D9: no per-transfer scaled event, since the multiplier is cosmetic and scaled balances +/// are reconstructable off-chain). Events do not contribute to the interface ID, so omitting +/// it leaves `0xa60bf13d` unchanged. +interface IScaledUIAmount { + /// @notice Emitted when the UI multiplier is updated. + event UIMultiplierUpdated(uint256 oldMultiplier, uint256 newMultiplier, uint256 effectiveAtTimestamp); + + /// @notice Returns the current UI multiplier. Multiplier is represented with 18 decimals (`1e18 = 1.0`). + /// @return Current UI multiplier. + function uiMultiplier() external view returns (uint256); +} + +/// @title IScaledUIAmountNewUIMultiplier +/// @author Ethereum (ERC-8056) +/// +/// @notice "Pending Multiplier" extension required by ERC-8056 +/// +/// @dev Interface ID: `0x4bd27648`. +interface IScaledUIAmountNewUIMultiplier { + /// @notice Returns the pending UI multiplier scheduled to take effect at `effectiveAt`. + /// Multiplier is represented with 18 decimals (`1e18 = 1.0`). + /// @return Pending UI multiplier. + function newUIMultiplier() external view returns (uint256); + + /// @notice Returns the timestamp at which the pending multiplier becomes effective. + /// @return Effective-at timestamp. + function effectiveAt() external view returns (uint256); +} + +/// @title IScaledUIAmountBalances +/// @author Ethereum (ERC-8056) +/// +/// @notice ERC-8056 optional "Balances" extension for on-chain UI balance queries. +/// +/// @dev Interface ID: `0xd890fd71`. +interface IScaledUIAmountBalances { + /// @notice Returns the UI-adjusted balance of an account. + /// + /// @param account Account whose UI-adjusted balance is being queried. + /// + /// @return UI-adjusted balance. + function balanceOfUI(address account) external view returns (uint256); + + /// @notice Returns the UI-adjusted total supply. + /// @return UI-adjusted total supply. + function totalSupplyUI() external view returns (uint256); +} diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index 792096d..d288cd2 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -210,6 +210,18 @@ library B20FactoryLib { return abi.encodeCall(IB20Asset.updateMultiplier, (newMultiplier)); } + /// @notice Encodes an initCall / announce inner call to `IB20Asset.setUIMultiplier` + /// @param newMultiplier New multiplier, scaled to `WAD_PRECISION`. + /// @param effectiveAt Timestamp at which `newMultiplier` becomes effective; must be in the future. + function encodeSetUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) internal pure returns (bytes memory) { + return abi.encodeCall(IB20Asset.setUIMultiplier, (newMultiplier, effectiveAt)); + } + + /// @notice Encodes an announce inner call to `IB20Asset.cancelScheduledMultiplier`. + function encodeCancelScheduledMultiplier() internal pure returns (bytes memory) { + return abi.encodeCall(IB20Asset.cancelScheduledMultiplier, ()); + } + /*////////////////////////////////////////////////////////////// INIT-CALL ARRAY BUILDERS //////////////////////////////////////////////////////////////*/ diff --git a/test/lib/B20AssetTest.sol b/test/lib/B20AssetTest.sol index 7662dda..6743e8d 100644 --- a/test/lib/B20AssetTest.sol +++ b/test/lib/B20AssetTest.sol @@ -81,6 +81,22 @@ contract B20AssetTest is B20Test { asset().updateMultiplier(newMultiplier); } + /// @notice Schedules a pending multiplier via the `operator` actor, + /// lazily granting `OPERATOR_ROLE` on first call. + function _setUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) internal { + _grantOperator(); + vm.prank(operator); + asset().setUIMultiplier(newMultiplier, effectiveAt); + } + + /// @notice Cancels the live pending multiplier via the `operator` + /// actor, lazily granting `OPERATOR_ROLE` on first call. + function _cancelScheduledMultiplier() internal { + _grantOperator(); + vm.prank(operator); + asset().cancelScheduledMultiplier(); + } + // ============================================================ // ANNOUNCEMENT HELPERS // ============================================================ diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index c2befee..dca1515 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -3,6 +3,12 @@ pragma solidity ^0.8.20; import {IB20} from "base-std/interfaces/IB20.sol"; import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; +import {IERC165} from "base-std/interfaces/IERC165.sol"; +import { + IScaledUIAmount, + IScaledUIAmountNewUIMultiplier, + IScaledUIAmountBalances +} from "base-std/interfaces/IScaledUIAmount.sol"; import {MockB20} from "base-std-test/lib/mocks/MockB20.sol"; import {MockB20AssetStorage, MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; @@ -120,6 +126,23 @@ contract MockB20Asset is MockB20, IB20Asset { return _multiplier(); } + /// @dev ERC-8056 alias of `multiplier()`. + function uiMultiplier() external view returns (uint256) { + return _multiplier(); + } + + /// @dev ERC-8056 pending target view function + function newUIMultiplier() external view returns (uint256) { + MockB20AssetStorage.PendingMultiplier storage pending = MockB20AssetStorage.layout().pending; + if (pending.effectiveAt > block.timestamp) return pending.multiplier; + return _multiplier(); + } + + /// @dev ERC-8056 pending effective time + function effectiveAt() external view returns (uint256) { + return MockB20AssetStorage.layout().pending.effectiveAt; + } + function toScaledBalance(uint256 rawBalance) external view returns (uint256) { return (rawBalance * _multiplier()) / WAD_PRECISION; } @@ -132,10 +155,80 @@ contract MockB20Asset is MockB20, IB20Asset { return (MockB20Storage.layout().balances[account] * _multiplier()) / WAD_PRECISION; } + /// @dev ERC-8056 Balances extension. Alias of `scaledBalanceOf`. + function balanceOfUI(address account) external view returns (uint256) { + return (MockB20Storage.layout().balances[account] * _multiplier()) / WAD_PRECISION; + } + + /// @dev ERC-8056 Balances extension. View function for scaled total supply. + function totalSupplyUI() external view returns (uint256) { + return (MockB20Storage.layout().totalSupply * _multiplier()) / WAD_PRECISION; + } + + /// @notice Schedules a single pending multiplier update. Standard corporate-action path. + /// @dev Factors a matured-but-uncancelled pending into the current multiplier first, so a + /// scheduled change is never silently lost (deliberately unlike the ERC-8056 reference + /// setter, which overwrites). A *live* pending (`effectiveAt > block.timestamp`) blocks and + /// must be cancelled first. + function setUIMultiplier(uint256 newMultiplier, uint256 effectiveAt_) external onlyRole(OPERATOR_ROLE) { + if (newMultiplier == 0 || newMultiplier > type(uint128).max) revert InvalidMultiplier(); + if (effectiveAt_ <= block.timestamp) revert EffectiveAtInPast(effectiveAt_); + if (effectiveAt_ > type(uint64).max) revert EffectiveAtTooFar(effectiveAt_); + + MockB20AssetStorage.Layout storage $ = MockB20AssetStorage.layout(); + uint256 pendingEff = $.pending.effectiveAt; + // A live pending blocks a new schedule. + if (pendingEff > block.timestamp) revert ScheduleOverlap(pendingEff); + // A matured-but-uncancelled pending is folded into the current multiplier before the + // overwrite below so it is never lost. + if (pendingEff != 0) $.multiplier = $.pending.multiplier; + + uint256 old = _currentMultiplier(); + $.pending = MockB20AssetStorage.PendingMultiplier({ + multiplier: uint128(newMultiplier), effectiveAt: uint64(effectiveAt_) + }); + + emit UIMultiplierUpdated(old, newMultiplier, effectiveAt_); + } + + /// @notice Cancels the single live pending update, restoring the no-pending state. + function cancelScheduledMultiplier() external onlyRole(OPERATOR_ROLE) { + MockB20AssetStorage.Layout storage $ = MockB20AssetStorage.layout(); + uint256 pendingMult = $.pending.multiplier; + uint256 pendingEff = $.pending.effectiveAt; + // Only a live pending can be cancelled + if (pendingEff <= block.timestamp) revert NoScheduledMultiplier(); + delete $.pending; + + emit MultiplierUpdateCancelled(pendingMult, pendingEff); + } + + /// @notice Sets the current multiplier immediately and clears any pending. function updateMultiplier(uint256 newMultiplier) external onlyRole(OPERATOR_ROLE) { - if (newMultiplier == 0) revert InvalidMultiplier(); - MockB20AssetStorage.layout().multiplier = newMultiplier; - emit MultiplierUpdated(newMultiplier); + if (newMultiplier == 0 || newMultiplier > type(uint128).max) revert InvalidMultiplier(); + MockB20AssetStorage.Layout storage $ = MockB20AssetStorage.layout(); + uint256 pendingMult = $.pending.multiplier; + uint256 pendingEff = $.pending.effectiveAt; + bool livePending = pendingEff > block.timestamp; + + uint256 old = _multiplier(); + $.multiplier = newMultiplier; + if (pendingEff != 0) delete $.pending; + if (livePending) emit MultiplierUpdateCancelled(pendingMult, pendingEff); + emit UIMultiplierUpdated(old, newMultiplier, block.timestamp); + } + + // ============================================================ + // ERC-165 + // ============================================================ + + /// @dev Advertises ERC-165 itself plus the three claimed ERC-8056 interfaces. The Conversion + /// extension (`0x57854fc3`) is deliberately NOT advertised — the native + /// `toScaledBalance` / `toRawBalance` names are kept unaliased. + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(IERC165).interfaceId || interfaceId == type(IScaledUIAmount).interfaceId + || interfaceId == type(IScaledUIAmountNewUIMultiplier).interfaceId + || interfaceId == type(IScaledUIAmountBalances).interfaceId; } // ============================================================ @@ -177,9 +270,19 @@ contract MockB20Asset is MockB20, IB20Asset { // INTERNAL HELPERS // ============================================================ - /// @dev Stored `0` resolves to `WAD_PRECISION` so a freshly-etched - /// token (no factory write yet) reports a 1:1 multiplier. + /// @dev The effective multiplier: returns the pending slot's value if live, + /// otherwise returns the current multiplier. function _multiplier() internal view returns (uint256) { + MockB20AssetStorage.PendingMultiplier storage pending = MockB20AssetStorage.layout().pending; + if (pending.effectiveAt != 0 && block.timestamp >= pending.effectiveAt) { + return pending.multiplier; + } + return _currentMultiplier(); + } + + /// @dev The current multiplier. If `multiplier` is unset, stored `0` resolves to `WAD_PRECISION` + /// so a fresh deploy reports a 1:1 multiplier. + function _currentMultiplier() internal view returns (uint256) { uint256 stored = MockB20AssetStorage.layout().multiplier; return stored == 0 ? WAD_PRECISION : stored; } diff --git a/test/lib/mocks/MockB20Storage.sol b/test/lib/mocks/MockB20Storage.sol index 1ba6898..66cb80c 100644 --- a/test/lib/mocks/MockB20Storage.sol +++ b/test/lib/mocks/MockB20Storage.sol @@ -358,6 +358,12 @@ library MockB20Storage { /// directly on the raw `key` string (e.g. `"category"`); /// empty value means unset/removed. library MockB20AssetStorage { + /// @notice The single scheduled multiplier update (ERC-8056 pending). + struct PendingMultiplier { + uint128 multiplier; + uint64 effectiveAt; + } + /// @custom:storage-location erc7201:base.b20.asset struct Layout { // ---------- Decimals ---------- @@ -382,6 +388,9 @@ library MockB20AssetStorage { // (e.g. `category`, `region`, `reference`). Empty string means // unset/removed. mapping(string key => string value) extraMetadata; + // ---------- Pending multiplier (ERC-8056 schedule) ---------- + // Single scheduled multiplier update, packed into one slot. + PendingMultiplier pending; } // keccak256(abi.encode(uint256(keccak256("base.b20.asset")) - 1)) & ~bytes32(uint256(0xff)) @@ -400,6 +409,7 @@ library MockB20AssetStorage { uint256 internal constant MULTIPLIER_OFFSET = 1; uint256 internal constant USED_ANNOUNCEMENT_IDS_OFFSET = 2; uint256 internal constant EXTRA_METADATA_OFFSET = 3; + uint256 internal constant PENDING_OFFSET = 4; /// @notice Absolute slot for a top-level field of `Layout`. function slotOf(uint256 offset) internal pure returns (bytes32) { @@ -428,6 +438,7 @@ library MockB20AssetStorage { function multiplierSlot() internal pure returns (bytes32) { return slotOf(MULTIPLIER_OFFSET); } function usedAnnouncementIdsBaseSlot() internal pure returns (bytes32) { return slotOf(USED_ANNOUNCEMENT_IDS_OFFSET); } function extraMetadataBaseSlot() internal pure returns (bytes32) { return slotOf(EXTRA_METADATA_OFFSET); } + function pendingSlot() internal pure returns (bytes32) { return slotOf(PENDING_OFFSET); } // forgefmt: disable-end @@ -451,6 +462,30 @@ library MockB20AssetStorage { function extraMetadataSlot(string memory key) internal pure returns (bytes32) { return keccak256(abi.encodePacked(key, extraMetadataBaseSlot())); } + + // ============================================================ + // PACKED-SLOT CODECS + // ============================================================ + // Production code accesses the pending slot via the `PendingMultiplier` + // struct, meaning Solidity handles the bit math automatically. + // These pure codecs exist for test-side use only (operating on vm.load outputs) + // The roundtrip test in `MockB20AssetSlotHelpers.t.sol` verifies this bit math matches + // Solidity's struct packing, enshrining it in CI. + + /// @notice Extracts the pending multiplier (bits 0..127) from the packed slot. + function pendingMultiplierValue(uint256 packed) internal pure returns (uint128) { + return uint128(packed); + } + + /// @notice Extracts the pending `effectiveAt` (bits 128..191) from the packed slot. + function pendingEffectiveAt(uint256 packed) internal pure returns (uint64) { + return uint64(packed >> 128); + } + + /// @notice Composes the pending slot from its two lanes. + function packPendingMultiplier(uint128 multiplier, uint64 effectiveAt) internal pure returns (uint256) { + return uint256(multiplier) | (uint256(effectiveAt) << 128); + } } /// @title MockB20StablecoinStorage diff --git a/test/regression/B20Renames.t.sol b/test/regression/B20Renames.t.sol index b12df1a..6f65648 100644 --- a/test/regression/B20Renames.t.sol +++ b/test/regression/B20Renames.t.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import {Vm} from "forge-std/Vm.sol"; + import {IB20} from "base-std/interfaces/IB20.sol"; import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; import {B20Constants} from "base-std/lib/B20Constants.sol"; @@ -77,6 +79,47 @@ contract B20RenamesTest is B20AssetTest { ); } + // ============================================================ + // MULTIPLIER EVENT + ERC-8056 SURFACE + // ============================================================ + + bytes32 internal constant UI_MULTIPLIER_UPDATED_SIG = keccak256("UIMultiplierUpdated(uint256,uint256,uint256)"); + bytes32 internal constant LEGACY_MULTIPLIER_UPDATED_SIG = keccak256("MultiplierUpdated(uint256)"); + + /// @notice Verifies the multiplier-change event was widened/renamed to the ERC-8056 + /// `UIMultiplierUpdated(old, new, effectiveAt)` and the legacy `MultiplierUpdated(uint256)` + /// is gone + /// @dev `updateMultiplier` must emit the ERC-8056 topic and never the legacy topic. Regression: BOP-431. + function test_multiplierEvent_success_widenedToUIMultiplierUpdated(uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + _grantOperator(); + vm.recordLogs(); + vm.prank(operator); + asset().updateMultiplier(newMultiplier); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertGt( + _firstLogIndex(logs, UI_MULTIPLIER_UPDATED_SIG), -1, "UIMultiplierUpdated(old,new,effAt) must be emitted" + ); + assertEq( + _firstLogIndex(logs, LEGACY_MULTIPLIER_UPDATED_SIG), -1, "legacy MultiplierUpdated(uint256) must be gone" + ); + } + + /// @notice Verifies the ERC-8056 surface resolves and aliases the native B20 names + /// @dev `uiMultiplier` aliases `multiplier`; `balanceOfUI` aliases `scaledBalanceOf`; the pending + /// surface, `totalSupplyUI`, and `supportsInterface` all resolve. These typed calls only + /// compile against the current interface, so their presence is the guard. Regression: BOP-431. + function test_erc8056Surface_success_aliasesResolve(uint256 amount) public { + amount = bound(amount, 0, type(uint128).max); + if (amount > 0) _mint(alice, amount); + assertEq(asset().uiMultiplier(), asset().multiplier(), "uiMultiplier must alias multiplier"); + assertEq(asset().balanceOfUI(alice), asset().scaledBalanceOf(alice), "balanceOfUI must alias scaledBalanceOf"); + assertEq(asset().newUIMultiplier(), asset().uiMultiplier(), "no-pending: newUIMultiplier == uiMultiplier"); + assertEq(asset().effectiveAt(), 0, "no-pending: effectiveAt == 0"); + assertEq(asset().totalSupplyUI(), token.totalSupply(), "default multiplier: totalSupplyUI == totalSupply"); + assertTrue(asset().supportsInterface(0xa60bf13d), "IScaledUIAmount (0xa60bf13d) must be advertised"); + } + // ============================================================ // METADATA vs OPERATOR GATING // ============================================================ diff --git a/test/unit/B20Asset/erc165/supportsInterface.t.sol b/test/unit/B20Asset/erc165/supportsInterface.t.sol new file mode 100644 index 0000000..bdb5126 --- /dev/null +++ b/test/unit/B20Asset/erc165/supportsInterface.t.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; + +import {IERC165} from "base-std/interfaces/IERC165.sol"; +import { + IScaledUIAmount, + IScaledUIAmountNewUIMultiplier, + IScaledUIAmountBalances +} from "base-std/interfaces/IScaledUIAmount.sol"; + +contract B20AssetSupportsInterfaceTest is B20AssetTest { + // Published ERC-8056 / ERC-165 interface identifiers. + bytes4 internal constant ERC165_ID = 0x01ffc9a7; + bytes4 internal constant SCALED_UI_AMOUNT_ID = 0xa60bf13d; + bytes4 internal constant NEW_UI_MULTIPLIER_ID = 0x4bd27648; + bytes4 internal constant BALANCES_ID = 0xd890fd71; + + /// @notice Verifies the four claimed interface IDs are advertised + /// @dev ERC-165 itself plus the ERC-8056 core, pending, and Balances extensions. + function test_supportsInterface_success_claimedIds() public view { + assertTrue(asset().supportsInterface(ERC165_ID), "must advertise IERC165"); + assertTrue(asset().supportsInterface(SCALED_UI_AMOUNT_ID), "must advertise IScaledUIAmount"); + assertTrue(asset().supportsInterface(NEW_UI_MULTIPLIER_ID), "must advertise IScaledUIAmountNewUIMultiplier"); + assertTrue(asset().supportsInterface(BALANCES_ID), "must advertise IScaledUIAmountBalances"); + } + + /// @notice Verifies an unknown interface ID returns false + function test_supportsInterface_success_unknownFalse(bytes4 interfaceId) public view { + vm.assume(interfaceId != ERC165_ID); + vm.assume(interfaceId != SCALED_UI_AMOUNT_ID); + vm.assume(interfaceId != NEW_UI_MULTIPLIER_ID); + vm.assume(interfaceId != BALANCES_ID); + assertFalse(asset().supportsInterface(interfaceId), "unknown interface must not be advertised"); + } + + /// @notice Verifies each computed `type(I).interfaceId` equals its published ERC-8056 hex + /// @dev Guards against selector drift in the interface definitions + function test_supportsInterface_success_interfaceIdsMatchPublishedHex() public pure { + assertEq(type(IERC165).interfaceId, ERC165_ID, "IERC165 id"); + assertEq(type(IScaledUIAmount).interfaceId, SCALED_UI_AMOUNT_ID, "IScaledUIAmount id"); + assertEq( + type(IScaledUIAmountNewUIMultiplier).interfaceId, NEW_UI_MULTIPLIER_ID, "IScaledUIAmountNewUIMultiplier id" + ); + assertEq(type(IScaledUIAmountBalances).interfaceId, BALANCES_ID, "IScaledUIAmountBalances id"); + } +} diff --git a/test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol b/test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol new file mode 100644 index 0000000..cc828f1 --- /dev/null +++ b/test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; + +import {IB20} from "base-std/interfaces/IB20.sol"; +import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; + +import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; + +contract B20AssetCancelScheduledMultiplierTest is B20AssetTest { + /// @notice Verifies cancel clears the live pending and restores the no-pending state + /// @dev Paired slot assertion: slot 4 is zeroed. `effectiveAt()` resets to 0 and + /// `newUIMultiplier() == uiMultiplier()` (no-live-pending invariant). + function test_cancelScheduledMultiplier_success_clearsPending(uint256 newMultiplier, uint256 effectiveAt) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + effectiveAt = bound(effectiveAt, block.timestamp + 1, type(uint64).max); + _setUIMultiplier(newMultiplier, effectiveAt); + + _cancelScheduledMultiplier(); + + assertEq( + uint256(vm.load(address(token), MockB20AssetStorage.pendingSlot())), 0, "slot 4 must be cleared on cancel" + ); + assertEq(asset().effectiveAt(), 0, "effectiveAt must reset to 0 after cancel"); + assertEq(asset().newUIMultiplier(), asset().uiMultiplier(), "no-live-pending: newUIMultiplier == uiMultiplier"); + } + + /// @notice Verifies cancel emits MultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt) + function test_cancelScheduledMultiplier_success_emitsEvent(uint256 newMultiplier, uint256 effectiveAt) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + effectiveAt = bound(effectiveAt, block.timestamp + 1, type(uint64).max); + _setUIMultiplier(newMultiplier, effectiveAt); + + vm.expectEmit(false, false, false, true, address(token)); + emit IB20Asset.MultiplierUpdateCancelled(newMultiplier, effectiveAt); + vm.prank(operator); + asset().cancelScheduledMultiplier(); + } + + /// @notice Verifies cancel does not disturb the current effective multiplier + function test_cancelScheduledMultiplier_success_leavesCurrentUntouched(uint256 current) public { + current = bound(current, 1, type(uint128).max); + _updateMultiplier(current); + _setUIMultiplier(2e18, block.timestamp + 1 days); + + _cancelScheduledMultiplier(); + + assertEq(asset().multiplier(), current, "cancel must leave the current multiplier unchanged"); + } + + /// @notice Verifies cancel reverts when the caller lacks OPERATOR_ROLE + function test_cancelScheduledMultiplier_revert_unauthorized(address caller) public { + _assumeValidCaller(caller); + vm.assume(caller != admin); + vm.assume(caller != operator); + _setUIMultiplier(2e18, block.timestamp + 1 days); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, OPERATOR_ROLE)); + asset().cancelScheduledMultiplier(); + } + + /// @notice Verifies cancel reverts when nothing is scheduled + function test_cancelScheduledMultiplier_revert_noPending() public { + _grantOperator(); + vm.prank(operator); + vm.expectRevert(IB20Asset.NoScheduledMultiplier.selector); + asset().cancelScheduledMultiplier(); + } + + /// @notice Verifies cancel reverts once the pending has matured + function test_cancelScheduledMultiplier_revert_matured(uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + uint256 effectiveAt = block.timestamp + 1 days; + _setUIMultiplier(newMultiplier, effectiveAt); + vm.warp(effectiveAt); + + vm.prank(operator); + vm.expectRevert(IB20Asset.NoScheduledMultiplier.selector); + asset().cancelScheduledMultiplier(); + } +} diff --git a/test/unit/B20Asset/multiplier/materialize.t.sol b/test/unit/B20Asset/multiplier/materialize.t.sol new file mode 100644 index 0000000..71828b9 --- /dev/null +++ b/test/unit/B20Asset/multiplier/materialize.t.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Vm} from "forge-std/Vm.sol"; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; + +import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; +import {IScaledUIAmount} from "base-std/interfaces/IScaledUIAmount.sol"; + +import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; + +/// @notice A matured-but-uncancelled pending must be folded into the current multiplier before any +/// set/cancel overwrites slot 4, so a scheduled change is never silently lost. +contract B20AssetMaterializeTest is B20AssetTest { + bytes32 internal constant CANCELLED_SIG = keccak256("MultiplierUpdateCancelled(uint256,uint256)"); + + /// @notice Verifies scheduling over a *matured* pending folds it into the current multiplier + function test_setUIMultiplier_success_materializesMaturedPending() public { + uint256 first = 2e18; + uint256 firstEffectiveAt = block.timestamp + 1 days; + _setUIMultiplier(first, firstEffectiveAt); + vm.warp(firstEffectiveAt + 1); + assertEq(asset().uiMultiplier(), first, "precondition: first schedule has matured"); + + uint256 second = 3e18; + uint256 secondEffectiveAt = block.timestamp + 1 days; + _grantOperator(); + vm.expectEmit(false, false, false, true, address(token)); + emit IScaledUIAmount.UIMultiplierUpdated(first, second, secondEffectiveAt); + vm.prank(operator); + asset().setUIMultiplier(second, secondEffectiveAt); + + // The matured `first` was folded into slot 1 and is still effective before `second` matures. + assertEq(asset().uiMultiplier(), first, "matured pending must be folded into current, not lost"); + assertEq( + uint256(vm.load(address(token), MockB20AssetStorage.multiplierSlot())), + first, + "slot 1 must hold the folded matured multiplier" + ); + assertEq(asset().newUIMultiplier(), second, "new pending must be recorded"); + assertEq(asset().effectiveAt(), secondEffectiveAt, "new effectiveAt must be recorded"); + + vm.warp(secondEffectiveAt); + assertEq(asset().uiMultiplier(), second, "second schedule flips in on maturity"); + } + + /// @notice Verifies updateMultiplier clears a *live* pending and emits the cancellation + function test_updateMultiplier_success_clearsLivePending() public { + uint256 pendingMultiplier = 2e18; + uint256 effectiveAt = block.timestamp + 1 days; + _setUIMultiplier(pendingMultiplier, effectiveAt); + + uint256 instant = 5e18; + uint256 old = asset().uiMultiplier(); + _grantOperator(); + vm.expectEmit(false, false, false, true, address(token)); + emit IB20Asset.MultiplierUpdateCancelled(pendingMultiplier, effectiveAt); + vm.expectEmit(false, false, false, true, address(token)); + emit IScaledUIAmount.UIMultiplierUpdated(old, instant, block.timestamp); + vm.prank(operator); + asset().updateMultiplier(instant); + + assertEq(asset().uiMultiplier(), instant, "instant update must take effect immediately"); + assertEq(uint256(vm.load(address(token), MockB20AssetStorage.pendingSlot())), 0, "pending must be cleared"); + assertEq(asset().effectiveAt(), 0, "effectiveAt must reset to 0"); + } + + /// @notice Verifies updateMultiplier clears a *matured* pending WITHOUT a cancellation event + /// @dev A matured pending already took effect, so it folds into `oldMultiplier` and is cleared + /// silently — `MultiplierUpdateCancelled` fires only for a live pending. + function test_updateMultiplier_success_clearsMaturedPendingNoCancelEvent() public { + uint256 matured = 2e18; + uint256 effectiveAt = block.timestamp + 1 days; + _setUIMultiplier(matured, effectiveAt); + vm.warp(effectiveAt + 1); + + uint256 instant = 5e18; + _grantOperator(); + vm.recordLogs(); + vm.prank(operator); + asset().updateMultiplier(instant); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + assertEq( + _firstLogIndex(logs, CANCELLED_SIG), + -1, + "no MultiplierUpdateCancelled for a matured (already-effective) pending" + ); + assertEq(asset().uiMultiplier(), instant, "instant update must take effect immediately"); + assertEq( + uint256(vm.load(address(token), MockB20AssetStorage.pendingSlot())), 0, "matured pending must be cleared" + ); + } +} diff --git a/test/unit/B20Asset/multiplier/multiplier.t.sol b/test/unit/B20Asset/multiplier/multiplier.t.sol index f15d3f8..26ab735 100644 --- a/test/unit/B20Asset/multiplier/multiplier.t.sol +++ b/test/unit/B20Asset/multiplier/multiplier.t.sol @@ -20,10 +20,11 @@ contract B20AssetMultiplierTest is B20AssetTest { } /// @notice Verifies the multiplier reads back the stored value after a write - /// @dev Fuzz over arbitrary non-zero multipliers; the read surface returns the stored value - /// verbatim (no rescaling, no clamping). + /// @dev Fuzz over the valid multiplier range; the read surface returns the stored value + /// verbatim (no rescaling, no clamping). The setter caps the multiplier at + /// `type(uint128).max`, so the fuzz range mirrors that. function test_multiplier_success_returnsStoredValue(uint256 newMultiplier) public { - newMultiplier = bound(newMultiplier, 1, type(uint256).max); + newMultiplier = bound(newMultiplier, 1, type(uint128).max); _updateMultiplier(newMultiplier); assertEq(asset().multiplier(), newMultiplier, "multiplier must equal the last written value"); assertEq( diff --git a/test/unit/B20Asset/multiplier/newUIMultiplier.t.sol b/test/unit/B20Asset/multiplier/newUIMultiplier.t.sol new file mode 100644 index 0000000..6970547 --- /dev/null +++ b/test/unit/B20Asset/multiplier/newUIMultiplier.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; + +contract B20AssetNewUIMultiplierTest is B20AssetTest { + /// @notice Verifies a live pending exposes its target and effective time + /// @dev While `effectiveAt > block.timestamp`: `newUIMultiplier()` is the scheduled target, + /// `effectiveAt()` is the schedule time, and `uiMultiplier()` still reads the old value. + function test_newUIMultiplier_success_reportsLivePending(uint256 newMultiplier, uint256 effectiveAt) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + vm.assume(newMultiplier != asset().WAD_PRECISION()); + effectiveAt = bound(effectiveAt, block.timestamp + 1, type(uint64).max); + + uint256 oldMultiplier = asset().uiMultiplier(); + _setUIMultiplier(newMultiplier, effectiveAt); + + assertEq(asset().newUIMultiplier(), newMultiplier, "newUIMultiplier must report the live pending target"); + assertEq(asset().effectiveAt(), effectiveAt, "effectiveAt must report the schedule time"); + assertEq(asset().uiMultiplier(), oldMultiplier, "uiMultiplier must still read the pre-schedule value"); + } + + /// @notice Verifies a matured-but-uncancelled pending mirrors uiMultiplier and keeps its past effectiveAt + /// @dev Once matured, `effectiveAt <= block.timestamp` reads as "no live pending": + /// `newUIMultiplier() == uiMultiplier()` (both the matured value). The stored `effectiveAt` + /// retains its (now past) value until the next set/cancel materializes it + function test_newUIMultiplier_success_maturedMirrorsUiMultiplier(uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + uint256 effectiveAt = block.timestamp + 5 days; + _setUIMultiplier(newMultiplier, effectiveAt); + vm.warp(effectiveAt + 1); + + assertEq(asset().newUIMultiplier(), asset().uiMultiplier(), "matured: newUIMultiplier == uiMultiplier"); + assertEq(asset().newUIMultiplier(), newMultiplier, "matured: both read the matured value"); + assertEq(asset().effectiveAt(), effectiveAt, "matured effectiveAt is retained (past) until re-set"); + } +} diff --git a/test/unit/B20Asset/multiplier/reorder.t.sol b/test/unit/B20Asset/multiplier/reorder.t.sol new file mode 100644 index 0000000..6f8870b --- /dev/null +++ b/test/unit/B20Asset/multiplier/reorder.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; + +import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; + +/// @title Reorder-in-one-bracket e2e. +/// +/// @notice The single-pending model rejects overlapping schedules, so a corporate action is +/// re-ordered by cancelling and re-scheduling atomically inside one `announce()` bracket +/// Both inner calls run via self-`delegatecall` preserving `msg.sender`, +/// so the operator's `OPERATOR_ROLE` gate passes on each. +contract B20AssetReorderTest is B20AssetTest { + function test_reorder_success_cancelThenScheduleInOneBracket() public { + uint256 firstEffectiveAt = block.timestamp + 1 days; + _setUIMultiplier(2e18, firstEffectiveAt); + + uint256 secondMultiplier = 3e18; + uint256 secondEffectiveAt = block.timestamp + 2 days; + + bytes[] memory calls = new bytes[](2); + calls[0] = abi.encodeCall(IB20Asset.cancelScheduledMultiplier, ()); + calls[1] = abi.encodeCall(IB20Asset.setUIMultiplier, (secondMultiplier, secondEffectiveAt)); + + _grantOperator(); + _announce(operator, calls, "reorder-2026-Q3", "reorder split", "https://disclosures.example/"); + + // The live pending is now the second schedule; the first was cancelled, not overlapped. + assertEq(asset().newUIMultiplier(), secondMultiplier, "pending target must be the re-scheduled multiplier"); + assertEq(asset().effectiveAt(), secondEffectiveAt, "pending effectiveAt must be the re-scheduled time"); + assertEq( + asset().uiMultiplier(), + asset().WAD_PRECISION(), + "current multiplier unchanged until the new schedule matures" + ); + + vm.warp(secondEffectiveAt); + assertEq(asset().uiMultiplier(), secondMultiplier, "re-scheduled multiplier flips in on maturity"); + } +} diff --git a/test/unit/B20Asset/multiplier/scaledBalanceOf.t.sol b/test/unit/B20Asset/multiplier/scaledBalanceOf.t.sol index 63add21..43f4584 100644 --- a/test/unit/B20Asset/multiplier/scaledBalanceOf.t.sol +++ b/test/unit/B20Asset/multiplier/scaledBalanceOf.t.sol @@ -10,7 +10,7 @@ contract B20AssetScaledBalanceOfTest is B20AssetTest { /// @dev Property: empty balance => zero scaled balance regardless of the multiplier. function test_scaledBalanceOf_success_zeroForEmptyAccount(address account, uint256 newMultiplier) public { _assumeValidActor(account); - newMultiplier = bound(newMultiplier, 1, type(uint256).max); + newMultiplier = bound(newMultiplier, 1, type(uint128).max); _updateMultiplier(newMultiplier); assertEq(asset().scaledBalanceOf(account), 0, "empty account must have zero scaled balance"); } diff --git a/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol b/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol new file mode 100644 index 0000000..2f1a1d9 --- /dev/null +++ b/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; + +import {IB20} from "base-std/interfaces/IB20.sol"; +import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; +import {IScaledUIAmount} from "base-std/interfaces/IScaledUIAmount.sol"; + +contract B20AssetSetUIMultiplierTest is B20AssetTest { + /// @notice Verifies setUIMultiplier emits UIMultiplierUpdated(old, new, effectiveAt) + function test_setUIMultiplier_success_emitsEvent(uint256 newMultiplier, uint256 effectiveAt) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + effectiveAt = bound(effectiveAt, block.timestamp + 1, type(uint64).max); + _grantOperator(); + + uint256 oldMultiplier = asset().multiplier(); + vm.expectEmit(false, false, false, true, address(token)); + emit IScaledUIAmount.UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAt); + vm.prank(operator); + asset().setUIMultiplier(newMultiplier, effectiveAt); + } + + /// @notice Verifies the effective multiplier flips lazily exactly at `effectiveAt` + function test_setUIMultiplier_success_lazyFlipAtBoundary(uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + vm.assume(newMultiplier != asset().WAD_PRECISION()); + uint256 effectiveAt = block.timestamp + 7 days; + uint256 oldMultiplier = asset().multiplier(); + + _setUIMultiplier(newMultiplier, effectiveAt); + + vm.warp(effectiveAt - 1); + assertEq(asset().uiMultiplier(), oldMultiplier, "T-1: must still read the old multiplier"); + + vm.warp(effectiveAt); + assertEq(asset().uiMultiplier(), newMultiplier, "T: must read the new multiplier"); + + vm.warp(effectiveAt + 1); + assertEq(asset().uiMultiplier(), newMultiplier, "T+1: must still read the new multiplier"); + } + + /// @notice Verifies setUIMultiplier reverts when the caller lacks OPERATOR_ROLE + function test_setUIMultiplier_revert_unauthorized(address caller, uint256 newMultiplier) public { + _assumeValidCaller(caller); + vm.assume(caller != admin); + vm.assume(caller != operator); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, OPERATOR_ROLE)); + asset().setUIMultiplier(newMultiplier, block.timestamp + 1); + } + + /// @notice Verifies setUIMultiplier reverts on a zero multiplier + function test_setUIMultiplier_revert_zeroMultiplier() public { + _grantOperator(); + vm.prank(operator); + vm.expectRevert(IB20Asset.InvalidMultiplier.selector); + asset().setUIMultiplier(0, block.timestamp + 1); + } + + /// @notice Verifies setUIMultiplier reverts above the uint128 ceiling + function test_setUIMultiplier_revert_aboveUint128Ceiling(uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, uint256(type(uint128).max) + 1, type(uint256).max); + _grantOperator(); + vm.prank(operator); + vm.expectRevert(IB20Asset.InvalidMultiplier.selector); + asset().setUIMultiplier(newMultiplier, block.timestamp + 1); + } + + /// @notice Verifies setUIMultiplier reverts when effectiveAt is not in the future + function test_setUIMultiplier_revert_effectiveAtInPast(uint256 effectiveAt) public { + effectiveAt = bound(effectiveAt, 0, block.timestamp); + _grantOperator(); + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(IB20Asset.EffectiveAtInPast.selector, effectiveAt)); + asset().setUIMultiplier(2e18, effectiveAt); + } + + /// @notice Verifies setUIMultiplier reverts when effectiveAt exceeds the uint64 storage width + function test_setUIMultiplier_revert_effectiveAtTooFar(uint256 effectiveAt) public { + effectiveAt = bound(effectiveAt, uint256(type(uint64).max) + 1, type(uint256).max); + _grantOperator(); + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(IB20Asset.EffectiveAtTooFar.selector, effectiveAt)); + asset().setUIMultiplier(2e18, effectiveAt); + } + + /// @notice Verifies setUIMultiplier reverts when a live pending update already exists + function test_setUIMultiplier_revert_scheduleOverlap(uint256 firstEffectiveAt, uint256 secondEffectiveAt) public { + firstEffectiveAt = bound(firstEffectiveAt, block.timestamp + 1, type(uint64).max); + secondEffectiveAt = bound(secondEffectiveAt, block.timestamp + 1, type(uint64).max); + _setUIMultiplier(2e18, firstEffectiveAt); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(IB20Asset.ScheduleOverlap.selector, firstEffectiveAt)); + asset().setUIMultiplier(3e18, secondEffectiveAt); + } +} diff --git a/test/unit/B20Asset/multiplier/toRawBalance.t.sol b/test/unit/B20Asset/multiplier/toRawBalance.t.sol index b1ec972..56808c9 100644 --- a/test/unit/B20Asset/multiplier/toRawBalance.t.sol +++ b/test/unit/B20Asset/multiplier/toRawBalance.t.sol @@ -30,7 +30,7 @@ contract B20AssetToRawBalanceTest is B20AssetTest { /// @notice Verifies toRawBalance of zero scaled balance is zero regardless of the multiplier /// @dev Degenerate input edge: any multiplier divided into zero is zero. function test_toRawBalance_success_zeroScaledBalance(uint256 newMultiplier) public { - newMultiplier = bound(newMultiplier, 1, type(uint256).max); + newMultiplier = bound(newMultiplier, 1, type(uint128).max); _updateMultiplier(newMultiplier); assertEq(asset().toRawBalance(0), 0, "zero scaled balance must produce zero raw balance"); } diff --git a/test/unit/B20Asset/multiplier/toScaledBalance.t.sol b/test/unit/B20Asset/multiplier/toScaledBalance.t.sol index 0b595fb..c86db36 100644 --- a/test/unit/B20Asset/multiplier/toScaledBalance.t.sol +++ b/test/unit/B20Asset/multiplier/toScaledBalance.t.sol @@ -30,7 +30,7 @@ contract B20AssetToScaledBalanceTest is B20AssetTest { /// @notice Verifies toScaledBalance of zero rawBalance is zero regardless of the multiplier /// @dev Degenerate input edge: any multiplier multiplied into zero is zero. function test_toScaledBalance_success_zeroRawBalance(uint256 newMultiplier) public { - newMultiplier = bound(newMultiplier, 1, type(uint256).max); + newMultiplier = bound(newMultiplier, 1, type(uint128).max); _updateMultiplier(newMultiplier); assertEq(asset().toScaledBalance(0), 0, "zero rawBalance must produce zero scaled balance"); } @@ -56,7 +56,10 @@ contract B20AssetToScaledBalanceTest is B20AssetTest { /// to avoid the overflow, leaving the boundary itself untested. A generic expectRevert keeps /// the assertion robust across the mock (Panic) and the live precompile's overflow error. function test_toScaledBalance_revert_arithmeticOverflow(uint256 rawBalance, uint256 newMultiplier) public { - newMultiplier = bound(newMultiplier, 2, type(uint256).max); + // The multiplier is capped at `type(uint128).max` by the setter; overflow is still + // reachable because `rawBalance` (an arbitrary conversion input, not bounded by supply) + // can be pushed high enough that `rawBalance * multiplier` exceeds `type(uint256).max`. + newMultiplier = bound(newMultiplier, 2, type(uint128).max); // Force rawBalance * multiplier strictly above type(uint256).max. rawBalance = bound(rawBalance, type(uint256).max / newMultiplier + 1, type(uint256).max); _updateMultiplier(newMultiplier); diff --git a/test/unit/B20Asset/multiplier/totalSupplyUI.t.sol b/test/unit/B20Asset/multiplier/totalSupplyUI.t.sol new file mode 100644 index 0000000..8a3d5d6 --- /dev/null +++ b/test/unit/B20Asset/multiplier/totalSupplyUI.t.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; + +contract B20AssetTotalSupplyUITest is B20AssetTest { + /// @notice Verifies totalSupplyUI scales the raw total supply by the active multiplier + /// @dev Property: totalSupplyUI == totalSupply * multiplier / WAD. + function test_totalSupplyUI_success_scalesByMultiplier(uint256 amount, uint256 newMultiplier) public { + amount = bound(amount, 1, type(uint128).max); + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + _mint(alice, amount); + _updateMultiplier(newMultiplier); + assertEq( + asset().totalSupplyUI(), + (token.totalSupply() * newMultiplier) / asset().WAD_PRECISION(), + "totalSupplyUI must apply totalSupply * multiplier / WAD" + ); + } + + /// @notice Verifies totalSupplyUI is zero when no supply has been minted + function test_totalSupplyUI_success_zeroWhenNoSupply(uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + _updateMultiplier(newMultiplier); + assertEq(asset().totalSupplyUI(), 0, "no supply: totalSupplyUI must be zero regardless of multiplier"); + } +} diff --git a/test/unit/B20Asset/multiplier/updateMultiplier.t.sol b/test/unit/B20Asset/multiplier/updateMultiplier.t.sol index d24f392..ff4ad44 100644 --- a/test/unit/B20Asset/multiplier/updateMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/updateMultiplier.t.sol @@ -5,6 +5,7 @@ import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; import {IB20} from "base-std/interfaces/IB20.sol"; import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; +import {IScaledUIAmount} from "base-std/interfaces/IScaledUIAmount.sol"; import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; @@ -33,12 +34,21 @@ contract B20AssetUpdateMultiplierTest is B20AssetTest { asset().updateMultiplier(0); } + /// @notice Verifies updateMultiplier reverts when newMultiplier exceeds the uint128 ceiling + function test_updateMultiplier_revert_aboveUint128Ceiling(uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, uint256(type(uint128).max) + 1, type(uint256).max); + _grantOperator(); + vm.prank(operator); + vm.expectRevert(IB20Asset.InvalidMultiplier.selector); + asset().updateMultiplier(newMultiplier); + } + /// @notice Verifies updateMultiplier writes the new value to the stored slot /// @dev State invariant: the stored slot holds the supplied multiplier verbatim (no clamping, /// no scaling). Paired slot assertion verifies the storage write lands at the /// multiplier slot. function test_updateMultiplier_success_writesSlot(uint256 newMultiplier) public { - vm.assume(newMultiplier != 0); + newMultiplier = bound(newMultiplier, 1, type(uint128).max); _updateMultiplier(newMultiplier); assertEq( uint256(vm.load(address(token), MockB20AssetStorage.multiplierSlot())), @@ -47,14 +57,15 @@ contract B20AssetUpdateMultiplierTest is B20AssetTest { ); } - /// @notice Verifies updateMultiplier emits MultiplierUpdated with the new value - /// @dev Event integrity for the rotation; subscribers depend on this event to - /// re-derive holder scaled balances off-chain. + /// @notice Verifies updateMultiplier emits UIMultiplierUpdated(old, new, block.timestamp) + /// @dev Event integrity for the instant failsafe: ERC-8056 requires the multiplier-change + /// event on every update. function test_updateMultiplier_success_emitsEvent(uint256 newMultiplier) public { - vm.assume(newMultiplier != 0); + newMultiplier = bound(newMultiplier, 1, type(uint128).max); _grantOperator(); + uint256 oldMultiplier = asset().multiplier(); vm.expectEmit(false, false, false, true, address(token)); - emit IB20Asset.MultiplierUpdated(newMultiplier); + emit IScaledUIAmount.UIMultiplierUpdated(oldMultiplier, newMultiplier, block.timestamp); vm.prank(operator); asset().updateMultiplier(newMultiplier); } diff --git a/test/unit/storage/B20AssetFullLayout.t.sol b/test/unit/storage/B20AssetFullLayout.t.sol index 33789fc..ccd774f 100644 --- a/test/unit/storage/B20AssetFullLayout.t.sol +++ b/test/unit/storage/B20AssetFullLayout.t.sol @@ -24,17 +24,23 @@ contract B20AssetFullLayoutTest is B20AssetTest { /// both zero (the "unwritten = WAD" default) and WAD itself. uint256 internal constant MULTIPLIER_MARKER = 2.5e18; + /// @dev Distinct pending multiplier + a future delay so slot 4 packs an observably + /// non-zero `(multiplier, effectiveAt)`. + uint128 internal constant PENDING_MULTIPLIER = 3e18; + uint256 internal constant PENDING_DELAY = 365 days; + string internal constant REFERENCE_VALUE = "REF-2024-001"; string internal constant ANNOUNCEMENT_ID = "layout-pin-announcement"; /// @notice Cross-cuts every field of the asset-variant namespace in /// one populated snapshot. - /// @dev Field coverage for `base.b20.asset` (slots 0..3): + /// @dev Field coverage for `base.b20.asset` (slots 0..4): /// - 0: decimals (factory-written at creation) /// - 1: multiplier /// - 2: usedAnnouncementIds[id] /// - 3: extraMetadata[key] (one example key mutated; the other /// left empty to confirm the factory seeds no entries) + /// - 4: pending (packed uint128 multiplier | uint64 effectiveAt) function test_b20AssetLayout_success_populatedSnapshotMatchesAllSlots() public { // ---------- Populate ---------- _populate(); @@ -80,6 +86,14 @@ contract B20AssetFullLayoutTest is B20AssetTest { _expectedStringFieldSlot(REFERENCE_VALUE), "asset slot 3: extraMetadata[example_3] must hold the post-creation short-string encoding" ); + + // ---------- pending (slot 4, packed) ---------- + // uint128 multiplier in the low 128 bits; uint64 effectiveAt in the next 64. + assertEq( + uint256(vm.load(tokenAddr, MockB20AssetStorage.pendingSlot())), + MockB20AssetStorage.packPendingMultiplier(PENDING_MULTIPLIER, uint64(block.timestamp + PENDING_DELAY)), + "asset slot 4: pending must hold the packed (multiplier, effectiveAt)" + ); } /// @notice Populates the asset variant with non-default values @@ -89,6 +103,9 @@ contract B20AssetFullLayoutTest is B20AssetTest { function _populate() internal { // multiplier: write the non-WAD marker via the public surface. _updateMultiplier(MULTIPLIER_MARKER); + // pending: schedule a live pending via the public surface. `updateMultiplier` above cleared + // any pending, so this leaves slot 1 (current) at MULTIPLIER_MARKER and populates slot 4. + _setUIMultiplier(PENDING_MULTIPLIER, block.timestamp + PENDING_DELAY); // extraMetadata[example_3]: post-creation metadata-admin write. The // factory does not seed any entry at creation; every other key // defaults to empty. From 307c63959ee06681c1335b1b53c956ade8e6e95b Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 21 Jul 2026 13:50:12 -0400 Subject: [PATCH 2/2] test(b20-asset): restore pending-multiplier codec coverage Re-adds test/unit/storage/MockB20AssetSlotHelpers.t.sol, the sole caller of MockB20AssetStorage.pendingMultiplierValue / pendingEffectiveAt. It had been dropped, leaving those packed-slot decoders uncovered and failing the Interface Coverage CI gate on #173. Co-authored-by: Cursor --- .../storage/MockB20AssetSlotHelpers.t.sol | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/unit/storage/MockB20AssetSlotHelpers.t.sol diff --git a/test/unit/storage/MockB20AssetSlotHelpers.t.sol b/test/unit/storage/MockB20AssetSlotHelpers.t.sol new file mode 100644 index 0000000..82355ba --- /dev/null +++ b/test/unit/storage/MockB20AssetSlotHelpers.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; +import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; + +/// @notice Self-tests for `MockB20AssetStorage`'s pending-slot packed codecs. +/// +/// @dev The pending multiplier lives in a single packed slot (offset 4 of the `base.b20.asset` +/// namespace: `uint128 multiplier | uint64 effectiveAt`). These verify the codecs' bit math +/// matches Solidity's struct packing — both as a pure round-trip and against a real slot +/// write — so a codec drifting from the canonical `PendingMultiplier` layout fails CI. The +/// Rust precompile impl uses the same helper outputs as its ground truth. +contract MockB20AssetSlotHelpersTest is B20AssetTest { + /// @notice Verifies `packPendingMultiplier` is the inverse of the lane decoders. + /// @dev Round-trip: pack a uint128 + uint64, decode, expect the inputs back. + function test_packPendingMultiplier_success_roundtrips(uint128 multiplier, uint64 effectiveAt) public pure { + uint256 packed = MockB20AssetStorage.packPendingMultiplier(multiplier, effectiveAt); + assertEq(MockB20AssetStorage.pendingMultiplierValue(packed), multiplier, "multiplier lane"); + assertEq(MockB20AssetStorage.pendingEffectiveAt(packed), effectiveAt, "effectiveAt lane"); + } + + /// @notice Verifies the lane decoders read a real scheduled pending back out of slot 4. + /// @dev Read-after-write: schedule via the public surface (which writes through the + /// `PendingMultiplier` struct), then `vm.load` slot 4 and decode both lanes. + function test_pendingSlot_success_decodesScheduledPending(uint256 newMultiplier, uint256 effectiveAt) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + effectiveAt = bound(effectiveAt, block.timestamp + 1, type(uint64).max); + + _setUIMultiplier(newMultiplier, effectiveAt); + + uint256 packed = uint256(vm.load(address(token), MockB20AssetStorage.pendingSlot())); + assertEq( + uint256(MockB20AssetStorage.pendingMultiplierValue(packed)), + newMultiplier, + "pendingMultiplierValue must reflect the scheduled multiplier" + ); + assertEq( + uint256(MockB20AssetStorage.pendingEffectiveAt(packed)), + effectiveAt, + "pendingEffectiveAt must reflect the scheduled effectiveAt" + ); + } +}