From d3010da8aeae7d13b41de45783e1955efee33953 Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 4 Aug 2026 16:34:06 -0500 Subject: [PATCH 01/11] chore(BOP-495): rename IScaledUIAmount.sol -> IERC8056.sol Rename the ERC-8056 interface file to IERC8056.sol per the Aug 4 2026 B20 interface review. File rename only: the three interface identifiers (IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances) and every wire symbol are unchanged. Updates all import paths and one doc comment reference. Co-authored-by: Cursor --- script/smoke/config.py | 2 +- src/interfaces/IB20Asset.sol | 2 +- src/interfaces/{IScaledUIAmount.sol => IERC8056.sol} | 0 test/lib/mocks/MockB20Asset.sol | 2 +- test/unit/B20Asset/erc165/supportsInterface.t.sol | 2 +- test/unit/B20Asset/multiplier/materialize.t.sol | 2 +- test/unit/B20Asset/multiplier/setUIMultiplier.t.sol | 2 +- test/unit/B20Asset/multiplier/updateMultiplier.t.sol | 2 +- 8 files changed, 7 insertions(+), 7 deletions(-) rename src/interfaces/{IScaledUIAmount.sol => IERC8056.sol} (100%) diff --git a/script/smoke/config.py b/script/smoke/config.py index aabdeab..fe1200d 100644 --- a/script/smoke/config.py +++ b/script/smoke/config.py @@ -64,7 +64,7 @@ def amt(whole: int, decimals: int) -> int: STABLECOIN_DECIMALS = 6 # ERC-165 + ERC-8056 interface ids advertised by the Asset variant (AssetV2 @ Cobalt). See -# src/interfaces/IScaledUIAmount.sol; `supportsInterface(SCALED_UI_AMOUNT_ID)` doubles as the +# src/interfaces/IERC8056.sol; `supportsInterface(SCALED_UI_AMOUNT_ID)` doubles as the # probe that tells a Cobalt (ERC-8056 scheduled multiplier) chain apart from a pre-Cobalt one. ERC165_ID = bytes.fromhex("01ffc9a7") SCALED_UI_AMOUNT_ID = bytes.fromhex("a60bf13d") diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 29b7037..9703a18 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -3,7 +3,7 @@ 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"; +import {IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances} from "./IERC8056.sol"; /// @title IB20Asset /// @author Coinbase diff --git a/src/interfaces/IScaledUIAmount.sol b/src/interfaces/IERC8056.sol similarity index 100% rename from src/interfaces/IScaledUIAmount.sol rename to src/interfaces/IERC8056.sol diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 6420ade..e77b82d 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -8,7 +8,7 @@ import { IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances -} from "base-std/interfaces/IScaledUIAmount.sol"; +} from "base-std/interfaces/IERC8056.sol"; import {MockB20} from "base-std-test/lib/mocks/MockB20.sol"; import {MockB20AssetStorage, MockB20Storage} from "base-std-test/lib/mocks/MockB20Storage.sol"; diff --git a/test/unit/B20Asset/erc165/supportsInterface.t.sol b/test/unit/B20Asset/erc165/supportsInterface.t.sol index bdb5126..d978c6b 100644 --- a/test/unit/B20Asset/erc165/supportsInterface.t.sol +++ b/test/unit/B20Asset/erc165/supportsInterface.t.sol @@ -8,7 +8,7 @@ import { IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances -} from "base-std/interfaces/IScaledUIAmount.sol"; +} from "base-std/interfaces/IERC8056.sol"; contract B20AssetSupportsInterfaceTest is B20AssetTest { // Published ERC-8056 / ERC-165 interface identifiers. diff --git a/test/unit/B20Asset/multiplier/materialize.t.sol b/test/unit/B20Asset/multiplier/materialize.t.sol index 71828b9..cb441d0 100644 --- a/test/unit/B20Asset/multiplier/materialize.t.sol +++ b/test/unit/B20Asset/multiplier/materialize.t.sol @@ -6,7 +6,7 @@ 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 {IScaledUIAmount} from "base-std/interfaces/IERC8056.sol"; import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; diff --git a/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol b/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol index 2f1a1d9..1c79887 100644 --- a/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol @@ -5,7 +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 {IScaledUIAmount} from "base-std/interfaces/IERC8056.sol"; contract B20AssetSetUIMultiplierTest is B20AssetTest { /// @notice Verifies setUIMultiplier emits UIMultiplierUpdated(old, new, effectiveAt) diff --git a/test/unit/B20Asset/multiplier/updateMultiplier.t.sol b/test/unit/B20Asset/multiplier/updateMultiplier.t.sol index ff4ad44..6da8042 100644 --- a/test/unit/B20Asset/multiplier/updateMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/updateMultiplier.t.sol @@ -5,7 +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 {IScaledUIAmount} from "base-std/interfaces/IERC8056.sol"; import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; From d34ea7f16fe756e1321fd86d68cc70a6ec56ca2d Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 4 Aug 2026 16:41:29 -0500 Subject: [PATCH 02/11] refactor(BOP-495): adopt UI-multiplier vocabulary for Cobalt-only errors/events Per the Aug 4 2026 B20 interface review, rename the Cobalt-only ERC-8056 scheduled-multiplier symbols to the "UI Multiplier" vocabulary: error ScheduleOverlap -> PendingUpdateExists error NoScheduledMultiplier -> NoScheduledUIMultiplier event MultiplierUpdateCancelled -> UIMultiplierUpdateCancelled Updates the IB20Asset interface, the MockB20Asset reference implementation, the affected unit tests, the scheduled-multiplier smoke journey, and the docs. These symbols are Cobalt-only (AssetV2) and not yet live, so the rename is safe. Co-authored-by: Cursor --- docs/B20/Asset.md | 4 +-- script/smoke/README.md | 2 +- script/smoke/journeys/scheduled_multiplier.py | 26 +++++++++---------- src/interfaces/IB20Asset.sol | 10 +++---- test/lib/mocks/MockB20Asset.sol | 8 +++--- .../cancelScheduledMultiplier.t.sol | 8 +++--- .../B20Asset/multiplier/materialize.t.sol | 8 +++--- .../B20Asset/multiplier/setUIMultiplier.t.sol | 6 +++-- 8 files changed, 37 insertions(+), 35 deletions(-) diff --git a/docs/B20/Asset.md b/docs/B20/Asset.md index fd7db2d..35041dc 100644 --- a/docs/B20/Asset.md +++ b/docs/B20/Asset.md @@ -14,7 +14,7 @@ Both multiplier setters validate `newMultiplier` is non-zero and at most `type(u 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). +Only **one pending update is live at a time**. Attempting to schedule over an existing pending update reverts `PendingUpdateExists`. 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 `NoScheduledUIMultiplier` 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. @@ -29,7 +29,7 @@ The Asset variant conforms to [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056 - `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. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. +**Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `setUIMultiplier` and from `updateMultiplier`. `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelScheduledMultiplier` and by `updateMultiplier` when it clears a live pending. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. ### Precision & decimals diff --git a/script/smoke/README.md b/script/smoke/README.md index 21b9261..28db654 100644 --- a/script/smoke/README.md +++ b/script/smoke/README.md @@ -110,7 +110,7 @@ Seven "journeys", run as a whole suite (a single journey can still be run via th |---|---| | `factory` | Deterministic create + address prediction, the `isB20` / `isB20Initialized` query surface, and creation-time reverts (duplicate salt, bad decimals, bad currency, unknown variant). | | `asset` | Full Asset-variant lifecycle (18 decimals): mint, transfer, `transferWithMemo`, delegated `transferFrom`, `announce` + `batchMint`, rebase via `updateMultiplier`, metadata, burn, then the gates that must reject (supply cap, pause, role, announcement-id reuse). The rebase event is fork-aware (V1 `MultiplierUpdated` vs Cobalt `UIMultiplierUpdated`). | -| `multiplier` | ERC-8056 scheduled multiplier (AssetV2 @ Cobalt): `setUIMultiplier` scheduling + its guards (`InvalidMultiplier`, `EffectiveAtInPast`, `EffectiveAtTooFar`, `ScheduleOverlap`), `cancelScheduledMultiplier` (+ `NoScheduledMultiplier`), the `updateMultiplier` instant-failsafe V2 event semantics (`UIMultiplierUpdated` + `MultiplierUpdateCancelled`, *not* `MultiplierUpdated`), the read aliases (`uiMultiplier`/`balanceOfUI`/`totalSupplyUI`), and ERC-165 advertisement. **Skips** cleanly on a pre-Cobalt chain (probed via `supportsInterface(0xa60bf13d)`). | +| `multiplier` | ERC-8056 scheduled multiplier (AssetV2 @ Cobalt): `setUIMultiplier` scheduling + its guards (`InvalidMultiplier`, `EffectiveAtInPast`, `EffectiveAtTooFar`, `PendingUpdateExists`), `cancelScheduledMultiplier` (+ `NoScheduledUIMultiplier`), the `updateMultiplier` instant-failsafe V2 event semantics (`UIMultiplierUpdated` + `UIMultiplierUpdateCancelled`, *not* `MultiplierUpdated`), the read aliases (`uiMultiplier`/`balanceOfUI`/`totalSupplyUI`), and ERC-165 advertisement. **Skips** cleanly on a pre-Cobalt chain (probed via `supportsInterface(0xa60bf13d)`). | | `stablecoin` | Stablecoin-variant deltas (fixed 6 decimals, immutable currency) plus the regulated freeze-and-seize path (blocklist policy + `burnBlocked`). | | `seize` | Transfer-based seize (AssetV2 @ Cobalt): the `SEIZE_HOLDER_POLICY` membership gate + `SEIZE_ROLE`, `seizeWithMemo` (`Transfer` -> `Memo` -> `Seized`, supply-preserving), its reject gates (`AccountNotSeizable`, role, `InvalidReceiver`, `ContractPaused`), the admin-op decoupling from the receiver policy on `to`, and the independent `SEIZE` pause vector. **Skips** cleanly on a pre-Cobalt chain (probed via the `SEIZE_HOLDER_POLICY()` getter). Complements `stablecoin`, which covers the legacy burn-based `burnBlocked`. | | `policy` | Policy creation (both types), membership, built-in sentinels, the two-step admin transfer lifecycle, and a token actually *enforcing* a policy (`PolicyForbids` on transfer + mint). | diff --git a/script/smoke/journeys/scheduled_multiplier.py b/script/smoke/journeys/scheduled_multiplier.py index 9f84208..ad75f62 100644 --- a/script/smoke/journeys/scheduled_multiplier.py +++ b/script/smoke/journeys/scheduled_multiplier.py @@ -24,10 +24,10 @@ from ..codec import AssetCreateParams, init_call # ERC-8056 events. UIMultiplierUpdated is emitted by both setUIMultiplier and (on V2) updateMultiplier; -# MultiplierUpdateCancelled by cancelScheduledMultiplier and by updateMultiplier when it clears a +# UIMultiplierUpdateCancelled by cancelScheduledMultiplier and by updateMultiplier when it clears a # live pending. V1_UPDATED is the superseded V1 event that V2's updateMultiplier must NOT emit. UI_UPDATED = "UIMultiplierUpdated(uint256,uint256,uint256)" -CANCELLED = "MultiplierUpdateCancelled(uint256,uint256)" +CANCELLED = "UIMultiplierUpdateCancelled(uint256,uint256)" V1_UPDATED = "MultiplierUpdated(uint256)" WAD = config.amt(1, 18) @@ -82,7 +82,7 @@ def _current_multiplier_and_aliases(c: Chain, tok) -> None: def _schedule_reverts(c: Chain, tok) -> None: - # No live pending exists yet, so ScheduleOverlap cannot fire — each guard is the binding revert. + # No live pending exists yet, so PendingUpdateExists cannot fire — each guard is the binding revert. # Every non-target argument is kept valid so the intended check is what reverts (mirrors the reference). step(4, "setUIMultiplier input guards: InvalidMultiplier / EffectiveAtInPast / EffectiveAtTooFar") future = _now(c) + 3600 @@ -110,38 +110,38 @@ def _schedule_and_cancel(c: Chain, tok) -> None: c.assert_eq(tok.functions.effectiveAt().call(), sched, "effectiveAt() == schedule time") c.assert_eq(tok.functions.uiMultiplier().call(), old, "uiMultiplier() still reads the old value while pending is future") - step(6, "a second setUIMultiplier while a live pending exists -> ScheduleOverlap") - c.expect_revert("ScheduleOverlap", tok.functions.setUIMultiplier(config.amt(4, 18), _now(c) + 7200), c.DEPLOYER) + step(6, "a second setUIMultiplier while a live pending exists -> PendingUpdateExists") + c.expect_revert("PendingUpdateExists", tok.functions.setUIMultiplier(config.amt(4, 18), _now(c) + 7200), c.DEPLOYER) - step(7, "cancelScheduledMultiplier clears the live pending -> MultiplierUpdateCancelled, effectiveAt() == 0") + step(7, "cancelScheduledMultiplier clears the live pending -> UIMultiplierUpdateCancelled, effectiveAt() == 0") receipt = c.send(tok.functions.cancelScheduledMultiplier(), c.deployer) - cancelled = c.event_args(receipt, tok, "MultiplierUpdateCancelled") + cancelled = c.event_args(receipt, tok, "UIMultiplierUpdateCancelled") c.assert_eq( [cancelled["cancelledMultiplier"], cancelled["cancelledEffectiveAt"]], [target, sched], - "MultiplierUpdateCancelled payload == (cancelled target, cancelled effectiveAt)", + "UIMultiplierUpdateCancelled payload == (cancelled target, cancelled effectiveAt)", ) c.assert_eq(tok.functions.effectiveAt().call(), 0, "effectiveAt() resets to 0 after cancel") c.assert_eq(tok.functions.newUIMultiplier().call(), tok.functions.uiMultiplier().call(), "no-live-pending: newUIMultiplier() == uiMultiplier()") c.assert_eq(tok.functions.uiMultiplier().call(), old, "cancel leaves the current multiplier untouched") - step(8, "cancelScheduledMultiplier with nothing scheduled -> NoScheduledMultiplier") - c.expect_revert("NoScheduledMultiplier", tok.functions.cancelScheduledMultiplier(), c.DEPLOYER) + step(8, "cancelScheduledMultiplier with nothing scheduled -> NoScheduledUIMultiplier") + c.expect_revert("NoScheduledUIMultiplier", tok.functions.cancelScheduledMultiplier(), c.DEPLOYER) def _failsafe_clears_pending(c: Chain, tok) -> None: - step(9, "updateMultiplier instant-failsafe clears a live pending: UIMultiplierUpdated + MultiplierUpdateCancelled, not MultiplierUpdated") + step(9, "updateMultiplier instant-failsafe clears a live pending: UIMultiplierUpdated + UIMultiplierUpdateCancelled, not MultiplierUpdated") cleared_target, cleared_sched = config.amt(5, 18), _now(c) + 3600 c.send(tok.functions.setUIMultiplier(cleared_target, cleared_sched), c.deployer) receipt = c.send(tok.functions.updateMultiplier(config.amt(6, 18)), c.deployer) c.assert_log(receipt, UI_UPDATED, "updateMultiplier emits UIMultiplierUpdated") # Decode the cancel: it must carry the pending it cleared, not any live pending. - cancelled = c.event_args(receipt, tok, "MultiplierUpdateCancelled") + cancelled = c.event_args(receipt, tok, "UIMultiplierUpdateCancelled") c.assert_eq( [cancelled["cancelledMultiplier"], cancelled["cancelledEffectiveAt"]], [cleared_target, cleared_sched], - "MultiplierUpdateCancelled payload == the pending that updateMultiplier cleared", + "UIMultiplierUpdateCancelled payload == the pending that updateMultiplier cleared", ) c.assert_no_log(receipt, V1_UPDATED, "V2 updateMultiplier does NOT emit the V1 MultiplierUpdated") c.assert_eq(tok.functions.multiplier().call(), config.amt(6, 18), "updateMultiplier sets the current multiplier immediately") diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 9703a18..ab3901d 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -41,10 +41,10 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// @notice `setUIMultiplier` was called while a live pending update already exists /// /// @param pendingEffectiveAt The `effectiveAt` of the live pending update. - error ScheduleOverlap(uint256 pendingEffectiveAt); + error PendingUpdateExists(uint256 pendingEffectiveAt); /// @notice `cancelScheduledMultiplier` was called when there is no live pending update - error NoScheduledMultiplier(); + error NoScheduledUIMultiplier(); /// @notice A batched function was called with parallel arrays of differing lengths. /// @@ -78,7 +78,7 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// /// @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); + event UIMultiplierUpdateCancelled(uint256 cancelledMultiplier, uint256 cancelledEffectiveAt); /// @notice Emitted by `updateExtraMetadata`. An empty `value` indicates removal. event ExtraMetadataUpdated(string key, string value); @@ -188,7 +188,7 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// @dev Reverts with `InvalidMultiplier` when `newMultiplier` is zero or above `type(uint128).max`. /// @dev Reverts with `EffectiveAtInPast` when `effectiveAt` is not in the future. /// @dev Reverts with `EffectiveAtTooFar` when `effectiveAt` exceeds `type(uint64).max`. - /// @dev Reverts with `ScheduleOverlap` when a live pending update already exists. + /// @dev Reverts with `PendingUpdateExists` when a live pending update already exists. /// /// @param newMultiplier New multiplier scaled to `WAD_PRECISION`. /// @param effectiveAt Timestamp at which `newMultiplier` becomes effective; must be in the future. @@ -198,7 +198,7 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// (`effectiveAt` resets to 0). /// /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `OPERATOR_ROLE`. - /// @dev Reverts with `NoScheduledMultiplier` when there is no live pending update. + /// @dev Reverts with `NoScheduledUIMultiplier` when there is no live pending update. function cancelScheduledMultiplier() external; /// @notice Instant failsafe / emergency override — sets the current multiplier immediately and diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index e77b82d..12fa2f4 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -188,7 +188,7 @@ contract MockB20Asset is MockB20, IB20Asset { MockB20AssetStorage.Layout storage $ = MockB20AssetStorage.layout(); uint256 pendingEff = $.pending.effectiveAt; // A live pending blocks a new schedule. - if (pendingEff > block.timestamp) revert ScheduleOverlap(pendingEff); + if (pendingEff > block.timestamp) revert PendingUpdateExists(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; @@ -207,10 +207,10 @@ contract MockB20Asset is MockB20, IB20Asset { uint256 pendingMult = $.pending.multiplier; uint256 pendingEff = $.pending.effectiveAt; // Only a live pending can be cancelled - if (pendingEff <= block.timestamp) revert NoScheduledMultiplier(); + if (pendingEff <= block.timestamp) revert NoScheduledUIMultiplier(); delete $.pending; - emit MultiplierUpdateCancelled(pendingMult, pendingEff); + emit UIMultiplierUpdateCancelled(pendingMult, pendingEff); } /// @notice Sets the current multiplier immediately and clears any pending. @@ -224,7 +224,7 @@ contract MockB20Asset is MockB20, IB20Asset { uint256 old = _multiplier(); $.multiplier = newMultiplier; if (pendingEff != 0) delete $.pending; - if (livePending) emit MultiplierUpdateCancelled(pendingMult, pendingEff); + if (livePending) emit UIMultiplierUpdateCancelled(pendingMult, pendingEff); emit UIMultiplierUpdated(old, newMultiplier, block.timestamp); } diff --git a/test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol b/test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol index cc828f1..372ef82 100644 --- a/test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol @@ -26,14 +26,14 @@ contract B20AssetCancelScheduledMultiplierTest is B20AssetTest { assertEq(asset().newUIMultiplier(), asset().uiMultiplier(), "no-live-pending: newUIMultiplier == uiMultiplier"); } - /// @notice Verifies cancel emits MultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt) + /// @notice Verifies cancel emits UIMultiplierUpdateCancelled(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); + emit IB20Asset.UIMultiplierUpdateCancelled(newMultiplier, effectiveAt); vm.prank(operator); asset().cancelScheduledMultiplier(); } @@ -65,7 +65,7 @@ contract B20AssetCancelScheduledMultiplierTest is B20AssetTest { function test_cancelScheduledMultiplier_revert_noPending() public { _grantOperator(); vm.prank(operator); - vm.expectRevert(IB20Asset.NoScheduledMultiplier.selector); + vm.expectRevert(IB20Asset.NoScheduledUIMultiplier.selector); asset().cancelScheduledMultiplier(); } @@ -77,7 +77,7 @@ contract B20AssetCancelScheduledMultiplierTest is B20AssetTest { vm.warp(effectiveAt); vm.prank(operator); - vm.expectRevert(IB20Asset.NoScheduledMultiplier.selector); + vm.expectRevert(IB20Asset.NoScheduledUIMultiplier.selector); asset().cancelScheduledMultiplier(); } } diff --git a/test/unit/B20Asset/multiplier/materialize.t.sol b/test/unit/B20Asset/multiplier/materialize.t.sol index cb441d0..8067c74 100644 --- a/test/unit/B20Asset/multiplier/materialize.t.sol +++ b/test/unit/B20Asset/multiplier/materialize.t.sol @@ -13,7 +13,7 @@ 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)"); + bytes32 internal constant CANCELLED_SIG = keccak256("UIMultiplierUpdateCancelled(uint256,uint256)"); /// @notice Verifies scheduling over a *matured* pending folds it into the current multiplier function test_setUIMultiplier_success_materializesMaturedPending() public { @@ -55,7 +55,7 @@ contract B20AssetMaterializeTest is B20AssetTest { uint256 old = asset().uiMultiplier(); _grantOperator(); vm.expectEmit(false, false, false, true, address(token)); - emit IB20Asset.MultiplierUpdateCancelled(pendingMultiplier, effectiveAt); + emit IB20Asset.UIMultiplierUpdateCancelled(pendingMultiplier, effectiveAt); vm.expectEmit(false, false, false, true, address(token)); emit IScaledUIAmount.UIMultiplierUpdated(old, instant, block.timestamp); vm.prank(operator); @@ -68,7 +68,7 @@ contract B20AssetMaterializeTest is B20AssetTest { /// @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. + /// silently — `UIMultiplierUpdateCancelled` fires only for a live pending. function test_updateMultiplier_success_clearsMaturedPendingNoCancelEvent() public { uint256 matured = 2e18; uint256 effectiveAt = block.timestamp + 1 days; @@ -85,7 +85,7 @@ contract B20AssetMaterializeTest is B20AssetTest { assertEq( _firstLogIndex(logs, CANCELLED_SIG), -1, - "no MultiplierUpdateCancelled for a matured (already-effective) pending" + "no UIMultiplierUpdateCancelled for a matured (already-effective) pending" ); assertEq(asset().uiMultiplier(), instant, "instant update must take effect immediately"); assertEq( diff --git a/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol b/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol index 1c79887..360056c 100644 --- a/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol @@ -87,13 +87,15 @@ contract B20AssetSetUIMultiplierTest is B20AssetTest { } /// @notice Verifies setUIMultiplier reverts when a live pending update already exists - function test_setUIMultiplier_revert_scheduleOverlap(uint256 firstEffectiveAt, uint256 secondEffectiveAt) public { + function test_setUIMultiplier_revert_pendingUpdateExists(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)); + vm.expectRevert(abi.encodeWithSelector(IB20Asset.PendingUpdateExists.selector, firstEffectiveAt)); asset().setUIMultiplier(3e18, secondEffectiveAt); } } From 32874e71239b2b38d10216349a611f843770a1f0 Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 4 Aug 2026 16:55:51 -0500 Subject: [PATCH 03/11] feat(BOP-495): add updateUIMultiplier alias; deprecate updateMultiplier Advertise the canonical instant-failsafe under the ERC-8056 "UI Multiplier" vocabulary as `updateUIMultiplier` and de-advertise the legacy `updateMultiplier` from IB20Asset. The MockB20Asset reference keeps `updateMultiplier` dialable (delegating to the shared body) so the precompile's retained legacy selector stays cross-validated by the fork tests; a regression test pins that the legacy selector remains callable and still emits UIMultiplierUpdated. B20FactoryLib gains encodeUpdateUIMultiplier (canonical) and keeps encodeUpdateMultiplier (legacy, encoded by signature). Docs and smoke journeys are swept separately in the reconciliation commit. Co-authored-by: Cursor --- src/interfaces/IB20Asset.sol | 10 +++-- src/lib/B20FactoryLib.sol | 13 ++++++- test/lib/B20AssetTest.sol | 6 +-- test/lib/mocks/MockB20Asset.sol | 39 +++++++++++++------ test/regression/B20Renames.t.sol | 39 ++++++++++++++++--- .../B20Asset/multiplier/materialize.t.sol | 12 +++--- ...tiplier.t.sol => updateUIMultiplier.t.sol} | 30 +++++++------- ...l => updateUIMultiplier_revertOrder.t.sol} | 12 +++--- .../encodeUpdateMultiplier.t.sol | 17 ++++++-- 9 files changed, 120 insertions(+), 58 deletions(-) rename test/unit/B20Asset/multiplier/{updateMultiplier.t.sol => updateUIMultiplier.t.sol} (67%) rename test/unit/B20Asset/multiplier/{updateMultiplier_revertOrder.t.sol => updateUIMultiplier_revertOrder.t.sol} (84%) diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index ab3901d..6585790 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -22,7 +22,7 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// @notice `updateExtraMetadata` was called with an empty `key`. error InvalidMetadataKey(); - /// @notice A multiplier setter (`setUIMultiplier` or `updateMultiplier`) was called with a + /// @notice A multiplier setter (`setUIMultiplier` or `updateUIMultiplier`) was called with a /// multiplier of zero or above the `type(uint128).max` overflow guard. error InvalidMultiplier(); @@ -74,7 +74,7 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti //////////////////////////////////////////////////////////////*/ /// @notice A scheduled multiplier update was cancelled. Emitted by `cancelScheduledMultiplier`, - /// and by `updateMultiplier` when it clears a live pending update. + /// and by `updateUIMultiplier` when 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. @@ -94,7 +94,7 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti //////////////////////////////////////////////////////////////*/ /// @notice Required to call `announce`, `setUIMultiplier`, `cancelScheduledMultiplier`, and - /// `updateMultiplier`. The metadata setters (`updateName`, `updateSymbol`, + /// `updateUIMultiplier`. The metadata setters (`updateName`, `updateSymbol`, /// `updateExtraMetadata`) are gated by the inherited `METADATA_ROLE` instead. /// @return Role constant. function OPERATOR_ROLE() external view returns (bytes32); @@ -205,11 +205,13 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// cancels any live pending update without a scheduling window. /// Prefer `setUIMultiplier` for routine corporate actions. /// + /// @dev The precompile also retains the legacy `updateMultiplier(uint256)` selector (identical + /// behavior), dialable but deprecated; it is intentionally no longer advertised here. /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `OPERATOR_ROLE`. /// @dev Reverts with `InvalidMultiplier` when `newMultiplier` is zero or above `type(uint128).max`. /// /// @param newMultiplier New multiplier scaled to `WAD_PRECISION`; must be in `(0, type(uint128).max]`. - function updateMultiplier(uint256 newMultiplier) external; + function updateUIMultiplier(uint256 newMultiplier) external; /*////////////////////////////////////////////////////////////// BATCHED ISSUANCE diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index d288cd2..8ac6739 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -204,10 +204,19 @@ library B20FactoryLib { return abi.encodeCall(IB20Asset.updateExtraMetadata, (key, value)); } - /// @notice Encodes a bootstrap initCall to `IB20Asset.updateMultiplier`. + /// @notice Encodes a bootstrap initCall to the canonical `IB20Asset.updateUIMultiplier`. + /// @param newMultiplier New multiplier, scaled to `WAD_PRECISION`. + function encodeUpdateUIMultiplier(uint256 newMultiplier) internal pure returns (bytes memory) { + return abi.encodeCall(IB20Asset.updateUIMultiplier, (newMultiplier)); + } + + /// @notice Encodes a bootstrap initCall to the deprecated `updateMultiplier(uint256)` selector. + /// @dev The selector is retained (dialable) in the precompile but de-advertised from + /// `IB20Asset`, so it is encoded by signature rather than `abi.encodeCall`. Prefer + /// `encodeUpdateUIMultiplier` for new call sites. /// @param newMultiplier New multiplier, scaled to `WAD_PRECISION`. function encodeUpdateMultiplier(uint256 newMultiplier) internal pure returns (bytes memory) { - return abi.encodeCall(IB20Asset.updateMultiplier, (newMultiplier)); + return abi.encodeWithSignature("updateMultiplier(uint256)", newMultiplier); } /// @notice Encodes an initCall / announce inner call to `IB20Asset.setUIMultiplier` diff --git a/test/lib/B20AssetTest.sol b/test/lib/B20AssetTest.sol index 6743e8d..d520354 100644 --- a/test/lib/B20AssetTest.sol +++ b/test/lib/B20AssetTest.sol @@ -73,12 +73,12 @@ contract B20AssetTest is B20Test { // MULTIPLIER HELPERS // ============================================================ - /// @notice Sets the multiplier via the `operator` actor, lazily - /// granting `OPERATOR_ROLE` on first call. + /// @notice Sets the multiplier immediately via the `operator` actor (canonical + /// `updateUIMultiplier`), lazily granting `OPERATOR_ROLE` on first call. function _updateMultiplier(uint256 newMultiplier) internal { _grantOperator(); vm.prank(operator); - asset().updateMultiplier(newMultiplier); + asset().updateUIMultiplier(newMultiplier); } /// @notice Schedules a pending multiplier via the `operator` actor, diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 12fa2f4..7bad2b2 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -213,19 +213,17 @@ contract MockB20Asset is MockB20, IB20Asset { emit UIMultiplierUpdateCancelled(pendingMult, pendingEff); } - /// @notice Sets the current multiplier immediately and clears any pending. - function updateMultiplier(uint256 newMultiplier) external onlyRole(OPERATOR_ROLE) { - 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; + /// @notice Sets the current multiplier immediately and clears any pending. Canonical ERC-8056 + /// "UI Multiplier" vocabulary name for the instant failsafe. + function updateUIMultiplier(uint256 newMultiplier) external onlyRole(OPERATOR_ROLE) { + _updateMultiplierNow(newMultiplier); + } - uint256 old = _multiplier(); - $.multiplier = newMultiplier; - if (pendingEff != 0) delete $.pending; - if (livePending) emit UIMultiplierUpdateCancelled(pendingMult, pendingEff); - emit UIMultiplierUpdated(old, newMultiplier, block.timestamp); + /// @notice Deprecated alias of `updateUIMultiplier`, retained (dialable) so the precompile's + /// legacy `updateMultiplier(uint256)` selector stays cross-validated by the fork tests. + /// No longer advertised in `IB20Asset`. + function updateMultiplier(uint256 newMultiplier) external onlyRole(OPERATOR_ROLE) { + _updateMultiplierNow(newMultiplier); } // ============================================================ @@ -280,6 +278,23 @@ contract MockB20Asset is MockB20, IB20Asset { // INTERNAL HELPERS // ============================================================ + /// @dev Shared body for `updateUIMultiplier` / `updateMultiplier`: sets the current multiplier + /// immediately, clears any pending update, and emits the ERC-8056 events (a + /// `UIMultiplierUpdateCancelled` when it clears a live pending, then `UIMultiplierUpdated`). + function _updateMultiplierNow(uint256 newMultiplier) internal { + 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 UIMultiplierUpdateCancelled(pendingMult, pendingEff); + emit UIMultiplierUpdated(old, newMultiplier, block.timestamp); + } + /// @dev The effective multiplier: returns the pending slot's value if live, /// otherwise returns the current multiplier. function _multiplier() internal view returns (uint256) { diff --git a/test/regression/B20Renames.t.sol b/test/regression/B20Renames.t.sol index 05ce1f4..c280dce 100644 --- a/test/regression/B20Renames.t.sol +++ b/test/regression/B20Renames.t.sol @@ -87,13 +87,13 @@ contract B20RenamesTest is B20AssetTest { /// @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. + /// @dev `updateUIMultiplier` must emit the ERC-8056 topic and never the legacy topic. function test_multiplierEvent_success_widenedToUIMultiplierUpdated(uint256 newMultiplier) public { newMultiplier = bound(newMultiplier, 1, type(uint128).max); _grantOperator(); vm.recordLogs(); vm.prank(operator); - asset().updateMultiplier(newMultiplier); + asset().updateUIMultiplier(newMultiplier); Vm.Log[] memory logs = vm.getRecordedLogs(); assertGt( _firstLogIndex(logs, UI_MULTIPLIER_UPDATED_SIG), -1, "UIMultiplierUpdated(old,new,effAt) must be emitted" @@ -123,7 +123,7 @@ contract B20RenamesTest is B20AssetTest { // ============================================================ // The asset variant splits authority: the metadata setters (updateName / updateSymbol / // updateContractURI / updateExtraMetadata) are gated by METADATA_ROLE, while the operator - // actions (announce / updateMultiplier) are gated by OPERATOR_ROLE. The tests below pin that + // actions (announce / updateUIMultiplier) are gated by OPERATOR_ROLE. The tests below pin that // split from both sides. /// @notice Verifies `updateExtraMetadata` is gated by METADATA_ROLE, not OPERATOR_ROLE @@ -145,17 +145,44 @@ contract B20RenamesTest is B20AssetTest { assertEq(asset().extraMetadata(METADATA_EXAMPLE_1), value, "metadata write by METADATA_ROLE must persist"); } - /// @notice Verifies `updateMultiplier` is gated by OPERATOR_ROLE, not METADATA_ROLE + /// @notice Verifies `updateUIMultiplier` is gated by OPERATOR_ROLE, not METADATA_ROLE /// @dev A METADATA_ROLE-only holder is rejected with the OPERATOR_ROLE selector — the inverse /// of the metadata-gating test, confirming the two authorities are distinct. - function test_updateMultiplier_revert_metadataRoleInsufficient(uint256 newMultiplier) public { + function test_updateUIMultiplier_revert_metadataRoleInsufficient(uint256 newMultiplier) public { newMultiplier = bound(newMultiplier, 1, type(uint128).max); _grantRole(B20Constants.METADATA_ROLE, bob); vm.prank(bob); vm.expectRevert( abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, bob, B20Constants.OPERATOR_ROLE) ); - asset().updateMultiplier(newMultiplier); + asset().updateUIMultiplier(newMultiplier); + } + + /// @notice Verifies the deprecated `updateMultiplier(uint256)` selector stays dialable after + /// being de-advertised from `IB20Asset`, and behaves identically to `updateUIMultiplier`. + /// @dev Deprecation-not-removal: the precompile permanently retains the legacy selector so block + /// explorers and existing integrations keep working; only base-std's advertised interface + /// drops it. Dialed by raw signature since the typed interface no longer declares it. + function test_updateMultiplier_deprecated_stillDialable(uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + _grantOperator(); + vm.recordLogs(); + vm.prank(operator); + (bool ok,) = address(token).call(abi.encodeWithSignature("updateMultiplier(uint256)", newMultiplier)); + assertTrue(ok, "legacy updateMultiplier(uint256) selector must remain dialable (deprecated, not removed)"); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertGt( + _firstLogIndex(logs, UI_MULTIPLIER_UPDATED_SIG), + -1, + "legacy updateMultiplier must emit the ERC-8056 UIMultiplierUpdated" + ); + assertEq( + _firstLogIndex(logs, LEGACY_MULTIPLIER_UPDATED_SIG), + -1, + "legacy updateMultiplier must not emit MultiplierUpdated" + ); + assertEq(asset().multiplier(), newMultiplier, "legacy updateMultiplier must set the current multiplier"); } /// @notice Verifies METADATA_ROLE is administered by DEFAULT_ADMIN_ROLE on a freshly created token diff --git a/test/unit/B20Asset/multiplier/materialize.t.sol b/test/unit/B20Asset/multiplier/materialize.t.sol index 8067c74..cbcf311 100644 --- a/test/unit/B20Asset/multiplier/materialize.t.sol +++ b/test/unit/B20Asset/multiplier/materialize.t.sol @@ -45,8 +45,8 @@ contract B20AssetMaterializeTest is B20AssetTest { 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 { + /// @notice Verifies updateUIMultiplier clears a *live* pending and emits the cancellation + function test_updateUIMultiplier_success_clearsLivePending() public { uint256 pendingMultiplier = 2e18; uint256 effectiveAt = block.timestamp + 1 days; _setUIMultiplier(pendingMultiplier, effectiveAt); @@ -59,17 +59,17 @@ contract B20AssetMaterializeTest is B20AssetTest { vm.expectEmit(false, false, false, true, address(token)); emit IScaledUIAmount.UIMultiplierUpdated(old, instant, block.timestamp); vm.prank(operator); - asset().updateMultiplier(instant); + asset().updateUIMultiplier(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 + /// @notice Verifies updateUIMultiplier clears a *matured* pending WITHOUT a cancellation event /// @dev A matured pending already took effect, so it folds into `oldMultiplier` and is cleared /// silently — `UIMultiplierUpdateCancelled` fires only for a live pending. - function test_updateMultiplier_success_clearsMaturedPendingNoCancelEvent() public { + function test_updateUIMultiplier_success_clearsMaturedPendingNoCancelEvent() public { uint256 matured = 2e18; uint256 effectiveAt = block.timestamp + 1 days; _setUIMultiplier(matured, effectiveAt); @@ -79,7 +79,7 @@ contract B20AssetMaterializeTest is B20AssetTest { _grantOperator(); vm.recordLogs(); vm.prank(operator); - asset().updateMultiplier(instant); + asset().updateUIMultiplier(instant); Vm.Log[] memory logs = vm.getRecordedLogs(); assertEq( diff --git a/test/unit/B20Asset/multiplier/updateMultiplier.t.sol b/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol similarity index 67% rename from test/unit/B20Asset/multiplier/updateMultiplier.t.sol rename to test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol index 6da8042..9fcf6d1 100644 --- a/test/unit/B20Asset/multiplier/updateMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol @@ -9,45 +9,45 @@ import {IScaledUIAmount} from "base-std/interfaces/IERC8056.sol"; import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; -contract B20AssetUpdateMultiplierTest is B20AssetTest { - /// @notice Verifies updateMultiplier reverts when caller lacks OPERATOR_ROLE +contract B20AssetUpdateUIMultiplierTest is B20AssetTest { + /// @notice Verifies updateUIMultiplier reverts when caller lacks OPERATOR_ROLE /// @dev Access control: only role-holders can rotate the multiplier; checks /// AccessControlUnauthorizedAccount with OPERATOR_ROLE. - function test_updateMultiplier_revert_unauthorized(address caller, uint256 newMultiplier) public { + function test_updateUIMultiplier_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().updateMultiplier(newMultiplier); + asset().updateUIMultiplier(newMultiplier); } - /// @notice Verifies updateMultiplier reverts when newMultiplier is zero + /// @notice Verifies updateUIMultiplier reverts when newMultiplier is zero /// @dev Input validation: zero is an invalid multiplier because stored zero is the /// uninitialized-storage sentinel (read path normalizes it to WAD). Passing zero /// would create an event/read inconsistency for off-chain indexers. - function test_updateMultiplier_revert_zeroMultiplier() public { + function test_updateUIMultiplier_revert_zeroMultiplier() public { _grantOperator(); vm.prank(operator); vm.expectRevert(IB20Asset.InvalidMultiplier.selector); - asset().updateMultiplier(0); + asset().updateUIMultiplier(0); } - /// @notice Verifies updateMultiplier reverts when newMultiplier exceeds the uint128 ceiling - function test_updateMultiplier_revert_aboveUint128Ceiling(uint256 newMultiplier) public { + /// @notice Verifies updateUIMultiplier reverts when newMultiplier exceeds the uint128 ceiling + function test_updateUIMultiplier_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); + asset().updateUIMultiplier(newMultiplier); } - /// @notice Verifies updateMultiplier writes the new value to the stored slot + /// @notice Verifies updateUIMultiplier 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 { + function test_updateUIMultiplier_success_writesSlot(uint256 newMultiplier) public { newMultiplier = bound(newMultiplier, 1, type(uint128).max); _updateMultiplier(newMultiplier); assertEq( @@ -57,16 +57,16 @@ contract B20AssetUpdateMultiplierTest is B20AssetTest { ); } - /// @notice Verifies updateMultiplier emits UIMultiplierUpdated(old, new, block.timestamp) + /// @notice Verifies updateUIMultiplier 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 { + function test_updateUIMultiplier_success_emitsEvent(uint256 newMultiplier) public { newMultiplier = bound(newMultiplier, 1, type(uint128).max); _grantOperator(); uint256 oldMultiplier = asset().multiplier(); vm.expectEmit(false, false, false, true, address(token)); emit IScaledUIAmount.UIMultiplierUpdated(oldMultiplier, newMultiplier, block.timestamp); vm.prank(operator); - asset().updateMultiplier(newMultiplier); + asset().updateUIMultiplier(newMultiplier); } } diff --git a/test/unit/B20Asset/multiplier/updateMultiplier_revertOrder.t.sol b/test/unit/B20Asset/multiplier/updateUIMultiplier_revertOrder.t.sol similarity index 84% rename from test/unit/B20Asset/multiplier/updateMultiplier_revertOrder.t.sol rename to test/unit/B20Asset/multiplier/updateUIMultiplier_revertOrder.t.sol index 84e2f8b..88da63a 100644 --- a/test/unit/B20Asset/multiplier/updateMultiplier_revertOrder.t.sol +++ b/test/unit/B20Asset/multiplier/updateUIMultiplier_revertOrder.t.sol @@ -6,15 +6,15 @@ import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; -/// @title Sequential revert-order test for `updateMultiplier`. +/// @title Sequential revert-order test for `updateUIMultiplier`. /// /// @notice **Canonical order (Solidity reference):** /// 1. ROLE (`onlyRole(OPERATOR_ROLE)` modifier) → `AccessControlUnauthorizedAccount` /// 2. INVALID-MULTIPLIER (`newMultiplier == 0`) → `InvalidMultiplier` /// /// Walks from all conditions broken to success, fixing one per step. -contract B20AssetUpdateMultiplierRevertOrderTest is B20AssetTest { - function test_updateMultiplier_revertOrder(address caller) public { +contract B20AssetUpdateUIMultiplierRevertOrderTest is B20AssetTest { + function test_updateUIMultiplier_revertOrder(address caller) public { // Exclude precompiles (which can distort msg.sender) and admin (needed // internally by _grantRole to approve the role grant). _assumeValidCaller(caller); @@ -29,7 +29,7 @@ contract B20AssetUpdateMultiplierRevertOrderTest is B20AssetTest { // The role modifier runs before the body's zero-multiplier check. vm.prank(caller); vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, operatorRole)); - asset().updateMultiplier(0); + asset().updateUIMultiplier(0); // Fix: grant OPERATOR_ROLE to caller. _grantRole(operatorRole, caller); @@ -37,12 +37,12 @@ contract B20AssetUpdateMultiplierRevertOrderTest is B20AssetTest { // 2. INVALID-MULTIPLIER fires: caller now holds the role, but multiplier is still zero. vm.prank(caller); vm.expectRevert(IB20Asset.InvalidMultiplier.selector); - asset().updateMultiplier(0); + asset().updateUIMultiplier(0); // Fix: pass a non-zero multiplier. // Success: all conditions resolved. vm.prank(caller); - asset().updateMultiplier(1e18); + asset().updateUIMultiplier(1e18); } } diff --git a/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol b/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol index f6e0b13..fa6dbc0 100644 --- a/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol +++ b/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol @@ -7,13 +7,22 @@ import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; import {B20FactoryLibTest} from "base-std-test/lib/B20FactoryLibTest.sol"; contract B20FactoryLibEncodeUpdateMultiplierTest is B20FactoryLibTest { - /// @notice Verifies the encoded blob matches `abi.encodeCall(IB20Asset.updateMultiplier, ...)`. + /// @notice Verifies the canonical encoder matches `abi.encodeCall(IB20Asset.updateUIMultiplier, ...)`. /// @dev Pins the selector binding and uint argument shape for the bootstrap multiplier /// init call. The asset variant's scaled-balance reads all derive from the /// multiplier this call seeds, so a selector/arg drift would silently mis-scale balances. - function test_encodeUpdateMultiplier_success_matchesAbiEncodeCall(uint256 newMultiplier) public pure { - bytes memory expected = abi.encodeCall(IB20Asset.updateMultiplier, (newMultiplier)); + function test_encodeUpdateUIMultiplier_success_matchesAbiEncodeCall(uint256 newMultiplier) public pure { + bytes memory expected = abi.encodeCall(IB20Asset.updateUIMultiplier, (newMultiplier)); + bytes memory actual = B20FactoryLib.encodeUpdateUIMultiplier(newMultiplier); + assertEq(actual, expected, "init-call must match abi.encodeCall(IB20Asset.updateUIMultiplier, ...)"); + } + + /// @notice Verifies the deprecated encoder still binds the legacy `updateMultiplier(uint256)` selector. + /// @dev The legacy selector is de-advertised from `IB20Asset` but retained (dialable) in the + /// precompile, so the encoder targets it by signature. Pins that binding stays intact. + function test_encodeUpdateMultiplier_success_matchesLegacySelector(uint256 newMultiplier) public pure { + bytes memory expected = abi.encodeWithSignature("updateMultiplier(uint256)", newMultiplier); bytes memory actual = B20FactoryLib.encodeUpdateMultiplier(newMultiplier); - assertEq(actual, expected, "init-call must match abi.encodeCall(IB20Asset.updateMultiplier, ...)"); + assertEq(actual, expected, "init-call must match the legacy updateMultiplier(uint256) selector"); } } From 1ed1df27235514f1bb2064f2322d94d74f0c38a0 Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 4 Aug 2026 17:13:04 -0500 Subject: [PATCH 04/11] feat(BOP-495): adopt IScaledUIAmountConversion (toUIAmount/fromUIAmount) Add the ERC-8056 Conversion extension interface (id 0x57854fc3) to IERC8056.sol, inherit it on IB20Asset, and advertise it via supportsInterface. The MockB20Asset reference implements toUIAmount/fromUIAmount and keeps the legacy toScaledBalance/toRawBalance dialable (shared bodies) so the precompile's retained legacy selectors stay cross-validated; a regression test pins that they remain callable and agree with the canonical converters. toScaledBalance/toRawBalance are de-advertised from IB20Asset; the dedicated tests move to toUIAmount/fromUIAmount, and B20FactoryLib/docs are updated. The "Conversion extension not claimed" doc statement is flipped. Remaining doc/smoke naming is swept in the reconciliation commit. Co-authored-by: Cursor --- docs/B20/Asset.md | 3 +- src/interfaces/IB20Asset.sol | 43 +++++----- src/interfaces/IERC8056.sol | 21 +++++ test/lib/mocks/MockB20Asset.sol | 42 ++++++++-- test/regression/B20Renames.t.sol | 32 +++++++- .../unit/B20Asset/announcement/announce.t.sol | 5 +- .../constants/precisionConstants.t.sol | 4 +- .../B20Asset/erc165/supportsInterface.t.sol | 11 ++- .../B20Asset/multiplier/fromUIAmount.t.sol | 74 ++++++++++++++++++ .../B20Asset/multiplier/toRawBalance.t.sol | 78 ------------------- .../B20Asset/multiplier/toScaledBalance.t.sol | 70 ----------------- .../unit/B20Asset/multiplier/toUIAmount.t.sol | 68 ++++++++++++++++ 12 files changed, 263 insertions(+), 188 deletions(-) create mode 100644 test/unit/B20Asset/multiplier/fromUIAmount.t.sol delete mode 100644 test/unit/B20Asset/multiplier/toRawBalance.t.sol delete mode 100644 test/unit/B20Asset/multiplier/toScaledBalance.t.sol create mode 100644 test/unit/B20Asset/multiplier/toUIAmount.t.sol diff --git a/docs/B20/Asset.md b/docs/B20/Asset.md index 35041dc..5b0e0e1 100644 --- a/docs/B20/Asset.md +++ b/docs/B20/Asset.md @@ -27,7 +27,8 @@ The Asset variant conforms to [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056 - `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. +- `toUIAmount(rawAmount)` / `fromUIAmount(uiAmount)` are the canonical raw ⇄ UI converters (optional Conversion extension `0x57854fc3`), applying the effective multiplier. The legacy `toScaledBalance` / `toRawBalance` selectors remain dialable for backwards compatibility but are no longer advertised in `IB20Asset`. +- `supportsInterface(bytes4)` (ERC-165, `0x01ffc9a7`) returns `true` for those four extension IDs and for ERC-165 itself. **Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `setUIMultiplier` and from `updateMultiplier`. `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelScheduledMultiplier` and by `updateMultiplier` when it clears a live pending. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 6585790..2f53cd7 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -3,7 +3,12 @@ pragma solidity >=0.8.20 <0.9.0; import {IB20} from "./IB20.sol"; import {IERC165} from "./IERC165.sol"; -import {IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances} from "./IERC8056.sol"; +import { + IScaledUIAmount, + IScaledUIAmountNewUIMultiplier, + IScaledUIAmountBalances, + IScaledUIAmountConversion +} from "./IERC8056.sol"; /// @title IB20Asset /// @author Coinbase @@ -11,7 +16,14 @@ import {IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances /// @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, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMultiplier, IScaledUIAmountBalances { +interface IB20Asset is + IB20, + IERC165, + IScaledUIAmount, + IScaledUIAmountNewUIMultiplier, + IScaledUIAmountBalances, + IScaledUIAmountConversion +{ /*////////////////////////////////////////////////////////////// ERRORS //////////////////////////////////////////////////////////////*/ @@ -155,26 +167,15 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// @return Current (effective) multiplier. function multiplier() external view returns (uint256); - /// @notice Converts a raw balance to its scaled view: `rawBalance * multiplier / WAD_PRECISION`. - /// - /// @param rawBalance Raw token amount to scale. - /// - /// @return Scaled balance at the current multiplier. - function toScaledBalance(uint256 rawBalance) external view returns (uint256); - - /// @notice Converts a scaled balance back to its raw representation: - /// `scaledBalance * WAD_PRECISION / multiplier`. - /// - /// @dev Integer division rounds toward zero; conversions are not exactly reversible when - /// `multiplier != WAD_PRECISION`. `toRawBalance(toScaledBalance(x))` may return a - /// value slightly less than `x`. - /// - /// @param scaledBalance Scaled token amount to convert back. - /// - /// @return rawBalance Raw balance at the current multiplier. - function toRawBalance(uint256 scaledBalance) external view returns (uint256 rawBalance); + /// @notice Raw <-> UI conversion is the inherited ERC-8056 Conversion extension + /// `toUIAmount(uint256)` / `fromUIAmount(uint256)` (see `IScaledUIAmountConversion`), + /// which apply `rawAmount * multiplier / WAD_PRECISION` and its floored inverse using + /// the effective multiplier. Integer division rounds toward zero, so the round-trip is + /// not exactly reversible when `multiplier != WAD_PRECISION`. + /// @dev The precompile also retains the legacy `toScaledBalance` / `toRawBalance` selectors + /// (identical behavior), dialable but no longer advertised here. - /// @notice Convenience for `toScaledBalance(balanceOf(account))`. + /// @notice Convenience for `toUIAmount(balanceOf(account))`. /// /// @param account Account whose scaled balance is being queried. /// diff --git a/src/interfaces/IERC8056.sol b/src/interfaces/IERC8056.sol index f519848..c99cbe1 100644 --- a/src/interfaces/IERC8056.sol +++ b/src/interfaces/IERC8056.sol @@ -54,3 +54,24 @@ interface IScaledUIAmountBalances { /// @return UI-adjusted total supply. function totalSupplyUI() external view returns (uint256); } + +/// @title IScaledUIAmountConversion +/// @author Ethereum (ERC-8056) +/// +/// @notice ERC-8056 optional "Conversion" extension: on-chain helpers for converting between raw +/// token amounts and their UI representation, using the effective (lazily-flipped) +/// multiplier. Integrators should treat raw on-chain amounts as canonical and call these +/// only at the display boundary; integer division truncates, so the round-trip is lossy. +/// +/// @dev Interface ID: `0x57854fc3`. +interface IScaledUIAmountConversion { + /// @notice Converts a raw token amount to its UI representation. + /// @param rawAmount Raw token amount to scale. + /// @return UI amount at the effective multiplier. + function toUIAmount(uint256 rawAmount) external view returns (uint256); + + /// @notice Converts a UI amount back to its raw token amount. + /// @param uiAmount UI amount to convert back. + /// @return Raw token amount at the effective multiplier. + function fromUIAmount(uint256 uiAmount) external view returns (uint256); +} diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 7bad2b2..1902682 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -7,7 +7,8 @@ import {IERC165} from "base-std/interfaces/IERC165.sol"; import { IScaledUIAmount, IScaledUIAmountNewUIMultiplier, - IScaledUIAmountBalances + IScaledUIAmountBalances, + IScaledUIAmountConversion } from "base-std/interfaces/IERC8056.sol"; import {MockB20} from "base-std-test/lib/mocks/MockB20.sol"; @@ -153,12 +154,25 @@ contract MockB20Asset is MockB20, IB20Asset { return MockB20AssetStorage.layout().pending.effectiveAt; } + /// @dev ERC-8056 Conversion extension: raw -> UI amount. + function toUIAmount(uint256 rawAmount) external view returns (uint256) { + return _toUIAmount(rawAmount); + } + + /// @dev ERC-8056 Conversion extension: UI -> raw amount. + function fromUIAmount(uint256 uiAmount) external view returns (uint256) { + return _fromUIAmount(uiAmount); + } + + /// @dev Deprecated alias of `toUIAmount`, retained (dialable) so the precompile's legacy + /// selector stays cross-validated by the fork tests. No longer advertised in `IB20Asset`. function toScaledBalance(uint256 rawBalance) external view returns (uint256) { - return (rawBalance * _multiplier()) / WAD_PRECISION; + return _toUIAmount(rawBalance); } + /// @dev Deprecated alias of `fromUIAmount`, retained (dialable). No longer advertised. function toRawBalance(uint256 scaledBalance) external view returns (uint256) { - return (scaledBalance * WAD_PRECISION) / _multiplier(); + return _fromUIAmount(scaledBalance); } function scaledBalanceOf(address account) external view returns (uint256) { @@ -230,13 +244,15 @@ contract MockB20Asset is MockB20, IB20Asset { // 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. + /// @dev Advertises ERC-165 itself plus the four claimed ERC-8056 interfaces (core, pending, + /// Balances, and Conversion). The Conversion extension (`0x57854fc3`) is claimed after the + /// interface review: `toUIAmount` / `fromUIAmount` are exposed alongside the retained + /// (dialable, de-advertised) legacy `toScaledBalance` / `toRawBalance` selectors. 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; + || interfaceId == type(IScaledUIAmountBalances).interfaceId + || interfaceId == type(IScaledUIAmountConversion).interfaceId; } // ============================================================ @@ -295,6 +311,18 @@ contract MockB20Asset is MockB20, IB20Asset { emit UIMultiplierUpdated(old, newMultiplier, block.timestamp); } + /// @dev raw -> UI amount at the effective multiplier: `rawAmount * multiplier / WAD_PRECISION`. + /// Shared body for `toUIAmount` and the deprecated `toScaledBalance` alias. + function _toUIAmount(uint256 rawAmount) internal view returns (uint256) { + return (rawAmount * _multiplier()) / WAD_PRECISION; + } + + /// @dev UI -> raw amount at the effective multiplier: `uiAmount * WAD_PRECISION / multiplier`. + /// Shared body for `fromUIAmount` and the deprecated `toRawBalance` alias. + function _fromUIAmount(uint256 uiAmount) internal view returns (uint256) { + return (uiAmount * WAD_PRECISION) / _multiplier(); + } + /// @dev The effective multiplier: returns the pending slot's value if live, /// otherwise returns the current multiplier. function _multiplier() internal view returns (uint256) { diff --git a/test/regression/B20Renames.t.sol b/test/regression/B20Renames.t.sol index c280dce..ebf9170 100644 --- a/test/regression/B20Renames.t.sol +++ b/test/regression/B20Renames.t.sol @@ -55,8 +55,8 @@ contract B20RenamesTest is B20AssetTest { // New surface resolves and behaves (1:1 at the WAD default). assertEq(asset().multiplier(), asset().WAD_PRECISION(), "fresh multiplier must default to WAD"); - assertEq(asset().toScaledBalance(rawBalance), rawBalance, "toScaledBalance is identity at WAD"); - assertEq(asset().toRawBalance(rawBalance), rawBalance, "toRawBalance is identity at WAD"); + assertEq(asset().toUIAmount(rawBalance), rawBalance, "toUIAmount is identity at WAD"); + assertEq(asset().fromUIAmount(rawBalance), rawBalance, "fromUIAmount is identity at WAD"); // Legacy share-ratio surface is gone. _assertSelectorRemoved( @@ -105,8 +105,9 @@ contract B20RenamesTest is B20AssetTest { /// @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. + /// surface, `totalSupplyUI`, `toUIAmount`/`fromUIAmount`, and `supportsInterface` all + /// resolve. These typed calls only compile against the current interface, so their presence + /// is the guard. function test_erc8056Surface_success_aliasesResolve(uint256 amount) public { amount = bound(amount, 0, type(uint128).max); if (amount > 0) _mint(alice, amount); @@ -115,7 +116,30 @@ contract B20RenamesTest is B20AssetTest { 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"); + assertEq(asset().toUIAmount(amount), amount, "toUIAmount identity at WAD default"); + assertEq(asset().fromUIAmount(amount), amount, "fromUIAmount identity at WAD default"); assertTrue(asset().supportsInterface(0xa60bf13d), "IScaledUIAmount (0xa60bf13d) must be advertised"); + assertTrue(asset().supportsInterface(0x57854fc3), "IScaledUIAmountConversion (0x57854fc3) must be advertised"); + } + + /// @notice Verifies the deprecated `toScaledBalance` / `toRawBalance` selectors stay dialable + /// after being de-advertised in favor of the ERC-8056 `toUIAmount` / `fromUIAmount`. + /// @dev Deprecation-not-removal: the precompile permanently retains the legacy conversion + /// selectors (identical behavior); only base-std's advertised interface drops them. + /// Dialed by raw signature since the typed interface no longer declares them. + function test_conversion_deprecated_stillDialable(uint256 amount) public { + amount = bound(amount, 0, type(uint128).max); + _updateMultiplier(2 * asset().WAD_PRECISION()); + + (bool okScaled, bytes memory rawScaled) = + address(token).staticcall(abi.encodeWithSignature("toScaledBalance(uint256)", amount)); + assertTrue(okScaled, "legacy toScaledBalance(uint256) must remain dialable"); + assertEq(abi.decode(rawScaled, (uint256)), asset().toUIAmount(amount), "toScaledBalance must equal toUIAmount"); + + (bool okRaw, bytes memory rawRaw) = + address(token).staticcall(abi.encodeWithSignature("toRawBalance(uint256)", amount)); + assertTrue(okRaw, "legacy toRawBalance(uint256) must remain dialable"); + assertEq(abi.decode(rawRaw, (uint256)), asset().fromUIAmount(amount), "toRawBalance must equal fromUIAmount"); } // ============================================================ diff --git a/test/unit/B20Asset/announcement/announce.t.sol b/test/unit/B20Asset/announcement/announce.t.sol index f8e4a18..f403eee 100644 --- a/test/unit/B20Asset/announcement/announce.t.sol +++ b/test/unit/B20Asset/announcement/announce.t.sol @@ -7,6 +7,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 {IScaledUIAmountConversion} from "base-std/interfaces/IERC8056.sol"; import {B20Constants} from "base-std/lib/B20Constants.sol"; @@ -79,12 +80,12 @@ contract B20AssetAnnounceTest is B20AssetTest { /// @notice Verifies an inner call that raises a Solidity Panic propagates the raw Panic /// unchanged instead of being wrapped as InternalCallFailed (parity with the Rust impl). /// @dev Arithmetic overflow (0x11) is the one inner-call Panic reachable on both sides: a - /// multiplier > 1 makes toScaledBalance(uint256 max) overflow. NOT skipped under live + /// multiplier > 1 makes toUIAmount(uint256 max) overflow. NOT skipped under live /// precompiles — asserting the raw payload from the live precompile is the conformance point. function test_announce_innerPanic_propagatesRaw() public { _grantOperator(); _updateMultiplier(2 * asset().WAD_PRECISION()); - bytes memory inner = abi.encodeWithSelector(IB20Asset.toScaledBalance.selector, type(uint256).max); + bytes memory inner = abi.encodeWithSelector(IScaledUIAmountConversion.toUIAmount.selector, type(uint256).max); vm.prank(operator); vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); diff --git a/test/unit/B20Asset/constants/precisionConstants.t.sol b/test/unit/B20Asset/constants/precisionConstants.t.sol index 5bd6f84..c9a922e 100644 --- a/test/unit/B20Asset/constants/precisionConstants.t.sol +++ b/test/unit/B20Asset/constants/precisionConstants.t.sol @@ -5,8 +5,8 @@ import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; contract B20AssetPrecisionConstantsTest is B20AssetTest { /// @notice Verifies WAD_PRECISION equals 1e18 - /// @dev DeFi convention check: `toScaledBalance` and `scaledBalanceOf` divide by this after - /// multiplying by the stored multiplier (and `toRawBalance` multiplies by this before + /// @dev DeFi convention check: `toUIAmount` and `scaledBalanceOf` divide by this after + /// multiplying by the stored multiplier (and `fromUIAmount` multiplies by this before /// dividing); any drift silently rescales every holder's scaled balance. function test_wadPrecision_success_equalsOneWad() public view { assertEq(asset().WAD_PRECISION(), 1e18, "WAD_PRECISION must equal 1e18"); diff --git a/test/unit/B20Asset/erc165/supportsInterface.t.sol b/test/unit/B20Asset/erc165/supportsInterface.t.sol index d978c6b..6ef4cd3 100644 --- a/test/unit/B20Asset/erc165/supportsInterface.t.sol +++ b/test/unit/B20Asset/erc165/supportsInterface.t.sol @@ -7,7 +7,8 @@ import {IERC165} from "base-std/interfaces/IERC165.sol"; import { IScaledUIAmount, IScaledUIAmountNewUIMultiplier, - IScaledUIAmountBalances + IScaledUIAmountBalances, + IScaledUIAmountConversion } from "base-std/interfaces/IERC8056.sol"; contract B20AssetSupportsInterfaceTest is B20AssetTest { @@ -16,14 +17,16 @@ contract B20AssetSupportsInterfaceTest is B20AssetTest { bytes4 internal constant SCALED_UI_AMOUNT_ID = 0xa60bf13d; bytes4 internal constant NEW_UI_MULTIPLIER_ID = 0x4bd27648; bytes4 internal constant BALANCES_ID = 0xd890fd71; + bytes4 internal constant CONVERSION_ID = 0x57854fc3; - /// @notice Verifies the four claimed interface IDs are advertised - /// @dev ERC-165 itself plus the ERC-8056 core, pending, and Balances extensions. + /// @notice Verifies the five claimed interface IDs are advertised + /// @dev ERC-165 itself plus the ERC-8056 core, pending, Balances, and Conversion 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"); + assertTrue(asset().supportsInterface(CONVERSION_ID), "must advertise IScaledUIAmountConversion"); } /// @notice Verifies an unknown interface ID returns false @@ -32,6 +35,7 @@ contract B20AssetSupportsInterfaceTest is B20AssetTest { vm.assume(interfaceId != SCALED_UI_AMOUNT_ID); vm.assume(interfaceId != NEW_UI_MULTIPLIER_ID); vm.assume(interfaceId != BALANCES_ID); + vm.assume(interfaceId != CONVERSION_ID); assertFalse(asset().supportsInterface(interfaceId), "unknown interface must not be advertised"); } @@ -44,5 +48,6 @@ contract B20AssetSupportsInterfaceTest is B20AssetTest { type(IScaledUIAmountNewUIMultiplier).interfaceId, NEW_UI_MULTIPLIER_ID, "IScaledUIAmountNewUIMultiplier id" ); assertEq(type(IScaledUIAmountBalances).interfaceId, BALANCES_ID, "IScaledUIAmountBalances id"); + assertEq(type(IScaledUIAmountConversion).interfaceId, CONVERSION_ID, "IScaledUIAmountConversion id"); } } diff --git a/test/unit/B20Asset/multiplier/fromUIAmount.t.sol b/test/unit/B20Asset/multiplier/fromUIAmount.t.sol new file mode 100644 index 0000000..27ddcaf --- /dev/null +++ b/test/unit/B20Asset/multiplier/fromUIAmount.t.sol @@ -0,0 +1,74 @@ +// 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"; + +contract B20AssetFromUIAmountTest is B20AssetTest { + /// @notice Verifies fromUIAmount is the identity on a fresh token (WAD multiplier) + /// @dev Default multiplier is WAD, so uiAmount * WAD / WAD == uiAmount for every input. + function test_fromUIAmount_success_identityOnWadDefault(uint256 uiAmount) public view { + uiAmount = bound(uiAmount, 0, type(uint256).max / asset().WAD_PRECISION()); + assertEq(asset().fromUIAmount(uiAmount), uiAmount, "default multiplier must produce identity"); + } + + /// @notice Verifies fromUIAmount inverts the stored multiplier after an update + /// @dev Property: fromUIAmount(uiAmount) == uiAmount * WAD / multiplier. Fuzz both + /// inputs over the range that avoids the intermediate-product overflow. + function test_fromUIAmount_success_invertsByStoredMultiplier(uint256 uiAmount, uint256 newMultiplier) public { + uiAmount = bound(uiAmount, 0, type(uint128).max); + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + _updateMultiplier(newMultiplier); + assertEq( + asset().fromUIAmount(uiAmount), + (uiAmount * asset().WAD_PRECISION()) / newMultiplier, + "fromUIAmount must apply uiAmount * WAD / multiplier" + ); + } + + /// @notice Verifies fromUIAmount of zero UI amount is zero regardless of the multiplier + /// @dev Degenerate input edge: any multiplier divided into zero is zero. + function test_fromUIAmount_success_zeroUIAmount(uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + _updateMultiplier(newMultiplier); + assertEq(asset().fromUIAmount(0), 0, "zero UI amount must produce zero raw amount"); + } + + /// @notice Verifies fromUIAmount applies the WAD fallback when the stored multiplier is zero + /// @dev A stored `multiplier` of zero resolves as `WAD_PRECISION` on the read surface. + /// `updateUIMultiplier(0)` now reverts (InvalidMultiplier), so we zero the slot via + /// vm.store to isolate the read-path fallback from write-path validation. + function test_fromUIAmount_success_explicitZeroMultiplierFallsBackToWad(uint256 uiAmount) public { + uiAmount = bound(uiAmount, 0, type(uint128).max); + _updateMultiplier(5e18); // seed a non-zero value first + vm.store(address(token), MockB20AssetStorage.multiplierSlot(), bytes32(0)); // zero the slot directly + assertEq( + asset().fromUIAmount(uiAmount), uiAmount, "stored zero multiplier must produce identity (WAD fallback)" + ); + } + + /// @notice Verifies the round-trip fromUIAmount(toUIAmount(x)) == x at the WAD default + /// @dev With multiplier == WAD, both directions collapse to the identity, so the round-trip + /// is exact. + function test_fromUIAmount_success_roundTripExactOnWadDefault(uint256 rawAmount) public view { + rawAmount = bound(rawAmount, 0, type(uint256).max / asset().WAD_PRECISION()); + uint256 ui = asset().toUIAmount(rawAmount); + assertEq(asset().fromUIAmount(ui), rawAmount, "round-trip must be exact at WAD multiplier"); + } + + /// @notice Verifies the round-trip fromUIAmount(toUIAmount(x)) <= x for arbitrary multipliers + /// @dev Both legs floor-divide. The forward leg loses up to one ULP and the reverse leg loses + /// up to one more, so the round-trip can return a value strictly less than `x`. The + /// conservative invariant asserted here is `fromUIAmount(toUIAmount(x)) <= x`. + function test_fromUIAmount_success_roundTripFloors(uint256 rawAmount, uint256 newMultiplier) public { + // Bound the multiplier strictly below WAD to actually exercise the floor — at multipliers + // >= WAD the forward leg loses nothing, so the round-trip is exact and uninteresting. + rawAmount = bound(rawAmount, 0, type(uint128).max); + newMultiplier = bound(newMultiplier, 1, asset().WAD_PRECISION() - 1); + _updateMultiplier(newMultiplier); + uint256 ui = asset().toUIAmount(rawAmount); + uint256 roundTripped = asset().fromUIAmount(ui); + assertLe(roundTripped, rawAmount, "round-trip must not exceed input (floors at each step)"); + } +} diff --git a/test/unit/B20Asset/multiplier/toRawBalance.t.sol b/test/unit/B20Asset/multiplier/toRawBalance.t.sol deleted file mode 100644 index 56808c9..0000000 --- a/test/unit/B20Asset/multiplier/toRawBalance.t.sol +++ /dev/null @@ -1,78 +0,0 @@ -// 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"; - -contract B20AssetToRawBalanceTest is B20AssetTest { - /// @notice Verifies toRawBalance is the identity on a fresh token (WAD multiplier) - /// @dev Default multiplier is WAD, so scaledBalance * WAD / WAD == scaledBalance for every input. - function test_toRawBalance_success_identityOnWadDefault(uint256 scaledBalance) public view { - scaledBalance = bound(scaledBalance, 0, type(uint256).max / asset().WAD_PRECISION()); - assertEq(asset().toRawBalance(scaledBalance), scaledBalance, "default multiplier must produce identity"); - } - - /// @notice Verifies toRawBalance inverts the stored multiplier after an update - /// @dev Property: toRawBalance(scaledBalance) == scaledBalance * WAD / multiplier. Fuzz both - /// inputs over the range that avoids the intermediate-product overflow. - function test_toRawBalance_success_invertsByStoredMultiplier(uint256 scaledBalance, uint256 newMultiplier) public { - scaledBalance = bound(scaledBalance, 0, type(uint128).max); - newMultiplier = bound(newMultiplier, 1, type(uint128).max); - _updateMultiplier(newMultiplier); - assertEq( - asset().toRawBalance(scaledBalance), - (scaledBalance * asset().WAD_PRECISION()) / newMultiplier, - "toRawBalance must apply scaledBalance * WAD / multiplier" - ); - } - - /// @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(uint128).max); - _updateMultiplier(newMultiplier); - assertEq(asset().toRawBalance(0), 0, "zero scaled balance must produce zero raw balance"); - } - - /// @notice Verifies toRawBalance applies the WAD fallback when the stored multiplier is zero - /// @dev A stored `multiplier` of zero resolves as `WAD_PRECISION` on the read surface. - /// `updateMultiplier(0)` now reverts (InvalidMultiplier), so we zero the slot via - /// vm.store to isolate the read-path fallback from write-path validation. - function test_toRawBalance_success_explicitZeroMultiplierFallsBackToWad(uint256 scaledBalance) public { - scaledBalance = bound(scaledBalance, 0, type(uint128).max); - _updateMultiplier(5e18); // seed a non-zero value first - vm.store(address(token), MockB20AssetStorage.multiplierSlot(), bytes32(0)); // zero the slot directly - assertEq( - asset().toRawBalance(scaledBalance), - scaledBalance, - "stored zero multiplier must produce identity (WAD fallback)" - ); - } - - /// @notice Verifies the round-trip toRawBalance(toScaledBalance(x)) == x at the WAD default - /// @dev With multiplier == WAD, both directions collapse to the identity, so the round-trip - /// is exact. - function test_toRawBalance_success_roundTripExactOnWadDefault(uint256 rawBalance) public view { - rawBalance = bound(rawBalance, 0, type(uint256).max / asset().WAD_PRECISION()); - uint256 scaled = asset().toScaledBalance(rawBalance); - assertEq(asset().toRawBalance(scaled), rawBalance, "round-trip must be exact at WAD multiplier"); - } - - /// @notice Verifies the round-trip toRawBalance(toScaledBalance(x)) <= x for arbitrary multipliers - /// @dev Both legs floor-divide. The forward leg loses up to one ULP and the reverse leg loses - /// up to one more, so the round-trip can return a value strictly less than `x`. Bound the - /// gap precisely: the post-trip value lies in `[x - 1 - WAD/multiplier, x]` for non-zero - /// multipliers <= WAD, and is upper-bounded by `x` everywhere. The conservative invariant - /// asserted here is `toRawBalance(toScaledBalance(x)) <= x`. - function test_toRawBalance_success_roundTripFloors(uint256 rawBalance, uint256 newMultiplier) public { - // Bound the multiplier strictly below WAD to actually exercise the floor — at multipliers - // >= WAD the forward leg loses nothing, so the round-trip is exact and uninteresting. - rawBalance = bound(rawBalance, 0, type(uint128).max); - newMultiplier = bound(newMultiplier, 1, asset().WAD_PRECISION() - 1); - _updateMultiplier(newMultiplier); - uint256 scaled = asset().toScaledBalance(rawBalance); - uint256 roundTripped = asset().toRawBalance(scaled); - assertLe(roundTripped, rawBalance, "round-trip must not exceed input (floors at each step)"); - } -} diff --git a/test/unit/B20Asset/multiplier/toScaledBalance.t.sol b/test/unit/B20Asset/multiplier/toScaledBalance.t.sol deleted file mode 100644 index c86db36..0000000 --- a/test/unit/B20Asset/multiplier/toScaledBalance.t.sol +++ /dev/null @@ -1,70 +0,0 @@ -// 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"; - -contract B20AssetToScaledBalanceTest is B20AssetTest { - /// @notice Verifies toScaledBalance is the identity on a fresh token (WAD multiplier) - /// @dev Default multiplier is WAD, so rawBalance * WAD / WAD == rawBalance for every input. - function test_toScaledBalance_success_identityOnWadDefault(uint256 rawBalance) public view { - rawBalance = bound(rawBalance, 0, type(uint256).max / asset().WAD_PRECISION()); - assertEq(asset().toScaledBalance(rawBalance), rawBalance, "default multiplier must produce identity"); - } - - /// @notice Verifies toScaledBalance scales by the stored multiplier after an update - /// @dev Property: toScaledBalance(rawBalance) == rawBalance * multiplier / WAD. Fuzz both - /// inputs over the range that avoids the intermediate-product overflow. - function test_toScaledBalance_success_scalesByStoredMultiplier(uint256 rawBalance, uint256 newMultiplier) public { - rawBalance = bound(rawBalance, 0, type(uint128).max); - newMultiplier = bound(newMultiplier, 1, type(uint128).max); - _updateMultiplier(newMultiplier); - assertEq( - asset().toScaledBalance(rawBalance), - (rawBalance * newMultiplier) / asset().WAD_PRECISION(), - "toScaledBalance must apply rawBalance * multiplier / WAD" - ); - } - - /// @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(uint128).max); - _updateMultiplier(newMultiplier); - assertEq(asset().toScaledBalance(0), 0, "zero rawBalance must produce zero scaled balance"); - } - - /// @notice Verifies toScaledBalance applies the WAD fallback when the stored multiplier is zero - /// @dev A stored `multiplier` of zero resolves as `WAD_PRECISION` on the read surface. - /// `updateMultiplier(0)` now reverts (InvalidMultiplier), so we zero the slot via - /// vm.store to isolate the read-path fallback from write-path validation. - function test_toScaledBalance_success_explicitZeroMultiplierFallsBackToWad(uint256 rawBalance) public { - rawBalance = bound(rawBalance, 0, type(uint128).max); - _updateMultiplier(5e18); // seed a non-zero value first - vm.store(address(token), MockB20AssetStorage.multiplierSlot(), bytes32(0)); // zero the slot directly - assertEq( - asset().toScaledBalance(rawBalance), - rawBalance, - "stored zero multiplier must produce identity (WAD fallback)" - ); - } - - /// @notice Verifies toScaledBalance reverts when rawBalance * multiplier overflows uint256 - /// @dev The Rust precompile uses checked multiplication and reverts on overflow; the Solidity - /// reference relies on 0.8.x checked arithmetic (Panic 0x11). The success tests bound inputs - /// 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 { - // 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); - - vm.expectRevert(); - asset().toScaledBalance(rawBalance); - } -} diff --git a/test/unit/B20Asset/multiplier/toUIAmount.t.sol b/test/unit/B20Asset/multiplier/toUIAmount.t.sol new file mode 100644 index 0000000..ecd48fc --- /dev/null +++ b/test/unit/B20Asset/multiplier/toUIAmount.t.sol @@ -0,0 +1,68 @@ +// 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"; + +contract B20AssetToUIAmountTest is B20AssetTest { + /// @notice Verifies toUIAmount is the identity on a fresh token (WAD multiplier) + /// @dev Default multiplier is WAD, so rawAmount * WAD / WAD == rawAmount for every input. + function test_toUIAmount_success_identityOnWadDefault(uint256 rawAmount) public view { + rawAmount = bound(rawAmount, 0, type(uint256).max / asset().WAD_PRECISION()); + assertEq(asset().toUIAmount(rawAmount), rawAmount, "default multiplier must produce identity"); + } + + /// @notice Verifies toUIAmount scales by the stored multiplier after an update + /// @dev Property: toUIAmount(rawAmount) == rawAmount * multiplier / WAD. Fuzz both + /// inputs over the range that avoids the intermediate-product overflow. + function test_toUIAmount_success_scalesByStoredMultiplier(uint256 rawAmount, uint256 newMultiplier) public { + rawAmount = bound(rawAmount, 0, type(uint128).max); + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + _updateMultiplier(newMultiplier); + assertEq( + asset().toUIAmount(rawAmount), + (rawAmount * newMultiplier) / asset().WAD_PRECISION(), + "toUIAmount must apply rawAmount * multiplier / WAD" + ); + } + + /// @notice Verifies toUIAmount of zero rawAmount is zero regardless of the multiplier + /// @dev Degenerate input edge: any multiplier multiplied into zero is zero. + function test_toUIAmount_success_zeroRawAmount(uint256 newMultiplier) public { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + _updateMultiplier(newMultiplier); + assertEq(asset().toUIAmount(0), 0, "zero rawAmount must produce zero UI amount"); + } + + /// @notice Verifies toUIAmount applies the WAD fallback when the stored multiplier is zero + /// @dev A stored `multiplier` of zero resolves as `WAD_PRECISION` on the read surface. + /// `updateUIMultiplier(0)` now reverts (InvalidMultiplier), so we zero the slot via + /// vm.store to isolate the read-path fallback from write-path validation. + function test_toUIAmount_success_explicitZeroMultiplierFallsBackToWad(uint256 rawAmount) public { + rawAmount = bound(rawAmount, 0, type(uint128).max); + _updateMultiplier(5e18); // seed a non-zero value first + vm.store(address(token), MockB20AssetStorage.multiplierSlot(), bytes32(0)); // zero the slot directly + assertEq( + asset().toUIAmount(rawAmount), rawAmount, "stored zero multiplier must produce identity (WAD fallback)" + ); + } + + /// @notice Verifies toUIAmount reverts when rawAmount * multiplier overflows uint256 + /// @dev The Rust precompile uses checked multiplication and reverts on overflow; the Solidity + /// reference relies on 0.8.x checked arithmetic (Panic 0x11). The success tests bound inputs + /// 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_toUIAmount_revert_arithmeticOverflow(uint256 rawAmount, uint256 newMultiplier) public { + // The multiplier is capped at `type(uint128).max` by the setter; overflow is still + // reachable because `rawAmount` (an arbitrary conversion input, not bounded by supply) + // can be pushed high enough that `rawAmount * multiplier` exceeds `type(uint256).max`. + newMultiplier = bound(newMultiplier, 2, type(uint128).max); + // Force rawAmount * multiplier strictly above type(uint256).max. + rawAmount = bound(rawAmount, type(uint256).max / newMultiplier + 1, type(uint256).max); + _updateMultiplier(newMultiplier); + + vm.expectRevert(); + asset().toUIAmount(rawAmount); + } +} From c9a8d3674d979853efb911a251819f09fa3b293f Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 4 Aug 2026 17:18:32 -0500 Subject: [PATCH 05/11] feat(BOP-495): expose MAX_UI_MULTIPLIER() view Advertise MAX_UI_MULTIPLIER() (== type(uint128).max) on IB20Asset. The MockB20Asset reference declares it as a public constant (single source of truth, auto-generated getter) and the setter guards reference it, so callers can read the accepted-multiplier bound without hitting the InvalidMultiplier revert path. Pinned to the uint128 overflow guard by a constants test. Co-authored-by: Cursor --- src/interfaces/IB20Asset.sol | 7 +++++++ test/lib/mocks/MockB20Asset.sol | 8 ++++++-- test/unit/B20Asset/constants/precisionConstants.t.sol | 8 ++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 2f53cd7..a6e9e2e 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -119,6 +119,13 @@ interface IB20Asset is /// @return Precision constant. function WAD_PRECISION() external view returns (uint256); + /// @notice The maximum multiplier the setters accept: `type(uint128).max`, the overflow guard. + /// Exposed so callers can read the bound without triggering the `InvalidMultiplier` + /// revert path. With supply capped at `type(uint128).max`, a `uint128` multiplier keeps + /// `balance * multiplier` inside `uint256`. + /// @return Maximum UI multiplier constant. + function MAX_UI_MULTIPLIER() external view returns (uint256); + /*////////////////////////////////////////////////////////////// ANNOUNCEMENTS //////////////////////////////////////////////////////////////*/ diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 1902682..1760895 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -71,6 +71,10 @@ contract MockB20Asset is MockB20, IB20Asset { /// by this before dividing. uint256 public constant WAD_PRECISION = 1e18; + /// @notice The maximum multiplier the setters accept: `type(uint128).max`, the overflow guard. + /// Single source of truth for the setter guards, exposed via its auto-generated getter. + uint256 public constant MAX_UI_MULTIPLIER = type(uint128).max; + // ============================================================ // DECIMALS // ============================================================ @@ -195,7 +199,7 @@ contract MockB20Asset is MockB20, IB20Asset { /// 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 (newMultiplier == 0 || newMultiplier > MAX_UI_MULTIPLIER) revert InvalidMultiplier(); if (effectiveAt_ <= block.timestamp) revert EffectiveAtInPast(effectiveAt_); if (effectiveAt_ > type(uint64).max) revert EffectiveAtTooFar(effectiveAt_); @@ -298,7 +302,7 @@ contract MockB20Asset is MockB20, IB20Asset { /// immediately, clears any pending update, and emits the ERC-8056 events (a /// `UIMultiplierUpdateCancelled` when it clears a live pending, then `UIMultiplierUpdated`). function _updateMultiplierNow(uint256 newMultiplier) internal { - if (newMultiplier == 0 || newMultiplier > type(uint128).max) revert InvalidMultiplier(); + if (newMultiplier == 0 || newMultiplier > MAX_UI_MULTIPLIER) revert InvalidMultiplier(); MockB20AssetStorage.Layout storage $ = MockB20AssetStorage.layout(); uint256 pendingMult = $.pending.multiplier; uint256 pendingEff = $.pending.effectiveAt; diff --git a/test/unit/B20Asset/constants/precisionConstants.t.sol b/test/unit/B20Asset/constants/precisionConstants.t.sol index c9a922e..aafcc90 100644 --- a/test/unit/B20Asset/constants/precisionConstants.t.sol +++ b/test/unit/B20Asset/constants/precisionConstants.t.sol @@ -11,4 +11,12 @@ contract B20AssetPrecisionConstantsTest is B20AssetTest { function test_wadPrecision_success_equalsOneWad() public view { assertEq(asset().WAD_PRECISION(), 1e18, "WAD_PRECISION must equal 1e18"); } + + /// @notice Verifies MAX_UI_MULTIPLIER equals type(uint128).max + /// @dev The setters reject `newMultiplier > MAX_UI_MULTIPLIER`; exposing the bound as a getter + /// lets callers read it without hitting the revert path. Pins it to the uint128 overflow + /// guard so a drift can't silently widen (or narrow) the accepted multiplier range. + function test_maxUIMultiplier_success_equalsUint128Max() public view { + assertEq(asset().MAX_UI_MULTIPLIER(), type(uint128).max, "MAX_UI_MULTIPLIER must equal type(uint128).max"); + } } From 7f5960af6def57574fca6020153bc40e343ef178 Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 4 Aug 2026 17:30:12 -0500 Subject: [PATCH 06/11] chore(BOP-495): reconcile multiplier events + docs + smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep the docs and smoke journeys for the renamed/added surface and document the event reconciliation: - docs/B20/Asset.md: use updateUIMultiplier / toUIAmount / fromUIAmount as the canonical names (noting the retained-but-de-advertised legacy selectors), expose MAX_UI_MULTIPLIER(), and spell out the intended double event — an instant updateUIMultiplier that supersedes a *live* schedule emits UIMultiplierUpdateCancelled then UIMultiplierUpdated{block.timestamp}, and AssetV2 never emits the legacy V1 MultiplierUpdated (not part of IB20Asset). - scheduled_multiplier.py (Cobalt-only): switch to the canonical updateUIMultiplier / toUIAmount. - asset_lifecycle.py (cross-fork): keep the legacy updateMultiplier / toScaledBalance / toRawBalance selectors — the only multiplier/conversion selectors dialable on both Beryl and Cobalt — with a comment explaining why. abis.py appends their fragments since IB20Asset no longer advertises them. Co-authored-by: Cursor --- docs/B20/Asset.md | 14 ++++---- script/smoke/abis.py | 32 +++++++++++++++++ script/smoke/journeys/asset_lifecycle.py | 5 +++ script/smoke/journeys/scheduled_multiplier.py | 34 +++++++++---------- 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/docs/B20/Asset.md b/docs/B20/Asset.md index 5b0e0e1..f50bb35 100644 --- a/docs/B20/Asset.md +++ b/docs/B20/Asset.md @@ -6,9 +6,9 @@ The Asset variant of B20 — designed for assets of all kinds. Everything in [B2 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. +Read the current multiplier with `multiplier()`; the value is in WAD precision (`1e18`, exposed as `WAD_PRECISION()`). `toUIAmount(rawAmount)` converts a raw amount to its scaled view, `fromUIAmount(uiAmount)` 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. (The legacy `toScaledBalance` / `toRawBalance` selectors remain dialable for backwards compatibility but are no longer advertised in `IB20Asset` — see [ERC-8056 conformance](#erc-8056-conformance).) -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 `balance * multiplier` inside `uint256`, so balance-derived reads never overflow. +Both multiplier setters validate `newMultiplier` is non-zero and at most `type(uint128).max` (exposed as `MAX_UI_MULTIPLIER()`, reverting `InvalidMultiplier` otherwise). The `uint128` ceiling is the overflow guard: with supply capped at `type(uint128).max`, a `uint128` multiplier keeps `balance * multiplier` inside `uint256`, so balance-derived reads never overflow. ### Scheduling multiplier updates @@ -16,7 +16,7 @@ The standard path for a corporate action (a stock split or reinvested stock divi Only **one pending update is live at a time**. Attempting to schedule over an existing pending update reverts `PendingUpdateExists`. 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 `NoScheduledUIMultiplier` 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. +`updateUIMultiplier(newMultiplier)` is the **instant failsafe / emergency override**: it sets the multiplier immediately, stamping `effectiveAt = block.timestamp` and clearing any pending update. (The legacy `updateMultiplier` selector remains dialable for backwards compatibility but is no longer advertised in `IB20Asset`.) The pending schedule is observable through the ERC-8056 surface: `newUIMultiplier()` returns the scheduled target while it is live (otherwise it mirrors `uiMultiplier()`). @@ -30,17 +30,17 @@ The Asset variant conforms to [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056 - `toUIAmount(rawAmount)` / `fromUIAmount(uiAmount)` are the canonical raw ⇄ UI converters (optional Conversion extension `0x57854fc3`), applying the effective multiplier. The legacy `toScaledBalance` / `toRawBalance` selectors remain dialable for backwards compatibility but are no longer advertised in `IB20Asset`. - `supportsInterface(bytes4)` (ERC-165, `0x01ffc9a7`) returns `true` for those four extension IDs and for ERC-165 itself. -**Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `setUIMultiplier` and from `updateMultiplier`. `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelScheduledMultiplier` and by `updateMultiplier` when it clears a live pending. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. +**Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `setUIMultiplier` and from `updateUIMultiplier` (which stamps `effectiveAtTimestamp = block.timestamp`), satisfying ERC-8056's "emit on every multiplier change". `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelScheduledMultiplier` and by `updateUIMultiplier` when it clears a *live* pending — so an instant override that supersedes a live schedule intentionally emits **both** events (the cancel first, then the update). `AssetV2` never emits the legacy V1 `MultiplierUpdated(uint256)` event, and that event is not part of the `IB20Asset` surface. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. ### 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. +All multiplier-derived reads (`toUIAmount` / `scaledBalanceOf` / `totalSupplyUI` divide by `WAD_PRECISION`; `fromUIAmount` 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 `fromUIAmount` 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. +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 `updateUIMultiplier` bypasses the scheduling window entirely, so it should likewise be pause-bracketed. ## Announcements @@ -83,7 +83,7 @@ Each Asset token can carry an arbitrary set of named metadata entries — a gene ### `OPERATOR_ROLE` -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. +Gates `announce`, `setUIMultiplier`, `cancelScheduledMultiplier`, and `updateUIMultiplier` (and the retained legacy `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/script/smoke/abis.py b/script/smoke/abis.py index 220c551..6f97c20 100644 --- a/script/smoke/abis.py +++ b/script/smoke/abis.py @@ -37,6 +37,38 @@ def _load(name: str) -> list[dict[str, Any]]: STABLECOIN_ABI = _load("IB20Stablecoin") POLICY_ABI = _load("IPolicyRegistry") +# The Cobalt interface review de-advertised three selectors from IB20Asset while the precompile +# keeps them permanently dialable (deprecation, not removal): the instant-failsafe multiplier setter +# `updateMultiplier(uint256)` and the conversion helpers `toScaledBalance` / `toRawBalance` (now +# aliased by `updateUIMultiplier` / `toUIAmount` / `fromUIAmount`). The cross-fork asset-lifecycle +# journey dials these legacy selectors because they are the only multiplier/conversion selectors +# present on BOTH Beryl (V1) and Cobalt (V2) — the canonical replacements are V2-only. They are not +# in the compiled IB20Asset ABI anymore, so append their fragments for web3 encoding. +_DEPRECATED_ASSET_SELECTORS: list[dict[str, Any]] = [ + { + "type": "function", + "name": "updateMultiplier", + "stateMutability": "nonpayable", + "inputs": [{"name": "newMultiplier", "type": "uint256"}], + "outputs": [], + }, + { + "type": "function", + "name": "toScaledBalance", + "stateMutability": "view", + "inputs": [{"name": "rawBalance", "type": "uint256"}], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "type": "function", + "name": "toRawBalance", + "stateMutability": "view", + "inputs": [{"name": "scaledBalance", "type": "uint256"}], + "outputs": [{"name": "rawBalance", "type": "uint256"}], + }, +] +ASSET_ABI = ASSET_ABI + _DEPRECATED_ASSET_SELECTORS + ALL_ABIS = [FACTORY_ABI, ASSET_ABI, STABLECOIN_ABI, POLICY_ABI] diff --git a/script/smoke/journeys/asset_lifecycle.py b/script/smoke/journeys/asset_lifecycle.py index 9e1e060..2febc52 100644 --- a/script/smoke/journeys/asset_lifecycle.py +++ b/script/smoke/journeys/asset_lifecycle.py @@ -77,6 +77,11 @@ def _journey(c: Chain, tok) -> None: c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), config.amt(1010, 18), "alice balance after batch") c.assert_eq(tok.functions.balanceOf(c.BOB).call(), config.amt(271, 18), "bob balance after batch") + # Cross-fork journey: dial the legacy `updateMultiplier` / `toScaledBalance` / `toRawBalance` + # selectors — the only multiplier/conversion selectors present on BOTH Beryl (V1) and Cobalt + # (V2). Their canonical replacements (`updateUIMultiplier` / `toUIAmount` / `fromUIAmount`) are + # V2-only, so using them here would break the Beryl run. The rebase event stays fork-aware in + # `_events` (V1 `MultiplierUpdated` vs V2 `UIMultiplierUpdated`). step(7, "announce + rebase: updateMultiplier(2e18); scaled view doubles") rebase = init_call(c.asset_abi, "updateMultiplier", config.amt(2, 18)) c.send(tok.functions.announce([rebase], "smoke-rebase-1", "2x rebase", "ipfs://smoke/rebase-1"), c.deployer) diff --git a/script/smoke/journeys/scheduled_multiplier.py b/script/smoke/journeys/scheduled_multiplier.py index ad75f62..eda8aae 100644 --- a/script/smoke/journeys/scheduled_multiplier.py +++ b/script/smoke/journeys/scheduled_multiplier.py @@ -1,7 +1,7 @@ """ERC-8056 scheduled-multiplier smoketest (AssetV2 @ Cobalt). Exercises the "Scaled UI Amount" surface added to the Asset variant at Cobalt: the scheduled -`setUIMultiplier` path (with its guards), `cancelScheduledMultiplier`, the `updateMultiplier` +`setUIMultiplier` path (with its guards), `cancelScheduledMultiplier`, the `updateUIMultiplier` instant-failsafe V2 event semantics, the ERC-8056 read aliases, and ERC-165 advertisement. Fork-gated: the whole surface is version-specific, so the journey probes `supportsInterface` @@ -23,9 +23,9 @@ from ..chain import Chain, log, ok, skip, step from ..codec import AssetCreateParams, init_call -# ERC-8056 events. UIMultiplierUpdated is emitted by both setUIMultiplier and (on V2) updateMultiplier; -# UIMultiplierUpdateCancelled by cancelScheduledMultiplier and by updateMultiplier when it clears a -# live pending. V1_UPDATED is the superseded V1 event that V2's updateMultiplier must NOT emit. +# ERC-8056 events. UIMultiplierUpdated is emitted by both setUIMultiplier and (on V2) updateUIMultiplier; +# UIMultiplierUpdateCancelled by cancelScheduledMultiplier and by updateUIMultiplier when it clears a +# live pending. V1_UPDATED is the superseded V1 event that V2's updateUIMultiplier must NOT emit. UI_UPDATED = "UIMultiplierUpdated(uint256,uint256,uint256)" CANCELLED = "UIMultiplierUpdateCancelled(uint256,uint256)" V1_UPDATED = "MultiplierUpdated(uint256)" @@ -63,11 +63,11 @@ def _interface_ids(c: Chain, tok) -> None: def _current_multiplier_and_aliases(c: Chain, tok) -> None: - step(2, "seed a non-unit current multiplier: updateMultiplier(2e18) — V2 emits UIMultiplierUpdated, not MultiplierUpdated") + step(2, "seed a non-unit current multiplier: updateUIMultiplier(2e18) — V2 emits UIMultiplierUpdated, not MultiplierUpdated") c.send(tok.functions.mint(c.ALICE, config.amt(1000, 18)), c.deployer) - receipt = c.send(tok.functions.updateMultiplier(config.amt(2, 18)), c.deployer) - c.assert_log(receipt, UI_UPDATED, "updateMultiplier emits UIMultiplierUpdated") - c.assert_no_log(receipt, V1_UPDATED, "V2 updateMultiplier does NOT emit the V1 MultiplierUpdated") + receipt = c.send(tok.functions.updateUIMultiplier(config.amt(2, 18)), c.deployer) + c.assert_log(receipt, UI_UPDATED, "updateUIMultiplier emits UIMultiplierUpdated") + c.assert_no_log(receipt, V1_UPDATED, "V2 updateUIMultiplier does NOT emit the V1 MultiplierUpdated") c.assert_eq(tok.functions.multiplier().call(), config.amt(2, 18), "multiplier == 2e18 immediately") step(3, "ERC-8056 read aliases mirror their B20 originals") @@ -77,8 +77,8 @@ def _current_multiplier_and_aliases(c: Chain, tok) -> None: "balanceOfUI(alice) == scaledBalanceOf(alice)") c.assert_eq(tok.functions.balanceOfUI(c.ALICE).call(), raw * 2, "balanceOfUI(alice) == 2 * balanceOf(alice)") total = tok.functions.totalSupply().call() - c.assert_eq(tok.functions.totalSupplyUI().call(), tok.functions.toScaledBalance(total).call(), - "totalSupplyUI() == toScaledBalance(totalSupply())") + c.assert_eq(tok.functions.totalSupplyUI().call(), tok.functions.toUIAmount(total).call(), + "totalSupplyUI() == toUIAmount(totalSupply())") def _schedule_reverts(c: Chain, tok) -> None: @@ -131,21 +131,21 @@ def _schedule_and_cancel(c: Chain, tok) -> None: def _failsafe_clears_pending(c: Chain, tok) -> None: - step(9, "updateMultiplier instant-failsafe clears a live pending: UIMultiplierUpdated + UIMultiplierUpdateCancelled, not MultiplierUpdated") + step(9, "updateUIMultiplier instant-failsafe clears a live pending: UIMultiplierUpdated + UIMultiplierUpdateCancelled, not MultiplierUpdated") cleared_target, cleared_sched = config.amt(5, 18), _now(c) + 3600 c.send(tok.functions.setUIMultiplier(cleared_target, cleared_sched), c.deployer) - receipt = c.send(tok.functions.updateMultiplier(config.amt(6, 18)), c.deployer) - c.assert_log(receipt, UI_UPDATED, "updateMultiplier emits UIMultiplierUpdated") + receipt = c.send(tok.functions.updateUIMultiplier(config.amt(6, 18)), c.deployer) + c.assert_log(receipt, UI_UPDATED, "updateUIMultiplier emits UIMultiplierUpdated") # Decode the cancel: it must carry the pending it cleared, not any live pending. cancelled = c.event_args(receipt, tok, "UIMultiplierUpdateCancelled") c.assert_eq( [cancelled["cancelledMultiplier"], cancelled["cancelledEffectiveAt"]], [cleared_target, cleared_sched], - "UIMultiplierUpdateCancelled payload == the pending that updateMultiplier cleared", + "UIMultiplierUpdateCancelled payload == the pending that updateUIMultiplier cleared", ) - c.assert_no_log(receipt, V1_UPDATED, "V2 updateMultiplier does NOT emit the V1 MultiplierUpdated") - c.assert_eq(tok.functions.multiplier().call(), config.amt(6, 18), "updateMultiplier sets the current multiplier immediately") - c.assert_eq(tok.functions.effectiveAt().call(), 0, "updateMultiplier cleared the pending (effectiveAt() == 0)") + c.assert_no_log(receipt, V1_UPDATED, "V2 updateUIMultiplier does NOT emit the V1 MultiplierUpdated") + c.assert_eq(tok.functions.multiplier().call(), config.amt(6, 18), "updateUIMultiplier sets the current multiplier immediately") + c.assert_eq(tok.functions.effectiveAt().call(), 0, "updateUIMultiplier cleared the pending (effectiveAt() == 0)") def _observe_lazy_flip(c: Chain, tok) -> None: From 9dc7d15cb1e037fb767c3ca0ca0df641af9c46ce Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 4 Aug 2026 18:34:31 -0500 Subject: [PATCH 07/11] chore(BOP-495): drop unnecessary explanatory comments Remove verbose comments in IB20Asset.sol, script/smoke/abis.py, and script/smoke/journeys/asset_lifecycle.py per review feedback; the code is self-explanatory. Co-authored-by: Cursor --- script/smoke/abis.py | 7 ------- script/smoke/journeys/asset_lifecycle.py | 5 ----- src/interfaces/IB20Asset.sol | 8 -------- 3 files changed, 20 deletions(-) diff --git a/script/smoke/abis.py b/script/smoke/abis.py index 6f97c20..92830c7 100644 --- a/script/smoke/abis.py +++ b/script/smoke/abis.py @@ -37,13 +37,6 @@ def _load(name: str) -> list[dict[str, Any]]: STABLECOIN_ABI = _load("IB20Stablecoin") POLICY_ABI = _load("IPolicyRegistry") -# The Cobalt interface review de-advertised three selectors from IB20Asset while the precompile -# keeps them permanently dialable (deprecation, not removal): the instant-failsafe multiplier setter -# `updateMultiplier(uint256)` and the conversion helpers `toScaledBalance` / `toRawBalance` (now -# aliased by `updateUIMultiplier` / `toUIAmount` / `fromUIAmount`). The cross-fork asset-lifecycle -# journey dials these legacy selectors because they are the only multiplier/conversion selectors -# present on BOTH Beryl (V1) and Cobalt (V2) — the canonical replacements are V2-only. They are not -# in the compiled IB20Asset ABI anymore, so append their fragments for web3 encoding. _DEPRECATED_ASSET_SELECTORS: list[dict[str, Any]] = [ { "type": "function", diff --git a/script/smoke/journeys/asset_lifecycle.py b/script/smoke/journeys/asset_lifecycle.py index 2febc52..9e1e060 100644 --- a/script/smoke/journeys/asset_lifecycle.py +++ b/script/smoke/journeys/asset_lifecycle.py @@ -77,11 +77,6 @@ def _journey(c: Chain, tok) -> None: c.assert_eq(tok.functions.balanceOf(c.ALICE).call(), config.amt(1010, 18), "alice balance after batch") c.assert_eq(tok.functions.balanceOf(c.BOB).call(), config.amt(271, 18), "bob balance after batch") - # Cross-fork journey: dial the legacy `updateMultiplier` / `toScaledBalance` / `toRawBalance` - # selectors — the only multiplier/conversion selectors present on BOTH Beryl (V1) and Cobalt - # (V2). Their canonical replacements (`updateUIMultiplier` / `toUIAmount` / `fromUIAmount`) are - # V2-only, so using them here would break the Beryl run. The rebase event stays fork-aware in - # `_events` (V1 `MultiplierUpdated` vs V2 `UIMultiplierUpdated`). step(7, "announce + rebase: updateMultiplier(2e18); scaled view doubles") rebase = init_call(c.asset_abi, "updateMultiplier", config.amt(2, 18)) c.send(tok.functions.announce([rebase], "smoke-rebase-1", "2x rebase", "ipfs://smoke/rebase-1"), c.deployer) diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index a6e9e2e..9f8abce 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -174,14 +174,6 @@ interface IB20Asset is /// @return Current (effective) multiplier. function multiplier() external view returns (uint256); - /// @notice Raw <-> UI conversion is the inherited ERC-8056 Conversion extension - /// `toUIAmount(uint256)` / `fromUIAmount(uint256)` (see `IScaledUIAmountConversion`), - /// which apply `rawAmount * multiplier / WAD_PRECISION` and its floored inverse using - /// the effective multiplier. Integer division rounds toward zero, so the round-trip is - /// not exactly reversible when `multiplier != WAD_PRECISION`. - /// @dev The precompile also retains the legacy `toScaledBalance` / `toRawBalance` selectors - /// (identical behavior), dialable but no longer advertised here. - /// @notice Convenience for `toUIAmount(balanceOf(account))`. /// /// @param account Account whose scaled balance is being queried. From 50e82ab1382ff359781c6b38fa7c8c57087035b5 Mon Sep 17 00:00:00 2001 From: robriks Date: Tue, 4 Aug 2026 20:00:23 -0500 Subject: [PATCH 08/11] feat(BOP-495): instant setter emits deprecated MultiplierUpdated + UIMultiplierUpdated Per reviewer intent, the instant multiplier setter (updateUIMultiplier and the retained updateMultiplier) now emits the deprecated MultiplierUpdated(newMultiplier) alongside the ERC-8056 UIMultiplierUpdated, so indexers on the legacy topic keep working. The scheduled setUIMultiplier still emits only UIMultiplierUpdated. Re-declares MultiplierUpdated on IB20Asset, emits it from MockB20Asset's shared instant-setter body (cancel -> MultiplierUpdated -> UIMultiplierUpdated), and updates the affected unit/regression tests, smoke journeys, and docs. Co-authored-by: Cursor --- docs/B20/Asset.md | 2 +- script/smoke/journeys/asset_lifecycle.py | 10 ++++++---- script/smoke/journeys/scheduled_multiplier.py | 11 +++++----- src/interfaces/IB20Asset.sol | 8 ++++++++ test/lib/mocks/MockB20Asset.sol | 2 ++ test/regression/B20Renames.t.sol | 20 ++++++++++--------- .../B20Asset/multiplier/materialize.t.sol | 2 ++ .../multiplier/updateUIMultiplier.t.sol | 3 +++ 8 files changed, 39 insertions(+), 19 deletions(-) diff --git a/docs/B20/Asset.md b/docs/B20/Asset.md index f50bb35..99fe539 100644 --- a/docs/B20/Asset.md +++ b/docs/B20/Asset.md @@ -30,7 +30,7 @@ The Asset variant conforms to [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056 - `toUIAmount(rawAmount)` / `fromUIAmount(uiAmount)` are the canonical raw ⇄ UI converters (optional Conversion extension `0x57854fc3`), applying the effective multiplier. The legacy `toScaledBalance` / `toRawBalance` selectors remain dialable for backwards compatibility but are no longer advertised in `IB20Asset`. - `supportsInterface(bytes4)` (ERC-165, `0x01ffc9a7`) returns `true` for those four extension IDs and for ERC-165 itself. -**Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `setUIMultiplier` and from `updateUIMultiplier` (which stamps `effectiveAtTimestamp = block.timestamp`), satisfying ERC-8056's "emit on every multiplier change". `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelScheduledMultiplier` and by `updateUIMultiplier` when it clears a *live* pending — so an instant override that supersedes a live schedule intentionally emits **both** events (the cancel first, then the update). `AssetV2` never emits the legacy V1 `MultiplierUpdated(uint256)` event, and that event is not part of the `IB20Asset` surface. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. +**Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `setUIMultiplier` and from `updateUIMultiplier` (which stamps `effectiveAtTimestamp = block.timestamp`), satisfying ERC-8056's "emit on every multiplier change". The instant setter (`updateUIMultiplier` / the retained `updateMultiplier`) additionally emits the **deprecated** `MultiplierUpdated(newMultiplier)` event alongside `UIMultiplierUpdated`, so indexers still watching the legacy topic keep working through the transition; the scheduled `setUIMultiplier` emits only `UIMultiplierUpdated`. `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelScheduledMultiplier` and by the instant setter when it clears a *live* pending — so an instant override that supersedes a live schedule emits the cancel, then `MultiplierUpdated`, then `UIMultiplierUpdated`. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. ### Precision & decimals diff --git a/script/smoke/journeys/asset_lifecycle.py b/script/smoke/journeys/asset_lifecycle.py index 9e1e060..16f0f6d 100644 --- a/script/smoke/journeys/asset_lifecycle.py +++ b/script/smoke/journeys/asset_lifecycle.py @@ -159,9 +159,11 @@ def _edges(c: Chain, tok) -> None: def _events(c: Chain, v2: bool) -> None: step(15, "expected events emitted across the flow") - # Cobalt (AssetV2) reworked the rebase event: the step-7 updateMultiplier emits ERC-8056's - # UIMultiplierUpdated, whereas V1 emits MultiplierUpdated. Assert whichever the fork under test uses. - multiplier_event = "UIMultiplierUpdated(uint256,uint256,uint256)" if v2 else "MultiplierUpdated(uint256)" + # The step-7 updateMultiplier always emits the deprecated MultiplierUpdated; on Cobalt (AssetV2) + # it additionally emits the ERC-8056 UIMultiplierUpdated. Assert both on V2. + multiplier_events = ["MultiplierUpdated(uint256)"] + if v2: + multiplier_events.append("UIMultiplierUpdated(uint256,uint256,uint256)") c.assert_events_emitted( "asset events", "B20Created(address,uint8,string,string,uint8,bytes)", @@ -172,7 +174,7 @@ def _events(c: Chain, v2: bool) -> None: "Approval(address,address,uint256)", "Announcement(address,string,string,string)", "EndAnnouncement(string)", - multiplier_event, + *multiplier_events, "ExtraMetadataUpdated(string,string)", "NameUpdated(address,string)", "SymbolUpdated(address,string)", diff --git a/script/smoke/journeys/scheduled_multiplier.py b/script/smoke/journeys/scheduled_multiplier.py index eda8aae..22ca576 100644 --- a/script/smoke/journeys/scheduled_multiplier.py +++ b/script/smoke/journeys/scheduled_multiplier.py @@ -25,7 +25,8 @@ # ERC-8056 events. UIMultiplierUpdated is emitted by both setUIMultiplier and (on V2) updateUIMultiplier; # UIMultiplierUpdateCancelled by cancelScheduledMultiplier and by updateUIMultiplier when it clears a -# live pending. V1_UPDATED is the superseded V1 event that V2's updateUIMultiplier must NOT emit. +# live pending. V1_UPDATED (the deprecated MultiplierUpdated) is emitted alongside UIMultiplierUpdated +# by the instant setter for backward compatibility. UI_UPDATED = "UIMultiplierUpdated(uint256,uint256,uint256)" CANCELLED = "UIMultiplierUpdateCancelled(uint256,uint256)" V1_UPDATED = "MultiplierUpdated(uint256)" @@ -63,11 +64,11 @@ def _interface_ids(c: Chain, tok) -> None: def _current_multiplier_and_aliases(c: Chain, tok) -> None: - step(2, "seed a non-unit current multiplier: updateUIMultiplier(2e18) — V2 emits UIMultiplierUpdated, not MultiplierUpdated") + step(2, "seed a non-unit current multiplier: updateUIMultiplier(2e18) — V2 emits UIMultiplierUpdated + deprecated MultiplierUpdated") c.send(tok.functions.mint(c.ALICE, config.amt(1000, 18)), c.deployer) receipt = c.send(tok.functions.updateUIMultiplier(config.amt(2, 18)), c.deployer) c.assert_log(receipt, UI_UPDATED, "updateUIMultiplier emits UIMultiplierUpdated") - c.assert_no_log(receipt, V1_UPDATED, "V2 updateUIMultiplier does NOT emit the V1 MultiplierUpdated") + c.assert_log(receipt, V1_UPDATED, "V2 updateUIMultiplier also emits the deprecated MultiplierUpdated") c.assert_eq(tok.functions.multiplier().call(), config.amt(2, 18), "multiplier == 2e18 immediately") step(3, "ERC-8056 read aliases mirror their B20 originals") @@ -131,7 +132,7 @@ def _schedule_and_cancel(c: Chain, tok) -> None: def _failsafe_clears_pending(c: Chain, tok) -> None: - step(9, "updateUIMultiplier instant-failsafe clears a live pending: UIMultiplierUpdated + UIMultiplierUpdateCancelled, not MultiplierUpdated") + step(9, "updateUIMultiplier instant-failsafe clears a live pending: UIMultiplierUpdated + UIMultiplierUpdateCancelled + deprecated MultiplierUpdated") cleared_target, cleared_sched = config.amt(5, 18), _now(c) + 3600 c.send(tok.functions.setUIMultiplier(cleared_target, cleared_sched), c.deployer) receipt = c.send(tok.functions.updateUIMultiplier(config.amt(6, 18)), c.deployer) @@ -143,7 +144,7 @@ def _failsafe_clears_pending(c: Chain, tok) -> None: [cleared_target, cleared_sched], "UIMultiplierUpdateCancelled payload == the pending that updateUIMultiplier cleared", ) - c.assert_no_log(receipt, V1_UPDATED, "V2 updateUIMultiplier does NOT emit the V1 MultiplierUpdated") + c.assert_log(receipt, V1_UPDATED, "V2 updateUIMultiplier also emits the deprecated MultiplierUpdated") c.assert_eq(tok.functions.multiplier().call(), config.amt(6, 18), "updateUIMultiplier sets the current multiplier immediately") c.assert_eq(tok.functions.effectiveAt().call(), 0, "updateUIMultiplier cleared the pending (effectiveAt() == 0)") diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 9f8abce..5c39f1e 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -85,6 +85,14 @@ interface IB20Asset is EVENTS //////////////////////////////////////////////////////////////*/ + /// @notice Deprecated multiplier-change event. The instant setter (`updateUIMultiplier` / + /// `updateMultiplier`) emits this alongside `UIMultiplierUpdated` so indexers on the + /// legacy topic keep working; the scheduled `setUIMultiplier` emits only + /// `UIMultiplierUpdated`. + /// + /// @param multiplier The new immediate multiplier. + event MultiplierUpdated(uint256 multiplier); + /// @notice A scheduled multiplier update was cancelled. Emitted by `cancelScheduledMultiplier`, /// and by `updateUIMultiplier` when it clears a live pending update. /// diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 1760895..4d4dd08 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -312,6 +312,8 @@ contract MockB20Asset is MockB20, IB20Asset { $.multiplier = newMultiplier; if (pendingEff != 0) delete $.pending; if (livePending) emit UIMultiplierUpdateCancelled(pendingMult, pendingEff); + // Emit the deprecated V1 event alongside the ERC-8056 event for backward compatibility. + emit MultiplierUpdated(newMultiplier); emit UIMultiplierUpdated(old, newMultiplier, block.timestamp); } diff --git a/test/regression/B20Renames.t.sol b/test/regression/B20Renames.t.sol index ebf9170..7eeafb0 100644 --- a/test/regression/B20Renames.t.sol +++ b/test/regression/B20Renames.t.sol @@ -84,11 +84,11 @@ contract B20RenamesTest is B20AssetTest { 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 `updateUIMultiplier` must emit the ERC-8056 topic and never the legacy topic. - function test_multiplierEvent_success_widenedToUIMultiplierUpdated(uint256 newMultiplier) public { + /// @notice Verifies the instant setter emits BOTH the ERC-8056 `UIMultiplierUpdated(old, new, + /// effectiveAt)` and the deprecated `MultiplierUpdated(uint256)` (kept for backward + /// compatibility with indexers on the legacy topic). + /// @dev `updateUIMultiplier` must emit both topics. + function test_multiplierEvent_success_emitsUIAndDeprecated(uint256 newMultiplier) public { newMultiplier = bound(newMultiplier, 1, type(uint128).max); _grantOperator(); vm.recordLogs(); @@ -98,8 +98,10 @@ contract B20RenamesTest is B20AssetTest { 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" + assertGt( + _firstLogIndex(logs, LEGACY_MULTIPLIER_UPDATED_SIG), + -1, + "deprecated MultiplierUpdated(uint256) must also be emitted" ); } @@ -201,10 +203,10 @@ contract B20RenamesTest is B20AssetTest { -1, "legacy updateMultiplier must emit the ERC-8056 UIMultiplierUpdated" ); - assertEq( + assertGt( _firstLogIndex(logs, LEGACY_MULTIPLIER_UPDATED_SIG), -1, - "legacy updateMultiplier must not emit MultiplierUpdated" + "legacy updateMultiplier must also emit the deprecated MultiplierUpdated" ); assertEq(asset().multiplier(), newMultiplier, "legacy updateMultiplier must set the current multiplier"); } diff --git a/test/unit/B20Asset/multiplier/materialize.t.sol b/test/unit/B20Asset/multiplier/materialize.t.sol index cbcf311..6be1b8e 100644 --- a/test/unit/B20Asset/multiplier/materialize.t.sol +++ b/test/unit/B20Asset/multiplier/materialize.t.sol @@ -57,6 +57,8 @@ contract B20AssetMaterializeTest is B20AssetTest { vm.expectEmit(false, false, false, true, address(token)); emit IB20Asset.UIMultiplierUpdateCancelled(pendingMultiplier, effectiveAt); vm.expectEmit(false, false, false, true, address(token)); + emit IB20Asset.MultiplierUpdated(instant); + vm.expectEmit(false, false, false, true, address(token)); emit IScaledUIAmount.UIMultiplierUpdated(old, instant, block.timestamp); vm.prank(operator); asset().updateUIMultiplier(instant); diff --git a/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol b/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol index 9fcf6d1..8a22c54 100644 --- a/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol @@ -64,6 +64,9 @@ contract B20AssetUpdateUIMultiplierTest is B20AssetTest { newMultiplier = bound(newMultiplier, 1, type(uint128).max); _grantOperator(); uint256 oldMultiplier = asset().multiplier(); + // Instant setter emits the deprecated MultiplierUpdated (backward compat) then UIMultiplierUpdated. + vm.expectEmit(false, false, false, true, address(token)); + emit IB20Asset.MultiplierUpdated(newMultiplier); vm.expectEmit(false, false, false, true, address(token)); emit IScaledUIAmount.UIMultiplierUpdated(oldMultiplier, newMultiplier, block.timestamp); vm.prank(operator); From 32ee28298487559a7f29d1e40f62dd2112c9e8a0 Mon Sep 17 00:00:00 2001 From: robriks Date: Wed, 5 Aug 2026 16:26:25 -0500 Subject: [PATCH 09/11] refactor(BOP-495): keep deprecated methods in IB20Asset (documented), not removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: match the agreed deprecation pattern (mirroring base/base-std#193's burnBlocked) — retain the legacy functions in the interface, marked DEPRECATED, rather than removing them. Removing them broke the block-explorer customer class. Re-declares updateMultiplier / toScaledBalance / toRawBalance on IB20Asset with `@notice DEPRECATED.` docs pointing at updateUIMultiplier / toUIAmount / fromUIAmount. Reverts the workarounds the removal forced: drops the manual _DEPRECATED_ASSET_SELECTORS ABI fragments in smoke (the selectors are back in the compiled ABI), restores abi.encodeCall in B20FactoryLib.encodeUpdateMultiplier, and switches the deprecation regression tests to typed calls. Docs/mocks updated. Co-authored-by: Cursor --- docs/B20/Asset.md | 6 +-- script/smoke/abis.py | 25 ------------ src/interfaces/IB20Asset.sol | 32 ++++++++++++++- src/lib/B20FactoryLib.sol | 8 ++-- test/lib/mocks/MockB20Asset.sol | 16 ++++---- test/regression/B20Renames.t.sol | 39 +++++++------------ .../encodeUpdateMultiplier.t.sol | 11 +++--- 7 files changed, 64 insertions(+), 73 deletions(-) diff --git a/docs/B20/Asset.md b/docs/B20/Asset.md index 99fe539..b3287ba 100644 --- a/docs/B20/Asset.md +++ b/docs/B20/Asset.md @@ -6,7 +6,7 @@ The Asset variant of B20 — designed for assets of all kinds. Everything in [B2 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()`). `toUIAmount(rawAmount)` converts a raw amount to its scaled view, `fromUIAmount(uiAmount)` 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. (The legacy `toScaledBalance` / `toRawBalance` selectors remain dialable for backwards compatibility but are no longer advertised in `IB20Asset` — see [ERC-8056 conformance](#erc-8056-conformance).) +Read the current multiplier with `multiplier()`; the value is in WAD precision (`1e18`, exposed as `WAD_PRECISION()`). `toUIAmount(rawAmount)` converts a raw amount to its scaled view, `fromUIAmount(uiAmount)` 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. (The legacy `toScaledBalance` / `toRawBalance` are retained in `IB20Asset` as deprecated aliases — see [ERC-8056 conformance](#erc-8056-conformance).) Both multiplier setters validate `newMultiplier` is non-zero and at most `type(uint128).max` (exposed as `MAX_UI_MULTIPLIER()`, reverting `InvalidMultiplier` otherwise). The `uint128` ceiling is the overflow guard: with supply capped at `type(uint128).max`, a `uint128` multiplier keeps `balance * multiplier` inside `uint256`, so balance-derived reads never overflow. @@ -16,7 +16,7 @@ The standard path for a corporate action (a stock split or reinvested stock divi Only **one pending update is live at a time**. Attempting to schedule over an existing pending update reverts `PendingUpdateExists`. 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 `NoScheduledUIMultiplier` when nothing live is scheduled). -`updateUIMultiplier(newMultiplier)` is the **instant failsafe / emergency override**: it sets the multiplier immediately, stamping `effectiveAt = block.timestamp` and clearing any pending update. (The legacy `updateMultiplier` selector remains dialable for backwards compatibility but is no longer advertised in `IB20Asset`.) +`updateUIMultiplier(newMultiplier)` is the **instant failsafe / emergency override**: it sets the multiplier immediately, stamping `effectiveAt = block.timestamp` and clearing any pending update. (The legacy `updateMultiplier` is retained in `IB20Asset` as a deprecated alias.) The pending schedule is observable through the ERC-8056 surface: `newUIMultiplier()` returns the scheduled target while it is live (otherwise it mirrors `uiMultiplier()`). @@ -27,7 +27,7 @@ The Asset variant conforms to [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056 - `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`). -- `toUIAmount(rawAmount)` / `fromUIAmount(uiAmount)` are the canonical raw ⇄ UI converters (optional Conversion extension `0x57854fc3`), applying the effective multiplier. The legacy `toScaledBalance` / `toRawBalance` selectors remain dialable for backwards compatibility but are no longer advertised in `IB20Asset`. +- `toUIAmount(rawAmount)` / `fromUIAmount(uiAmount)` are the canonical raw ⇄ UI converters (optional Conversion extension `0x57854fc3`), applying the effective multiplier. The legacy `toScaledBalance` / `toRawBalance` are retained as deprecated aliases. - `supportsInterface(bytes4)` (ERC-165, `0x01ffc9a7`) returns `true` for those four extension IDs and for ERC-165 itself. **Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `setUIMultiplier` and from `updateUIMultiplier` (which stamps `effectiveAtTimestamp = block.timestamp`), satisfying ERC-8056's "emit on every multiplier change". The instant setter (`updateUIMultiplier` / the retained `updateMultiplier`) additionally emits the **deprecated** `MultiplierUpdated(newMultiplier)` event alongside `UIMultiplierUpdated`, so indexers still watching the legacy topic keep working through the transition; the scheduled `setUIMultiplier` emits only `UIMultiplierUpdated`. `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelScheduledMultiplier` and by the instant setter when it clears a *live* pending — so an instant override that supersedes a live schedule emits the cancel, then `MultiplierUpdated`, then `UIMultiplierUpdated`. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. diff --git a/script/smoke/abis.py b/script/smoke/abis.py index 92830c7..220c551 100644 --- a/script/smoke/abis.py +++ b/script/smoke/abis.py @@ -37,31 +37,6 @@ def _load(name: str) -> list[dict[str, Any]]: STABLECOIN_ABI = _load("IB20Stablecoin") POLICY_ABI = _load("IPolicyRegistry") -_DEPRECATED_ASSET_SELECTORS: list[dict[str, Any]] = [ - { - "type": "function", - "name": "updateMultiplier", - "stateMutability": "nonpayable", - "inputs": [{"name": "newMultiplier", "type": "uint256"}], - "outputs": [], - }, - { - "type": "function", - "name": "toScaledBalance", - "stateMutability": "view", - "inputs": [{"name": "rawBalance", "type": "uint256"}], - "outputs": [{"name": "", "type": "uint256"}], - }, - { - "type": "function", - "name": "toRawBalance", - "stateMutability": "view", - "inputs": [{"name": "scaledBalance", "type": "uint256"}], - "outputs": [{"name": "rawBalance", "type": "uint256"}], - }, -] -ASSET_ABI = ASSET_ABI + _DEPRECATED_ASSET_SELECTORS - ALL_ABIS = [FACTORY_ABI, ASSET_ABI, STABLECOIN_ABI, POLICY_ABI] diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 5c39f1e..38e1ecd 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -182,6 +182,28 @@ interface IB20Asset is /// @return Current (effective) multiplier. function multiplier() external view returns (uint256); + /// @notice DEPRECATED. Converts a raw balance to its scaled view: + /// `rawBalance * multiplier / WAD_PRECISION`. Retained (dialable) for backward + /// compatibility; prefer the ERC-8056 Conversion extension `toUIAmount`. + /// + /// @param rawBalance Raw token amount to scale. + /// + /// @return Scaled balance at the current multiplier. + function toScaledBalance(uint256 rawBalance) external view returns (uint256); + + /// @notice DEPRECATED. Converts a scaled balance back to its raw representation: + /// `scaledBalance * WAD_PRECISION / multiplier`. Retained (dialable) for backward + /// compatibility; prefer the ERC-8056 Conversion extension `fromUIAmount`. + /// + /// @dev Integer division rounds toward zero; conversions are not exactly reversible when + /// `multiplier != WAD_PRECISION`. `toRawBalance(toScaledBalance(x))` may return a + /// value slightly less than `x`. + /// + /// @param scaledBalance Scaled token amount to convert back. + /// + /// @return rawBalance Raw balance at the current multiplier. + function toRawBalance(uint256 scaledBalance) external view returns (uint256 rawBalance); + /// @notice Convenience for `toUIAmount(balanceOf(account))`. /// /// @param account Account whose scaled balance is being queried. @@ -213,14 +235,20 @@ interface IB20Asset is /// cancels any live pending update without a scheduling window. /// Prefer `setUIMultiplier` for routine corporate actions. /// - /// @dev The precompile also retains the legacy `updateMultiplier(uint256)` selector (identical - /// behavior), dialable but deprecated; it is intentionally no longer advertised here. /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `OPERATOR_ROLE`. /// @dev Reverts with `InvalidMultiplier` when `newMultiplier` is zero or above `type(uint128).max`. /// /// @param newMultiplier New multiplier scaled to `WAD_PRECISION`; must be in `(0, type(uint128).max]`. function updateUIMultiplier(uint256 newMultiplier) external; + /// @notice DEPRECATED. Legacy alias of `updateUIMultiplier` with identical behavior (sets the + /// multiplier immediately, clears any live pending, and emits both `MultiplierUpdated` + /// and `UIMultiplierUpdated`). Retained (dialable) for backward compatibility; prefer + /// `updateUIMultiplier`. + /// + /// @param newMultiplier New multiplier scaled to `WAD_PRECISION`; must be in `(0, type(uint128).max]`. + function updateMultiplier(uint256 newMultiplier) external; + /*////////////////////////////////////////////////////////////// BATCHED ISSUANCE //////////////////////////////////////////////////////////////*/ diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index 8ac6739..85d0e7f 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -210,13 +210,11 @@ library B20FactoryLib { return abi.encodeCall(IB20Asset.updateUIMultiplier, (newMultiplier)); } - /// @notice Encodes a bootstrap initCall to the deprecated `updateMultiplier(uint256)` selector. - /// @dev The selector is retained (dialable) in the precompile but de-advertised from - /// `IB20Asset`, so it is encoded by signature rather than `abi.encodeCall`. Prefer - /// `encodeUpdateUIMultiplier` for new call sites. + /// @notice Encodes a bootstrap initCall to the deprecated `IB20Asset.updateMultiplier`. + /// @dev Retained for backward compatibility; prefer `encodeUpdateUIMultiplier` for new call sites. /// @param newMultiplier New multiplier, scaled to `WAD_PRECISION`. function encodeUpdateMultiplier(uint256 newMultiplier) internal pure returns (bytes memory) { - return abi.encodeWithSignature("updateMultiplier(uint256)", newMultiplier); + return abi.encodeCall(IB20Asset.updateMultiplier, (newMultiplier)); } /// @notice Encodes an initCall / announce inner call to `IB20Asset.setUIMultiplier` diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index 4d4dd08..d96f675 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -168,13 +168,14 @@ contract MockB20Asset is MockB20, IB20Asset { return _fromUIAmount(uiAmount); } - /// @dev Deprecated alias of `toUIAmount`, retained (dialable) so the precompile's legacy - /// selector stays cross-validated by the fork tests. No longer advertised in `IB20Asset`. + /// @dev Deprecated alias of `toUIAmount` with identical behavior; declared deprecated in + /// `IB20Asset` but kept in the interface for backward compatibility. function toScaledBalance(uint256 rawBalance) external view returns (uint256) { return _toUIAmount(rawBalance); } - /// @dev Deprecated alias of `fromUIAmount`, retained (dialable). No longer advertised. + /// @dev Deprecated alias of `fromUIAmount` with identical behavior; declared deprecated in + /// `IB20Asset` but kept in the interface for backward compatibility. function toRawBalance(uint256 scaledBalance) external view returns (uint256) { return _fromUIAmount(scaledBalance); } @@ -237,9 +238,8 @@ contract MockB20Asset is MockB20, IB20Asset { _updateMultiplierNow(newMultiplier); } - /// @notice Deprecated alias of `updateUIMultiplier`, retained (dialable) so the precompile's - /// legacy `updateMultiplier(uint256)` selector stays cross-validated by the fork tests. - /// No longer advertised in `IB20Asset`. + /// @notice Deprecated alias of `updateUIMultiplier`, retained (dialable) with identical behavior. + /// Declared deprecated in `IB20Asset` but kept in the interface for backward compatibility. function updateMultiplier(uint256 newMultiplier) external onlyRole(OPERATOR_ROLE) { _updateMultiplierNow(newMultiplier); } @@ -250,8 +250,8 @@ contract MockB20Asset is MockB20, IB20Asset { /// @dev Advertises ERC-165 itself plus the four claimed ERC-8056 interfaces (core, pending, /// Balances, and Conversion). The Conversion extension (`0x57854fc3`) is claimed after the - /// interface review: `toUIAmount` / `fromUIAmount` are exposed alongside the retained - /// (dialable, de-advertised) legacy `toScaledBalance` / `toRawBalance` selectors. + /// interface review: `toUIAmount` / `fromUIAmount` are the canonical converters, with the + /// legacy `toScaledBalance` / `toRawBalance` retained (deprecated) as aliases. function supportsInterface(bytes4 interfaceId) external pure returns (bool) { return interfaceId == type(IERC165).interfaceId || interfaceId == type(IScaledUIAmount).interfaceId || interfaceId == type(IScaledUIAmountNewUIMultiplier).interfaceId diff --git a/test/regression/B20Renames.t.sol b/test/regression/B20Renames.t.sol index 7eeafb0..d5a7430 100644 --- a/test/regression/B20Renames.t.sol +++ b/test/regression/B20Renames.t.sol @@ -124,24 +124,16 @@ contract B20RenamesTest is B20AssetTest { assertTrue(asset().supportsInterface(0x57854fc3), "IScaledUIAmountConversion (0x57854fc3) must be advertised"); } - /// @notice Verifies the deprecated `toScaledBalance` / `toRawBalance` selectors stay dialable - /// after being de-advertised in favor of the ERC-8056 `toUIAmount` / `fromUIAmount`. - /// @dev Deprecation-not-removal: the precompile permanently retains the legacy conversion - /// selectors (identical behavior); only base-std's advertised interface drops them. - /// Dialed by raw signature since the typed interface no longer declares them. + /// @notice Verifies the deprecated `toScaledBalance` / `toRawBalance` are retained in `IB20Asset` + /// (declared deprecated) and behave identically to the ERC-8056 `toUIAmount` / `fromUIAmount`. + /// @dev Deprecation-not-removal: the legacy conversion selectors stay advertised (marked + /// deprecated) and dialable so block explorers and existing integrations keep working. function test_conversion_deprecated_stillDialable(uint256 amount) public { amount = bound(amount, 0, type(uint128).max); _updateMultiplier(2 * asset().WAD_PRECISION()); - (bool okScaled, bytes memory rawScaled) = - address(token).staticcall(abi.encodeWithSignature("toScaledBalance(uint256)", amount)); - assertTrue(okScaled, "legacy toScaledBalance(uint256) must remain dialable"); - assertEq(abi.decode(rawScaled, (uint256)), asset().toUIAmount(amount), "toScaledBalance must equal toUIAmount"); - - (bool okRaw, bytes memory rawRaw) = - address(token).staticcall(abi.encodeWithSignature("toRawBalance(uint256)", amount)); - assertTrue(okRaw, "legacy toRawBalance(uint256) must remain dialable"); - assertEq(abi.decode(rawRaw, (uint256)), asset().fromUIAmount(amount), "toRawBalance must equal fromUIAmount"); + assertEq(asset().toScaledBalance(amount), asset().toUIAmount(amount), "toScaledBalance must equal toUIAmount"); + assertEq(asset().toRawBalance(amount), asset().fromUIAmount(amount), "toRawBalance must equal fromUIAmount"); } // ============================================================ @@ -184,31 +176,30 @@ contract B20RenamesTest is B20AssetTest { asset().updateUIMultiplier(newMultiplier); } - /// @notice Verifies the deprecated `updateMultiplier(uint256)` selector stays dialable after - /// being de-advertised from `IB20Asset`, and behaves identically to `updateUIMultiplier`. - /// @dev Deprecation-not-removal: the precompile permanently retains the legacy selector so block - /// explorers and existing integrations keep working; only base-std's advertised interface - /// drops it. Dialed by raw signature since the typed interface no longer declares it. + /// @notice Verifies the deprecated `updateMultiplier` is retained in `IB20Asset` (declared + /// deprecated) and behaves identically to `updateUIMultiplier`. + /// @dev Deprecation-not-removal: the legacy selector stays advertised (marked deprecated) and + /// dialable so block explorers and existing integrations keep working; it emits both the + /// deprecated `MultiplierUpdated` and the ERC-8056 `UIMultiplierUpdated`. function test_updateMultiplier_deprecated_stillDialable(uint256 newMultiplier) public { newMultiplier = bound(newMultiplier, 1, type(uint128).max); _grantOperator(); vm.recordLogs(); vm.prank(operator); - (bool ok,) = address(token).call(abi.encodeWithSignature("updateMultiplier(uint256)", newMultiplier)); - assertTrue(ok, "legacy updateMultiplier(uint256) selector must remain dialable (deprecated, not removed)"); + asset().updateMultiplier(newMultiplier); Vm.Log[] memory logs = vm.getRecordedLogs(); assertGt( _firstLogIndex(logs, UI_MULTIPLIER_UPDATED_SIG), -1, - "legacy updateMultiplier must emit the ERC-8056 UIMultiplierUpdated" + "deprecated updateMultiplier must emit the ERC-8056 UIMultiplierUpdated" ); assertGt( _firstLogIndex(logs, LEGACY_MULTIPLIER_UPDATED_SIG), -1, - "legacy updateMultiplier must also emit the deprecated MultiplierUpdated" + "deprecated updateMultiplier must also emit MultiplierUpdated" ); - assertEq(asset().multiplier(), newMultiplier, "legacy updateMultiplier must set the current multiplier"); + assertEq(asset().multiplier(), newMultiplier, "deprecated updateMultiplier must set the current multiplier"); } /// @notice Verifies METADATA_ROLE is administered by DEFAULT_ADMIN_ROLE on a freshly created token diff --git a/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol b/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol index fa6dbc0..e728f96 100644 --- a/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol +++ b/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol @@ -17,12 +17,11 @@ contract B20FactoryLibEncodeUpdateMultiplierTest is B20FactoryLibTest { assertEq(actual, expected, "init-call must match abi.encodeCall(IB20Asset.updateUIMultiplier, ...)"); } - /// @notice Verifies the deprecated encoder still binds the legacy `updateMultiplier(uint256)` selector. - /// @dev The legacy selector is de-advertised from `IB20Asset` but retained (dialable) in the - /// precompile, so the encoder targets it by signature. Pins that binding stays intact. - function test_encodeUpdateMultiplier_success_matchesLegacySelector(uint256 newMultiplier) public pure { - bytes memory expected = abi.encodeWithSignature("updateMultiplier(uint256)", newMultiplier); + /// @notice Verifies the deprecated encoder matches `abi.encodeCall(IB20Asset.updateMultiplier, ...)`. + /// @dev `updateMultiplier` is retained (deprecated) in `IB20Asset`; pins the selector binding. + function test_encodeUpdateMultiplier_success_matchesAbiEncodeCall(uint256 newMultiplier) public pure { + bytes memory expected = abi.encodeCall(IB20Asset.updateMultiplier, (newMultiplier)); bytes memory actual = B20FactoryLib.encodeUpdateMultiplier(newMultiplier); - assertEq(actual, expected, "init-call must match the legacy updateMultiplier(uint256) selector"); + assertEq(actual, expected, "init-call must match abi.encodeCall(IB20Asset.updateMultiplier, ...)"); } } From 399ee9340b24825c826e5ad402abdde9aee3c863 Mon Sep 17 00:00:00 2001 From: robriks Date: Wed, 5 Aug 2026 18:45:42 -0500 Subject: [PATCH 10/11] docs(BOP-495): fix smoke README multiplier-event description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback (base/base-std#192): the smoke journeys now assert the instant setter emits the deprecated MultiplierUpdated alongside UIMultiplierUpdated (dual event), so correct the README rows — the `multiplier` journey exercises updateUIMultiplier and expects UIMultiplierUpdated + UIMultiplierUpdateCancelled + MultiplierUpdated; the `asset` rebase emits both events on Cobalt. Also drops a stale example from the assert_no_log docstring. Co-authored-by: Cursor --- script/smoke/README.md | 4 ++-- script/smoke/chain.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/script/smoke/README.md b/script/smoke/README.md index 28db654..82b1dbc 100644 --- a/script/smoke/README.md +++ b/script/smoke/README.md @@ -109,8 +109,8 @@ Seven "journeys", run as a whole suite (a single journey can still be run via th | Journey | What it exercises | |---|---| | `factory` | Deterministic create + address prediction, the `isB20` / `isB20Initialized` query surface, and creation-time reverts (duplicate salt, bad decimals, bad currency, unknown variant). | -| `asset` | Full Asset-variant lifecycle (18 decimals): mint, transfer, `transferWithMemo`, delegated `transferFrom`, `announce` + `batchMint`, rebase via `updateMultiplier`, metadata, burn, then the gates that must reject (supply cap, pause, role, announcement-id reuse). The rebase event is fork-aware (V1 `MultiplierUpdated` vs Cobalt `UIMultiplierUpdated`). | -| `multiplier` | ERC-8056 scheduled multiplier (AssetV2 @ Cobalt): `setUIMultiplier` scheduling + its guards (`InvalidMultiplier`, `EffectiveAtInPast`, `EffectiveAtTooFar`, `PendingUpdateExists`), `cancelScheduledMultiplier` (+ `NoScheduledUIMultiplier`), the `updateMultiplier` instant-failsafe V2 event semantics (`UIMultiplierUpdated` + `UIMultiplierUpdateCancelled`, *not* `MultiplierUpdated`), the read aliases (`uiMultiplier`/`balanceOfUI`/`totalSupplyUI`), and ERC-165 advertisement. **Skips** cleanly on a pre-Cobalt chain (probed via `supportsInterface(0xa60bf13d)`). | +| `asset` | Full Asset-variant lifecycle (18 decimals): mint, transfer, `transferWithMemo`, delegated `transferFrom`, `announce` + `batchMint`, rebase via `updateMultiplier`, metadata, burn, then the gates that must reject (supply cap, pause, role, announcement-id reuse). The rebase event is fork-aware: V1 emits `MultiplierUpdated`; Cobalt (AssetV2) emits both `MultiplierUpdated` and `UIMultiplierUpdated`. | +| `multiplier` | ERC-8056 scheduled multiplier (AssetV2 @ Cobalt): `setUIMultiplier` scheduling + its guards (`InvalidMultiplier`, `EffectiveAtInPast`, `EffectiveAtTooFar`, `PendingUpdateExists`), `cancelScheduledMultiplier` (+ `NoScheduledUIMultiplier`), the `updateUIMultiplier` instant-failsafe V2 event semantics (`UIMultiplierUpdated` + `UIMultiplierUpdateCancelled` + the deprecated `MultiplierUpdated`), the read aliases (`uiMultiplier`/`balanceOfUI`/`totalSupplyUI`), and ERC-165 advertisement. **Skips** cleanly on a pre-Cobalt chain (probed via `supportsInterface(0xa60bf13d)`). | | `stablecoin` | Stablecoin-variant deltas (fixed 6 decimals, immutable currency) plus the regulated freeze-and-seize path (blocklist policy + `burnBlocked`). | | `seize` | Transfer-based seize (AssetV2 @ Cobalt): the `SEIZE_HOLDER_POLICY` membership gate + `SEIZE_ROLE`, `seizeWithMemo` (`Transfer` -> `Memo` -> `Seized`, supply-preserving), its reject gates (`AccountNotSeizable`, role, `InvalidReceiver`, `ContractPaused`), the admin-op decoupling from the receiver policy on `to`, and the independent `SEIZE` pause vector. **Skips** cleanly on a pre-Cobalt chain (probed via the `SEIZE_HOLDER_POLICY()` getter). Complements `stablecoin`, which covers the legacy burn-based `burnBlocked`. | | `policy` | Policy creation (both types), membership, built-in sentinels, the two-step admin transfer lifecycle, and a token actually *enforcing* a policy (`PolicyForbids` on transfer + mint). | diff --git a/script/smoke/chain.py b/script/smoke/chain.py index 2be56e9..cfdbd5d 100644 --- a/script/smoke/chain.py +++ b/script/smoke/chain.py @@ -419,7 +419,7 @@ def assert_log(self, receipt: TxReceipt, sig: str, desc: str) -> None: ok(desc) def assert_no_log(self, receipt: TxReceipt, sig: str, desc: str) -> None: - """Assert this receipt did NOT emit an event with signature `sig` (e.g. the superseded V1 event).""" + """Assert this receipt did NOT emit an event with signature `sig`.""" if self._emitted(receipt, sig): die(f"unexpected event emitted [{desc}]: {sig}") ok(desc) From 22d67a49dfa121c1e63018d3c154da3d37a23625 Mon Sep 17 00:00:00 2001 From: robriks Date: Thu, 6 Aug 2026 12:08:24 -0500 Subject: [PATCH 11/11] refactor(BOP-495): make updateUIMultiplier the scheduled setter; UI-multiplier vocabulary for cancel + errors Addresses ilikesymmetry's review on #192. The canonical Cobalt setter is now the scheduled `updateUIMultiplier(newMultiplier, effectiveAt)` (rename of `setUIMultiplier`), rather than an added instant alias. The instant path stays as the deprecated `updateMultiplier` (retained, dialable). Also, for "UI multiplier update" vocabulary parity: - cancelScheduledMultiplier -> cancelUIMultiplierUpdate - PendingUpdateExists(effectiveAt) -> UIMultiplierUpdateExists(effectiveAt) - NoScheduledUIMultiplier -> UIMultiplierUpdateDoesNotExist The redundant instant `updateUIMultiplier(uint256)` alias is dropped. Interface, mock, factory encoders, unit/regression/storage tests, smoke journeys and docs updated in lockstep. Co-authored-by: Cursor --- docs/B20/Asset.md | 14 +-- script/smoke/README.md | 2 +- script/smoke/journeys/scheduled_multiplier.py | 60 +++++------ src/interfaces/IB20Asset.sol | 49 ++++----- src/lib/B20FactoryLib.sol | 25 ++--- test/lib/B20AssetTest.sol | 14 +-- test/lib/mocks/MockB20Asset.sol | 20 ++-- test/regression/B20Renames.t.sol | 24 +++-- ...r.t.sol => cancelUIMultiplierUpdate.t.sol} | 40 +++---- .../B20Asset/multiplier/fromUIAmount.t.sol | 2 +- .../B20Asset/multiplier/materialize.t.sol | 22 ++-- .../B20Asset/multiplier/newUIMultiplier.t.sol | 4 +- test/unit/B20Asset/multiplier/reorder.t.sol | 6 +- .../B20Asset/multiplier/setUIMultiplier.t.sol | 101 ------------------ .../unit/B20Asset/multiplier/toUIAmount.t.sol | 2 +- .../multiplier/updateMultiplier.t.sol | 75 +++++++++++++ ...sol => updateMultiplier_revertOrder.t.sol} | 12 +-- .../multiplier/updateUIMultiplier.t.sol | 100 ++++++++++------- .../encodeUpdateMultiplier.t.sol | 18 ++-- test/unit/storage/B20AssetFullLayout.t.sol | 2 +- .../storage/MockB20AssetSlotHelpers.t.sol | 2 +- 21 files changed, 294 insertions(+), 300 deletions(-) rename test/unit/B20Asset/multiplier/{cancelScheduledMultiplier.t.sol => cancelUIMultiplierUpdate.t.sol} (65%) delete mode 100644 test/unit/B20Asset/multiplier/setUIMultiplier.t.sol create mode 100644 test/unit/B20Asset/multiplier/updateMultiplier.t.sol rename test/unit/B20Asset/multiplier/{updateUIMultiplier_revertOrder.t.sol => updateMultiplier_revertOrder.t.sol} (84%) diff --git a/docs/B20/Asset.md b/docs/B20/Asset.md index b3287ba..67384bd 100644 --- a/docs/B20/Asset.md +++ b/docs/B20/Asset.md @@ -12,11 +12,11 @@ Both multiplier setters validate `newMultiplier` is non-zero and at most `type(u ### 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`. +The standard path for a corporate action (a stock split or reinvested stock dividend) is to **schedule** the change ahead of time with `updateUIMultiplier(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 `PendingUpdateExists`. 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 `NoScheduledUIMultiplier` when nothing live is scheduled). +Only **one pending update is live at a time**. Attempting to schedule over an existing pending update reverts `UIMultiplierUpdateExists`. To reorder overlapping corporate actions, explicitly cancel and re-schedule in a single announcement bracket using `announce([cancelUIMultiplierUpdate, updateUIMultiplier(...)])`. `cancelUIMultiplierUpdate()` clears the live pending and restores the no-pending state (reverting `UIMultiplierUpdateDoesNotExist` when nothing live is scheduled). -`updateUIMultiplier(newMultiplier)` is the **instant failsafe / emergency override**: it sets the multiplier immediately, stamping `effectiveAt = block.timestamp` and clearing any pending update. (The legacy `updateMultiplier` is retained in `IB20Asset` as a deprecated alias.) +`updateMultiplier(newMultiplier)` is the **deprecated instant failsafe / emergency override**: it sets the multiplier immediately, stamping `effectiveAt = block.timestamp` and clearing any pending update. It is retained in `IB20Asset` (marked deprecated, still dialable) for backward compatibility; prefer the scheduled `updateUIMultiplier` for routine corporate actions. The pending schedule is observable through the ERC-8056 surface: `newUIMultiplier()` returns the scheduled target while it is live (otherwise it mirrors `uiMultiplier()`). @@ -30,7 +30,7 @@ The Asset variant conforms to [ERC-8056](https://eips.ethereum.org/EIPS/eip-8056 - `toUIAmount(rawAmount)` / `fromUIAmount(uiAmount)` are the canonical raw ⇄ UI converters (optional Conversion extension `0x57854fc3`), applying the effective multiplier. The legacy `toScaledBalance` / `toRawBalance` are retained as deprecated aliases. - `supportsInterface(bytes4)` (ERC-165, `0x01ffc9a7`) returns `true` for those four extension IDs and for ERC-165 itself. -**Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `setUIMultiplier` and from `updateUIMultiplier` (which stamps `effectiveAtTimestamp = block.timestamp`), satisfying ERC-8056's "emit on every multiplier change". The instant setter (`updateUIMultiplier` / the retained `updateMultiplier`) additionally emits the **deprecated** `MultiplierUpdated(newMultiplier)` event alongside `UIMultiplierUpdated`, so indexers still watching the legacy topic keep working through the transition; the scheduled `setUIMultiplier` emits only `UIMultiplierUpdated`. `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelScheduledMultiplier` and by the instant setter when it clears a *live* pending — so an instant override that supersedes a live schedule emits the cancel, then `MultiplierUpdated`, then `UIMultiplierUpdated`. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. +**Events.** Every multiplier change emits `UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAtTimestamp)` — from `updateUIMultiplier` and from `updateMultiplier` (which stamps `effectiveAtTimestamp = block.timestamp`), satisfying ERC-8056's "emit on every multiplier change". The deprecated instant setter (`updateMultiplier`) additionally emits the **deprecated** `MultiplierUpdated(newMultiplier)` event alongside `UIMultiplierUpdated`, so indexers still watching the legacy topic keep working through the transition; the scheduled `updateUIMultiplier` emits only `UIMultiplierUpdated`. `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)` is emitted by `cancelUIMultiplierUpdate` and by the instant setter when it clears a *live* pending — so an instant override that supersedes a live schedule emits the cancel, then `MultiplierUpdated`, then `UIMultiplierUpdated`. The optional ERC-8056 `TransferWithUIAmount` event is intentionally omitted — scaled balances are derivable from the raw `Transfer` and the active multiplier. ### Precision & decimals @@ -40,7 +40,7 @@ All multiplier-derived reads (`toUIAmount` / `scaledBalanceOf` / `totalSupplyUI` ### 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 `updateUIMultiplier` bypasses the scheduling window entirely, so it should likewise be pause-bracketed. +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 @@ -59,7 +59,7 @@ Wrap a set of operations in a single announcement by calling `announce(internalC ```solidity // Disclose and schedule a 2:1 forward split, effective at the ex-date. bytes[] memory internalCalls = new bytes[](1); -internalCalls[0] = abi.encodeCall(IB20Asset.setUIMultiplier, (2e18, exDateTimestamp)); +internalCalls[0] = abi.encodeCall(IB20Asset.updateUIMultiplier, (2e18, exDateTimestamp)); IB20Asset(token).announce({ internalCalls: internalCalls, @@ -83,7 +83,7 @@ Each Asset token can carry an arbitrary set of named metadata entries — a gene ### `OPERATOR_ROLE` -Gates `announce`, `setUIMultiplier`, `cancelScheduledMultiplier`, and `updateUIMultiplier` (and the retained legacy `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. +Gates `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and the deprecated `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/script/smoke/README.md b/script/smoke/README.md index 82b1dbc..a481880 100644 --- a/script/smoke/README.md +++ b/script/smoke/README.md @@ -110,7 +110,7 @@ Seven "journeys", run as a whole suite (a single journey can still be run via th |---|---| | `factory` | Deterministic create + address prediction, the `isB20` / `isB20Initialized` query surface, and creation-time reverts (duplicate salt, bad decimals, bad currency, unknown variant). | | `asset` | Full Asset-variant lifecycle (18 decimals): mint, transfer, `transferWithMemo`, delegated `transferFrom`, `announce` + `batchMint`, rebase via `updateMultiplier`, metadata, burn, then the gates that must reject (supply cap, pause, role, announcement-id reuse). The rebase event is fork-aware: V1 emits `MultiplierUpdated`; Cobalt (AssetV2) emits both `MultiplierUpdated` and `UIMultiplierUpdated`. | -| `multiplier` | ERC-8056 scheduled multiplier (AssetV2 @ Cobalt): `setUIMultiplier` scheduling + its guards (`InvalidMultiplier`, `EffectiveAtInPast`, `EffectiveAtTooFar`, `PendingUpdateExists`), `cancelScheduledMultiplier` (+ `NoScheduledUIMultiplier`), the `updateUIMultiplier` instant-failsafe V2 event semantics (`UIMultiplierUpdated` + `UIMultiplierUpdateCancelled` + the deprecated `MultiplierUpdated`), the read aliases (`uiMultiplier`/`balanceOfUI`/`totalSupplyUI`), and ERC-165 advertisement. **Skips** cleanly on a pre-Cobalt chain (probed via `supportsInterface(0xa60bf13d)`). | +| `multiplier` | ERC-8056 scheduled multiplier (AssetV2 @ Cobalt): `updateUIMultiplier` scheduling + its guards (`InvalidMultiplier`, `EffectiveAtInPast`, `EffectiveAtTooFar`, `UIMultiplierUpdateExists`), `cancelUIMultiplierUpdate` (+ `UIMultiplierUpdateDoesNotExist`), the `updateMultiplier` instant-failsafe V2 event semantics (`UIMultiplierUpdated` + `UIMultiplierUpdateCancelled` + the deprecated `MultiplierUpdated`), the read aliases (`uiMultiplier`/`balanceOfUI`/`totalSupplyUI`), and ERC-165 advertisement. **Skips** cleanly on a pre-Cobalt chain (probed via `supportsInterface(0xa60bf13d)`). | | `stablecoin` | Stablecoin-variant deltas (fixed 6 decimals, immutable currency) plus the regulated freeze-and-seize path (blocklist policy + `burnBlocked`). | | `seize` | Transfer-based seize (AssetV2 @ Cobalt): the `SEIZE_HOLDER_POLICY` membership gate + `SEIZE_ROLE`, `seizeWithMemo` (`Transfer` -> `Memo` -> `Seized`, supply-preserving), its reject gates (`AccountNotSeizable`, role, `InvalidReceiver`, `ContractPaused`), the admin-op decoupling from the receiver policy on `to`, and the independent `SEIZE` pause vector. **Skips** cleanly on a pre-Cobalt chain (probed via the `SEIZE_HOLDER_POLICY()` getter). Complements `stablecoin`, which covers the legacy burn-based `burnBlocked`. | | `policy` | Policy creation (both types), membership, built-in sentinels, the two-step admin transfer lifecycle, and a token actually *enforcing* a policy (`PolicyForbids` on transfer + mint). | diff --git a/script/smoke/journeys/scheduled_multiplier.py b/script/smoke/journeys/scheduled_multiplier.py index 22ca576..8518dbb 100644 --- a/script/smoke/journeys/scheduled_multiplier.py +++ b/script/smoke/journeys/scheduled_multiplier.py @@ -1,7 +1,7 @@ """ERC-8056 scheduled-multiplier smoketest (AssetV2 @ Cobalt). Exercises the "Scaled UI Amount" surface added to the Asset variant at Cobalt: the scheduled -`setUIMultiplier` path (with its guards), `cancelScheduledMultiplier`, the `updateUIMultiplier` +`updateUIMultiplier` path (with its guards), `cancelUIMultiplierUpdate`, the `updateMultiplier` instant-failsafe V2 event semantics, the ERC-8056 read aliases, and ERC-165 advertisement. Fork-gated: the whole surface is version-specific, so the journey probes `supportsInterface` @@ -23,8 +23,8 @@ from ..chain import Chain, log, ok, skip, step from ..codec import AssetCreateParams, init_call -# ERC-8056 events. UIMultiplierUpdated is emitted by both setUIMultiplier and (on V2) updateUIMultiplier; -# UIMultiplierUpdateCancelled by cancelScheduledMultiplier and by updateUIMultiplier when it clears a +# ERC-8056 events. UIMultiplierUpdated is emitted by both updateUIMultiplier and (on V2) updateMultiplier; +# UIMultiplierUpdateCancelled by cancelUIMultiplierUpdate and by updateMultiplier when it clears a # live pending. V1_UPDATED (the deprecated MultiplierUpdated) is emitted alongside UIMultiplierUpdated # by the instant setter for backward compatibility. UI_UPDATED = "UIMultiplierUpdated(uint256,uint256,uint256)" @@ -64,11 +64,11 @@ def _interface_ids(c: Chain, tok) -> None: def _current_multiplier_and_aliases(c: Chain, tok) -> None: - step(2, "seed a non-unit current multiplier: updateUIMultiplier(2e18) — V2 emits UIMultiplierUpdated + deprecated MultiplierUpdated") + step(2, "seed a non-unit current multiplier: updateMultiplier(2e18) — V2 emits UIMultiplierUpdated + deprecated MultiplierUpdated") c.send(tok.functions.mint(c.ALICE, config.amt(1000, 18)), c.deployer) - receipt = c.send(tok.functions.updateUIMultiplier(config.amt(2, 18)), c.deployer) - c.assert_log(receipt, UI_UPDATED, "updateUIMultiplier emits UIMultiplierUpdated") - c.assert_log(receipt, V1_UPDATED, "V2 updateUIMultiplier also emits the deprecated MultiplierUpdated") + receipt = c.send(tok.functions.updateMultiplier(config.amt(2, 18)), c.deployer) + c.assert_log(receipt, UI_UPDATED, "updateMultiplier emits UIMultiplierUpdated") + c.assert_log(receipt, V1_UPDATED, "V2 updateMultiplier also emits the deprecated MultiplierUpdated") c.assert_eq(tok.functions.multiplier().call(), config.amt(2, 18), "multiplier == 2e18 immediately") step(3, "ERC-8056 read aliases mirror their B20 originals") @@ -83,22 +83,22 @@ def _current_multiplier_and_aliases(c: Chain, tok) -> None: def _schedule_reverts(c: Chain, tok) -> None: - # No live pending exists yet, so PendingUpdateExists cannot fire — each guard is the binding revert. + # No live pending exists yet, so UIMultiplierUpdateExists cannot fire — each guard is the binding revert. # Every non-target argument is kept valid so the intended check is what reverts (mirrors the reference). - step(4, "setUIMultiplier input guards: InvalidMultiplier / EffectiveAtInPast / EffectiveAtTooFar") + step(4, "updateUIMultiplier input guards: InvalidMultiplier / EffectiveAtInPast / EffectiveAtTooFar") future = _now(c) + 3600 - c.expect_revert("InvalidMultiplier", tok.functions.setUIMultiplier(0, future), c.DEPLOYER) - c.expect_revert("InvalidMultiplier", tok.functions.setUIMultiplier(1 << 128, future), c.DEPLOYER) - c.expect_revert("EffectiveAtInPast", tok.functions.setUIMultiplier(config.amt(3, 18), _now(c)), c.DEPLOYER) - c.expect_revert("EffectiveAtTooFar", tok.functions.setUIMultiplier(config.amt(3, 18), 1 << 64), c.DEPLOYER) + c.expect_revert("InvalidMultiplier", tok.functions.updateUIMultiplier(0, future), c.DEPLOYER) + c.expect_revert("InvalidMultiplier", tok.functions.updateUIMultiplier(1 << 128, future), c.DEPLOYER) + c.expect_revert("EffectiveAtInPast", tok.functions.updateUIMultiplier(config.amt(3, 18), _now(c)), c.DEPLOYER) + c.expect_revert("EffectiveAtTooFar", tok.functions.updateUIMultiplier(config.amt(3, 18), 1 << 64), c.DEPLOYER) def _schedule_and_cancel(c: Chain, tok) -> None: old = tok.functions.uiMultiplier().call() sched = _now(c) + 3600 target = config.amt(3, 18) - step(5, f"setUIMultiplier({target}, now+3600) schedules a pending update (read-only assertions; no time travel)") - receipt = c.send(tok.functions.setUIMultiplier(target, sched), c.deployer) + step(5, f"updateUIMultiplier({target}, now+3600) schedules a pending update (read-only assertions; no time travel)") + receipt = c.send(tok.functions.updateUIMultiplier(target, sched), c.deployer) # Decode the receipt (not just presence): the scheduled target + effectiveAt are exactly what a # presence-only check can't verify. ui = c.event_args(receipt, tok, "UIMultiplierUpdated") @@ -111,11 +111,11 @@ def _schedule_and_cancel(c: Chain, tok) -> None: c.assert_eq(tok.functions.effectiveAt().call(), sched, "effectiveAt() == schedule time") c.assert_eq(tok.functions.uiMultiplier().call(), old, "uiMultiplier() still reads the old value while pending is future") - step(6, "a second setUIMultiplier while a live pending exists -> PendingUpdateExists") - c.expect_revert("PendingUpdateExists", tok.functions.setUIMultiplier(config.amt(4, 18), _now(c) + 7200), c.DEPLOYER) + step(6, "a second updateUIMultiplier while a live pending exists -> UIMultiplierUpdateExists") + c.expect_revert("UIMultiplierUpdateExists", tok.functions.updateUIMultiplier(config.amt(4, 18), _now(c) + 7200), c.DEPLOYER) - step(7, "cancelScheduledMultiplier clears the live pending -> UIMultiplierUpdateCancelled, effectiveAt() == 0") - receipt = c.send(tok.functions.cancelScheduledMultiplier(), c.deployer) + step(7, "cancelUIMultiplierUpdate clears the live pending -> UIMultiplierUpdateCancelled, effectiveAt() == 0") + receipt = c.send(tok.functions.cancelUIMultiplierUpdate(), c.deployer) cancelled = c.event_args(receipt, tok, "UIMultiplierUpdateCancelled") c.assert_eq( [cancelled["cancelledMultiplier"], cancelled["cancelledEffectiveAt"]], @@ -127,26 +127,26 @@ def _schedule_and_cancel(c: Chain, tok) -> None: "no-live-pending: newUIMultiplier() == uiMultiplier()") c.assert_eq(tok.functions.uiMultiplier().call(), old, "cancel leaves the current multiplier untouched") - step(8, "cancelScheduledMultiplier with nothing scheduled -> NoScheduledUIMultiplier") - c.expect_revert("NoScheduledUIMultiplier", tok.functions.cancelScheduledMultiplier(), c.DEPLOYER) + step(8, "cancelUIMultiplierUpdate with nothing scheduled -> UIMultiplierUpdateDoesNotExist") + c.expect_revert("UIMultiplierUpdateDoesNotExist", tok.functions.cancelUIMultiplierUpdate(), c.DEPLOYER) def _failsafe_clears_pending(c: Chain, tok) -> None: - step(9, "updateUIMultiplier instant-failsafe clears a live pending: UIMultiplierUpdated + UIMultiplierUpdateCancelled + deprecated MultiplierUpdated") + step(9, "updateMultiplier instant-failsafe clears a live pending: UIMultiplierUpdated + UIMultiplierUpdateCancelled + deprecated MultiplierUpdated") cleared_target, cleared_sched = config.amt(5, 18), _now(c) + 3600 - c.send(tok.functions.setUIMultiplier(cleared_target, cleared_sched), c.deployer) - receipt = c.send(tok.functions.updateUIMultiplier(config.amt(6, 18)), c.deployer) - c.assert_log(receipt, UI_UPDATED, "updateUIMultiplier emits UIMultiplierUpdated") + c.send(tok.functions.updateUIMultiplier(cleared_target, cleared_sched), c.deployer) + receipt = c.send(tok.functions.updateMultiplier(config.amt(6, 18)), c.deployer) + c.assert_log(receipt, UI_UPDATED, "updateMultiplier emits UIMultiplierUpdated") # Decode the cancel: it must carry the pending it cleared, not any live pending. cancelled = c.event_args(receipt, tok, "UIMultiplierUpdateCancelled") c.assert_eq( [cancelled["cancelledMultiplier"], cancelled["cancelledEffectiveAt"]], [cleared_target, cleared_sched], - "UIMultiplierUpdateCancelled payload == the pending that updateUIMultiplier cleared", + "UIMultiplierUpdateCancelled payload == the pending that updateMultiplier cleared", ) - c.assert_log(receipt, V1_UPDATED, "V2 updateUIMultiplier also emits the deprecated MultiplierUpdated") - c.assert_eq(tok.functions.multiplier().call(), config.amt(6, 18), "updateUIMultiplier sets the current multiplier immediately") - c.assert_eq(tok.functions.effectiveAt().call(), 0, "updateUIMultiplier cleared the pending (effectiveAt() == 0)") + c.assert_log(receipt, V1_UPDATED, "V2 updateMultiplier also emits the deprecated MultiplierUpdated") + c.assert_eq(tok.functions.multiplier().call(), config.amt(6, 18), "updateMultiplier sets the current multiplier immediately") + c.assert_eq(tok.functions.effectiveAt().call(), 0, "updateMultiplier cleared the pending (effectiveAt() == 0)") def _observe_lazy_flip(c: Chain, tok) -> None: @@ -155,7 +155,7 @@ def _observe_lazy_flip(c: Chain, tok) -> None: old = tok.functions.uiMultiplier().call() sched = _now(c) + window step(10, f"opt-in lazy flip: schedule {target} at now+{window}s, poll multiplier() up to {timeout}s for the matured value") - c.send(tok.functions.setUIMultiplier(target, sched), c.deployer) + c.send(tok.functions.updateUIMultiplier(target, sched), c.deployer) c.assert_eq(tok.functions.uiMultiplier().call(), old, "uiMultiplier() still old immediately after scheduling") deadline = time.time() + timeout diff --git a/src/interfaces/IB20Asset.sol b/src/interfaces/IB20Asset.sol index 38e1ecd..6afd4bd 100644 --- a/src/interfaces/IB20Asset.sol +++ b/src/interfaces/IB20Asset.sol @@ -34,29 +34,29 @@ interface IB20Asset is /// @notice `updateExtraMetadata` was called with an empty `key`. error InvalidMetadataKey(); - /// @notice A multiplier setter (`setUIMultiplier` or `updateUIMultiplier`) was called with a - /// multiplier of zero or above the `type(uint128).max` overflow guard. + /// @notice A multiplier setter (`updateUIMultiplier`, or the deprecated `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 + /// @notice `updateUIMultiplier` 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 + /// @notice `updateUIMultiplier` 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 + /// @notice `updateUIMultiplier` was called while a live pending update already exists /// - /// @param pendingEffectiveAt The `effectiveAt` of the live pending update. - error PendingUpdateExists(uint256 pendingEffectiveAt); + /// @param effectiveAt The `effectiveAt` of the live pending update. + error UIMultiplierUpdateExists(uint256 effectiveAt); - /// @notice `cancelScheduledMultiplier` was called when there is no live pending update - error NoScheduledUIMultiplier(); + /// @notice `cancelUIMultiplierUpdate` was called when there is no live pending update + error UIMultiplierUpdateDoesNotExist(); /// @notice A batched function was called with parallel arrays of differing lengths. /// @@ -87,13 +87,13 @@ interface IB20Asset is /// @notice Deprecated multiplier-change event. The instant setter (`updateUIMultiplier` / /// `updateMultiplier`) emits this alongside `UIMultiplierUpdated` so indexers on the - /// legacy topic keep working; the scheduled `setUIMultiplier` emits only + /// legacy topic keep working; the scheduled `updateUIMultiplier` emits only /// `UIMultiplierUpdated`. /// /// @param multiplier The new immediate multiplier. event MultiplierUpdated(uint256 multiplier); - /// @notice A scheduled multiplier update was cancelled. Emitted by `cancelScheduledMultiplier`, + /// @notice A scheduled multiplier update was cancelled. Emitted by `cancelUIMultiplierUpdate`, /// and by `updateUIMultiplier` when it clears a live pending update. /// /// @param cancelledMultiplier The pending multiplier that was cleared. @@ -113,7 +113,7 @@ interface IB20Asset is ROLE CONSTANTS //////////////////////////////////////////////////////////////*/ - /// @notice Required to call `announce`, `setUIMultiplier`, `cancelScheduledMultiplier`, and + /// @notice Required to call `announce`, `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and /// `updateUIMultiplier`. The metadata setters (`updateName`, `updateSymbol`, /// `updateExtraMetadata`) are gated by the inherited `METADATA_ROLE` instead. /// @return Role constant. @@ -211,42 +211,35 @@ interface IB20Asset is /// @return Scaled balance. function scaledBalanceOf(address account) external view returns (uint256); - /// @notice Schedules a multiplier update to take effect at `effectiveAt` — the standard path + /// @notice Schedules a UI-multiplier update to take effect at `effectiveAt` — the canonical 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 or above `type(uint128).max`. /// @dev Reverts with `EffectiveAtInPast` when `effectiveAt` is not in the future. /// @dev Reverts with `EffectiveAtTooFar` when `effectiveAt` exceeds `type(uint64).max`. - /// @dev Reverts with `PendingUpdateExists` when a live pending update already exists. + /// @dev Reverts with `UIMultiplierUpdateExists` when a live pending update already exists. /// /// @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; + function updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) external; /// @notice Cancels the single live pending update, restoring the no-pending state /// (`effectiveAt` resets to 0). /// /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `OPERATOR_ROLE`. - /// @dev Reverts with `NoScheduledUIMultiplier` when there is no live pending update. - function cancelScheduledMultiplier() external; + /// @dev Reverts with `UIMultiplierUpdateDoesNotExist` when there is no live pending update. + function cancelUIMultiplierUpdate() 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. + /// @notice DEPRECATED. Instant failsafe / emergency override — sets the current multiplier + /// immediately and cancels any live pending update without a scheduling window, emitting + /// both `MultiplierUpdated` and `UIMultiplierUpdated`. Retained (dialable) for backward + /// compatibility; prefer the scheduled `updateUIMultiplier` for routine corporate actions. /// /// @dev Reverts with `AccessControlUnauthorizedAccount` when the caller does not hold `OPERATOR_ROLE`. /// @dev Reverts with `InvalidMultiplier` when `newMultiplier` is zero or above `type(uint128).max`. /// /// @param newMultiplier New multiplier scaled to `WAD_PRECISION`; must be in `(0, type(uint128).max]`. - function updateUIMultiplier(uint256 newMultiplier) external; - - /// @notice DEPRECATED. Legacy alias of `updateUIMultiplier` with identical behavior (sets the - /// multiplier immediately, clears any live pending, and emits both `MultiplierUpdated` - /// and `UIMultiplierUpdated`). Retained (dialable) for backward compatibility; prefer - /// `updateUIMultiplier`. - /// - /// @param newMultiplier New multiplier scaled to `WAD_PRECISION`; must be in `(0, type(uint128).max]`. function updateMultiplier(uint256 newMultiplier) external; /*////////////////////////////////////////////////////////////// diff --git a/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index 85d0e7f..4b62053 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -204,29 +204,24 @@ library B20FactoryLib { return abi.encodeCall(IB20Asset.updateExtraMetadata, (key, value)); } - /// @notice Encodes a bootstrap initCall to the canonical `IB20Asset.updateUIMultiplier`. + /// @notice Encodes an initCall / announce inner call to the canonical scheduled + /// `IB20Asset.updateUIMultiplier`. /// @param newMultiplier New multiplier, scaled to `WAD_PRECISION`. - function encodeUpdateUIMultiplier(uint256 newMultiplier) internal pure returns (bytes memory) { - return abi.encodeCall(IB20Asset.updateUIMultiplier, (newMultiplier)); + /// @param effectiveAt Timestamp at which `newMultiplier` becomes effective; must be in the future. + function encodeUpdateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) internal pure returns (bytes memory) { + return abi.encodeCall(IB20Asset.updateUIMultiplier, (newMultiplier, effectiveAt)); } - /// @notice Encodes a bootstrap initCall to the deprecated `IB20Asset.updateMultiplier`. - /// @dev Retained for backward compatibility; prefer `encodeUpdateUIMultiplier` for new call sites. + /// @notice Encodes a bootstrap initCall to the deprecated instant `IB20Asset.updateMultiplier`. + /// @dev Retained for backward compatibility; prefer the scheduled `encodeUpdateUIMultiplier`. /// @param newMultiplier New multiplier, scaled to `WAD_PRECISION`. function encodeUpdateMultiplier(uint256 newMultiplier) internal pure returns (bytes memory) { 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, ()); + /// @notice Encodes an announce inner call to `IB20Asset.cancelUIMultiplierUpdate`. + function encodeCancelUIMultiplierUpdate() internal pure returns (bytes memory) { + return abi.encodeCall(IB20Asset.cancelUIMultiplierUpdate, ()); } /*////////////////////////////////////////////////////////////// diff --git a/test/lib/B20AssetTest.sol b/test/lib/B20AssetTest.sol index d520354..087bfd9 100644 --- a/test/lib/B20AssetTest.sol +++ b/test/lib/B20AssetTest.sol @@ -73,28 +73,28 @@ contract B20AssetTest is B20Test { // MULTIPLIER HELPERS // ============================================================ - /// @notice Sets the multiplier immediately via the `operator` actor (canonical - /// `updateUIMultiplier`), lazily granting `OPERATOR_ROLE` on first call. + /// @notice Sets the multiplier immediately via the `operator` actor (deprecated instant + /// `updateMultiplier`), lazily granting `OPERATOR_ROLE` on first call. function _updateMultiplier(uint256 newMultiplier) internal { _grantOperator(); vm.prank(operator); - asset().updateUIMultiplier(newMultiplier); + 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 { + function _updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt) internal { _grantOperator(); vm.prank(operator); - asset().setUIMultiplier(newMultiplier, effectiveAt); + asset().updateUIMultiplier(newMultiplier, effectiveAt); } /// @notice Cancels the live pending multiplier via the `operator` /// actor, lazily granting `OPERATOR_ROLE` on first call. - function _cancelScheduledMultiplier() internal { + function _cancelUIMultiplierUpdate() internal { _grantOperator(); vm.prank(operator); - asset().cancelScheduledMultiplier(); + asset().cancelUIMultiplierUpdate(); } // ============================================================ diff --git a/test/lib/mocks/MockB20Asset.sol b/test/lib/mocks/MockB20Asset.sol index d96f675..4902e09 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -199,7 +199,7 @@ contract MockB20Asset is MockB20, IB20Asset { /// 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) { + function updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt_) external onlyRole(OPERATOR_ROLE) { if (newMultiplier == 0 || newMultiplier > MAX_UI_MULTIPLIER) revert InvalidMultiplier(); if (effectiveAt_ <= block.timestamp) revert EffectiveAtInPast(effectiveAt_); if (effectiveAt_ > type(uint64).max) revert EffectiveAtTooFar(effectiveAt_); @@ -207,7 +207,7 @@ contract MockB20Asset is MockB20, IB20Asset { MockB20AssetStorage.Layout storage $ = MockB20AssetStorage.layout(); uint256 pendingEff = $.pending.effectiveAt; // A live pending blocks a new schedule. - if (pendingEff > block.timestamp) revert PendingUpdateExists(pendingEff); + if (pendingEff > block.timestamp) revert UIMultiplierUpdateExists(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; @@ -221,25 +221,21 @@ contract MockB20Asset is MockB20, IB20Asset { } /// @notice Cancels the single live pending update, restoring the no-pending state. - function cancelScheduledMultiplier() external onlyRole(OPERATOR_ROLE) { + function cancelUIMultiplierUpdate() 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 NoScheduledUIMultiplier(); + if (pendingEff <= block.timestamp) revert UIMultiplierUpdateDoesNotExist(); delete $.pending; emit UIMultiplierUpdateCancelled(pendingMult, pendingEff); } - /// @notice Sets the current multiplier immediately and clears any pending. Canonical ERC-8056 - /// "UI Multiplier" vocabulary name for the instant failsafe. - function updateUIMultiplier(uint256 newMultiplier) external onlyRole(OPERATOR_ROLE) { - _updateMultiplierNow(newMultiplier); - } - - /// @notice Deprecated alias of `updateUIMultiplier`, retained (dialable) with identical behavior. - /// Declared deprecated in `IB20Asset` but kept in the interface for backward compatibility. + /// @notice DEPRECATED instant failsafe: sets the current multiplier immediately and clears any + /// pending, emitting both `MultiplierUpdated` and `UIMultiplierUpdated`. Declared + /// deprecated in `IB20Asset` but kept (dialable) for backward compatibility; prefer the + /// scheduled `updateUIMultiplier`. function updateMultiplier(uint256 newMultiplier) external onlyRole(OPERATOR_ROLE) { _updateMultiplierNow(newMultiplier); } diff --git a/test/regression/B20Renames.t.sol b/test/regression/B20Renames.t.sol index d5a7430..637cf3f 100644 --- a/test/regression/B20Renames.t.sol +++ b/test/regression/B20Renames.t.sol @@ -84,24 +84,30 @@ contract B20RenamesTest is B20AssetTest { 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 instant setter emits BOTH the ERC-8056 `UIMultiplierUpdated(old, new, - /// effectiveAt)` and the deprecated `MultiplierUpdated(uint256)` (kept for backward - /// compatibility with indexers on the legacy topic). - /// @dev `updateUIMultiplier` must emit both topics. - function test_multiplierEvent_success_emitsUIAndDeprecated(uint256 newMultiplier) public { + /// @notice Verifies the canonical scheduled setter is `updateUIMultiplier(uint256,uint256)`, that + /// the pre-rename `setUIMultiplier(uint256,uint256)` selector is gone, and that the + /// scheduled setter emits only the ERC-8056 `UIMultiplierUpdated` (the deprecated + /// `MultiplierUpdated` is reserved for the instant `updateMultiplier`). + /// @dev `updateUIMultiplier` is the rename of `setUIMultiplier`; the old selector must not resolve. + function test_scheduledSetter_success_renamedFromSetUIMultiplier(uint256 newMultiplier) public { newMultiplier = bound(newMultiplier, 1, type(uint128).max); _grantOperator(); vm.recordLogs(); vm.prank(operator); - asset().updateUIMultiplier(newMultiplier); + asset().updateUIMultiplier(newMultiplier, block.timestamp + 1); Vm.Log[] memory logs = vm.getRecordedLogs(); assertGt( _firstLogIndex(logs, UI_MULTIPLIER_UPDATED_SIG), -1, "UIMultiplierUpdated(old,new,effAt) must be emitted" ); - assertGt( + assertEq( _firstLogIndex(logs, LEGACY_MULTIPLIER_UPDATED_SIG), -1, - "deprecated MultiplierUpdated(uint256) must also be emitted" + "scheduled updateUIMultiplier must NOT emit the deprecated MultiplierUpdated" + ); + // The pre-rename scheduled selector is gone. + _assertSelectorRemoved( + abi.encodeWithSignature("setUIMultiplier(uint256,uint256)", newMultiplier, block.timestamp + 1), + "setUIMultiplier(uint256,uint256) must not resolve (renamed to updateUIMultiplier)" ); } @@ -173,7 +179,7 @@ contract B20RenamesTest is B20AssetTest { vm.expectRevert( abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, bob, B20Constants.OPERATOR_ROLE) ); - asset().updateUIMultiplier(newMultiplier); + asset().updateUIMultiplier(newMultiplier, block.timestamp + 1); } /// @notice Verifies the deprecated `updateMultiplier` is retained in `IB20Asset` (declared diff --git a/test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol b/test/unit/B20Asset/multiplier/cancelUIMultiplierUpdate.t.sol similarity index 65% rename from test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol rename to test/unit/B20Asset/multiplier/cancelUIMultiplierUpdate.t.sol index 372ef82..009b25c 100644 --- a/test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/cancelUIMultiplierUpdate.t.sol @@ -8,16 +8,16 @@ import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; -contract B20AssetCancelScheduledMultiplierTest is B20AssetTest { +contract B20AssetCancelUIMultiplierUpdateTest 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 { + function test_cancelUIMultiplierUpdate_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); + _updateUIMultiplier(newMultiplier, effectiveAt); - _cancelScheduledMultiplier(); + _cancelUIMultiplierUpdate(); assertEq( uint256(vm.load(address(token), MockB20AssetStorage.pendingSlot())), 0, "slot 4 must be cleared on cancel" @@ -27,57 +27,57 @@ contract B20AssetCancelScheduledMultiplierTest is B20AssetTest { } /// @notice Verifies cancel emits UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt) - function test_cancelScheduledMultiplier_success_emitsEvent(uint256 newMultiplier, uint256 effectiveAt) public { + function test_cancelUIMultiplierUpdate_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); + _updateUIMultiplier(newMultiplier, effectiveAt); vm.expectEmit(false, false, false, true, address(token)); emit IB20Asset.UIMultiplierUpdateCancelled(newMultiplier, effectiveAt); vm.prank(operator); - asset().cancelScheduledMultiplier(); + asset().cancelUIMultiplierUpdate(); } /// @notice Verifies cancel does not disturb the current effective multiplier - function test_cancelScheduledMultiplier_success_leavesCurrentUntouched(uint256 current) public { + function test_cancelUIMultiplierUpdate_success_leavesCurrentUntouched(uint256 current) public { current = bound(current, 1, type(uint128).max); _updateMultiplier(current); - _setUIMultiplier(2e18, block.timestamp + 1 days); + _updateUIMultiplier(2e18, block.timestamp + 1 days); - _cancelScheduledMultiplier(); + _cancelUIMultiplierUpdate(); 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 { + function test_cancelUIMultiplierUpdate_revert_unauthorized(address caller) public { _assumeValidCaller(caller); vm.assume(caller != admin); vm.assume(caller != operator); - _setUIMultiplier(2e18, block.timestamp + 1 days); + _updateUIMultiplier(2e18, block.timestamp + 1 days); vm.prank(caller); vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, OPERATOR_ROLE)); - asset().cancelScheduledMultiplier(); + asset().cancelUIMultiplierUpdate(); } /// @notice Verifies cancel reverts when nothing is scheduled - function test_cancelScheduledMultiplier_revert_noPending() public { + function test_cancelUIMultiplierUpdate_revert_noPending() public { _grantOperator(); vm.prank(operator); - vm.expectRevert(IB20Asset.NoScheduledUIMultiplier.selector); - asset().cancelScheduledMultiplier(); + vm.expectRevert(IB20Asset.UIMultiplierUpdateDoesNotExist.selector); + asset().cancelUIMultiplierUpdate(); } /// @notice Verifies cancel reverts once the pending has matured - function test_cancelScheduledMultiplier_revert_matured(uint256 newMultiplier) public { + function test_cancelUIMultiplierUpdate_revert_matured(uint256 newMultiplier) public { newMultiplier = bound(newMultiplier, 1, type(uint128).max); uint256 effectiveAt = block.timestamp + 1 days; - _setUIMultiplier(newMultiplier, effectiveAt); + _updateUIMultiplier(newMultiplier, effectiveAt); vm.warp(effectiveAt); vm.prank(operator); - vm.expectRevert(IB20Asset.NoScheduledUIMultiplier.selector); - asset().cancelScheduledMultiplier(); + vm.expectRevert(IB20Asset.UIMultiplierUpdateDoesNotExist.selector); + asset().cancelUIMultiplierUpdate(); } } diff --git a/test/unit/B20Asset/multiplier/fromUIAmount.t.sol b/test/unit/B20Asset/multiplier/fromUIAmount.t.sol index 27ddcaf..ed02191 100644 --- a/test/unit/B20Asset/multiplier/fromUIAmount.t.sol +++ b/test/unit/B20Asset/multiplier/fromUIAmount.t.sol @@ -37,7 +37,7 @@ contract B20AssetFromUIAmountTest is B20AssetTest { /// @notice Verifies fromUIAmount applies the WAD fallback when the stored multiplier is zero /// @dev A stored `multiplier` of zero resolves as `WAD_PRECISION` on the read surface. - /// `updateUIMultiplier(0)` now reverts (InvalidMultiplier), so we zero the slot via + /// `updateMultiplier(0)` now reverts (InvalidMultiplier), so we zero the slot via /// vm.store to isolate the read-path fallback from write-path validation. function test_fromUIAmount_success_explicitZeroMultiplierFallsBackToWad(uint256 uiAmount) public { uiAmount = bound(uiAmount, 0, type(uint128).max); diff --git a/test/unit/B20Asset/multiplier/materialize.t.sol b/test/unit/B20Asset/multiplier/materialize.t.sol index 6be1b8e..4f83421 100644 --- a/test/unit/B20Asset/multiplier/materialize.t.sol +++ b/test/unit/B20Asset/multiplier/materialize.t.sol @@ -16,10 +16,10 @@ contract B20AssetMaterializeTest is B20AssetTest { bytes32 internal constant CANCELLED_SIG = keccak256("UIMultiplierUpdateCancelled(uint256,uint256)"); /// @notice Verifies scheduling over a *matured* pending folds it into the current multiplier - function test_setUIMultiplier_success_materializesMaturedPending() public { + function test_updateUIMultiplier_success_materializesMaturedPending() public { uint256 first = 2e18; uint256 firstEffectiveAt = block.timestamp + 1 days; - _setUIMultiplier(first, firstEffectiveAt); + _updateUIMultiplier(first, firstEffectiveAt); vm.warp(firstEffectiveAt + 1); assertEq(asset().uiMultiplier(), first, "precondition: first schedule has matured"); @@ -29,7 +29,7 @@ contract B20AssetMaterializeTest is B20AssetTest { vm.expectEmit(false, false, false, true, address(token)); emit IScaledUIAmount.UIMultiplierUpdated(first, second, secondEffectiveAt); vm.prank(operator); - asset().setUIMultiplier(second, secondEffectiveAt); + asset().updateUIMultiplier(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"); @@ -45,11 +45,11 @@ contract B20AssetMaterializeTest is B20AssetTest { assertEq(asset().uiMultiplier(), second, "second schedule flips in on maturity"); } - /// @notice Verifies updateUIMultiplier clears a *live* pending and emits the cancellation - function test_updateUIMultiplier_success_clearsLivePending() public { + /// @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); + _updateUIMultiplier(pendingMultiplier, effectiveAt); uint256 instant = 5e18; uint256 old = asset().uiMultiplier(); @@ -61,27 +61,27 @@ contract B20AssetMaterializeTest is B20AssetTest { vm.expectEmit(false, false, false, true, address(token)); emit IScaledUIAmount.UIMultiplierUpdated(old, instant, block.timestamp); vm.prank(operator); - asset().updateUIMultiplier(instant); + 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 updateUIMultiplier clears a *matured* pending WITHOUT a cancellation event + /// @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 — `UIMultiplierUpdateCancelled` fires only for a live pending. - function test_updateUIMultiplier_success_clearsMaturedPendingNoCancelEvent() public { + function test_updateMultiplier_success_clearsMaturedPendingNoCancelEvent() public { uint256 matured = 2e18; uint256 effectiveAt = block.timestamp + 1 days; - _setUIMultiplier(matured, effectiveAt); + _updateUIMultiplier(matured, effectiveAt); vm.warp(effectiveAt + 1); uint256 instant = 5e18; _grantOperator(); vm.recordLogs(); vm.prank(operator); - asset().updateUIMultiplier(instant); + asset().updateMultiplier(instant); Vm.Log[] memory logs = vm.getRecordedLogs(); assertEq( diff --git a/test/unit/B20Asset/multiplier/newUIMultiplier.t.sol b/test/unit/B20Asset/multiplier/newUIMultiplier.t.sol index 6970547..6334cc4 100644 --- a/test/unit/B20Asset/multiplier/newUIMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/newUIMultiplier.t.sol @@ -13,7 +13,7 @@ contract B20AssetNewUIMultiplierTest is B20AssetTest { effectiveAt = bound(effectiveAt, block.timestamp + 1, type(uint64).max); uint256 oldMultiplier = asset().uiMultiplier(); - _setUIMultiplier(newMultiplier, effectiveAt); + _updateUIMultiplier(newMultiplier, effectiveAt); assertEq(asset().newUIMultiplier(), newMultiplier, "newUIMultiplier must report the live pending target"); assertEq(asset().effectiveAt(), effectiveAt, "effectiveAt must report the schedule time"); @@ -27,7 +27,7 @@ contract B20AssetNewUIMultiplierTest is B20AssetTest { function test_newUIMultiplier_success_maturedMirrorsUiMultiplier(uint256 newMultiplier) public { newMultiplier = bound(newMultiplier, 1, type(uint128).max); uint256 effectiveAt = block.timestamp + 5 days; - _setUIMultiplier(newMultiplier, effectiveAt); + _updateUIMultiplier(newMultiplier, effectiveAt); vm.warp(effectiveAt + 1); assertEq(asset().newUIMultiplier(), asset().uiMultiplier(), "matured: newUIMultiplier == uiMultiplier"); diff --git a/test/unit/B20Asset/multiplier/reorder.t.sol b/test/unit/B20Asset/multiplier/reorder.t.sol index 6f8870b..ba968cd 100644 --- a/test/unit/B20Asset/multiplier/reorder.t.sol +++ b/test/unit/B20Asset/multiplier/reorder.t.sol @@ -14,14 +14,14 @@ import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; contract B20AssetReorderTest is B20AssetTest { function test_reorder_success_cancelThenScheduleInOneBracket() public { uint256 firstEffectiveAt = block.timestamp + 1 days; - _setUIMultiplier(2e18, firstEffectiveAt); + _updateUIMultiplier(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)); + calls[0] = abi.encodeCall(IB20Asset.cancelUIMultiplierUpdate, ()); + calls[1] = abi.encodeCall(IB20Asset.updateUIMultiplier, (secondMultiplier, secondEffectiveAt)); _grantOperator(); _announce(operator, calls, "reorder-2026-Q3", "reorder split", "https://disclosures.example/"); diff --git a/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol b/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol deleted file mode 100644 index 360056c..0000000 --- a/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol +++ /dev/null @@ -1,101 +0,0 @@ -// 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/IERC8056.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_pendingUpdateExists(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.PendingUpdateExists.selector, firstEffectiveAt)); - asset().setUIMultiplier(3e18, secondEffectiveAt); - } -} diff --git a/test/unit/B20Asset/multiplier/toUIAmount.t.sol b/test/unit/B20Asset/multiplier/toUIAmount.t.sol index ecd48fc..36b96e9 100644 --- a/test/unit/B20Asset/multiplier/toUIAmount.t.sol +++ b/test/unit/B20Asset/multiplier/toUIAmount.t.sol @@ -37,7 +37,7 @@ contract B20AssetToUIAmountTest is B20AssetTest { /// @notice Verifies toUIAmount applies the WAD fallback when the stored multiplier is zero /// @dev A stored `multiplier` of zero resolves as `WAD_PRECISION` on the read surface. - /// `updateUIMultiplier(0)` now reverts (InvalidMultiplier), so we zero the slot via + /// `updateMultiplier(0)` now reverts (InvalidMultiplier), so we zero the slot via /// vm.store to isolate the read-path fallback from write-path validation. function test_toUIAmount_success_explicitZeroMultiplierFallsBackToWad(uint256 rawAmount) public { rawAmount = bound(rawAmount, 0, type(uint128).max); diff --git a/test/unit/B20Asset/multiplier/updateMultiplier.t.sol b/test/unit/B20Asset/multiplier/updateMultiplier.t.sol new file mode 100644 index 0000000..aeb80dd --- /dev/null +++ b/test/unit/B20Asset/multiplier/updateMultiplier.t.sol @@ -0,0 +1,75 @@ +// 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/IERC8056.sol"; + +import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; + +contract B20AssetUpdateMultiplierTest is B20AssetTest { + /// @notice Verifies updateMultiplier reverts when caller lacks OPERATOR_ROLE + /// @dev Access control: only role-holders can rotate the multiplier; checks + /// AccessControlUnauthorizedAccount with OPERATOR_ROLE. + function test_updateMultiplier_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().updateMultiplier(newMultiplier); + } + + /// @notice Verifies updateMultiplier reverts when newMultiplier is zero + /// @dev Input validation: zero is an invalid multiplier because stored zero is the + /// uninitialized-storage sentinel (read path normalizes it to WAD). Passing zero + /// would create an event/read inconsistency for off-chain indexers. + function test_updateMultiplier_revert_zeroMultiplier() public { + _grantOperator(); + vm.prank(operator); + vm.expectRevert(IB20Asset.InvalidMultiplier.selector); + 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 { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + _updateMultiplier(newMultiplier); + assertEq( + uint256(vm.load(address(token), MockB20AssetStorage.multiplierSlot())), + newMultiplier, + "stored multiplier slot must reflect the write" + ); + } + + /// @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 { + newMultiplier = bound(newMultiplier, 1, type(uint128).max); + _grantOperator(); + uint256 oldMultiplier = asset().multiplier(); + // Instant setter emits the deprecated MultiplierUpdated (backward compat) then UIMultiplierUpdated. + vm.expectEmit(false, false, false, true, address(token)); + emit IB20Asset.MultiplierUpdated(newMultiplier); + vm.expectEmit(false, false, false, true, address(token)); + emit IScaledUIAmount.UIMultiplierUpdated(oldMultiplier, newMultiplier, block.timestamp); + vm.prank(operator); + asset().updateMultiplier(newMultiplier); + } +} diff --git a/test/unit/B20Asset/multiplier/updateUIMultiplier_revertOrder.t.sol b/test/unit/B20Asset/multiplier/updateMultiplier_revertOrder.t.sol similarity index 84% rename from test/unit/B20Asset/multiplier/updateUIMultiplier_revertOrder.t.sol rename to test/unit/B20Asset/multiplier/updateMultiplier_revertOrder.t.sol index 88da63a..84e2f8b 100644 --- a/test/unit/B20Asset/multiplier/updateUIMultiplier_revertOrder.t.sol +++ b/test/unit/B20Asset/multiplier/updateMultiplier_revertOrder.t.sol @@ -6,15 +6,15 @@ import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; -/// @title Sequential revert-order test for `updateUIMultiplier`. +/// @title Sequential revert-order test for `updateMultiplier`. /// /// @notice **Canonical order (Solidity reference):** /// 1. ROLE (`onlyRole(OPERATOR_ROLE)` modifier) → `AccessControlUnauthorizedAccount` /// 2. INVALID-MULTIPLIER (`newMultiplier == 0`) → `InvalidMultiplier` /// /// Walks from all conditions broken to success, fixing one per step. -contract B20AssetUpdateUIMultiplierRevertOrderTest is B20AssetTest { - function test_updateUIMultiplier_revertOrder(address caller) public { +contract B20AssetUpdateMultiplierRevertOrderTest is B20AssetTest { + function test_updateMultiplier_revertOrder(address caller) public { // Exclude precompiles (which can distort msg.sender) and admin (needed // internally by _grantRole to approve the role grant). _assumeValidCaller(caller); @@ -29,7 +29,7 @@ contract B20AssetUpdateUIMultiplierRevertOrderTest is B20AssetTest { // The role modifier runs before the body's zero-multiplier check. vm.prank(caller); vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, operatorRole)); - asset().updateUIMultiplier(0); + asset().updateMultiplier(0); // Fix: grant OPERATOR_ROLE to caller. _grantRole(operatorRole, caller); @@ -37,12 +37,12 @@ contract B20AssetUpdateUIMultiplierRevertOrderTest is B20AssetTest { // 2. INVALID-MULTIPLIER fires: caller now holds the role, but multiplier is still zero. vm.prank(caller); vm.expectRevert(IB20Asset.InvalidMultiplier.selector); - asset().updateUIMultiplier(0); + asset().updateMultiplier(0); // Fix: pass a non-zero multiplier. // Success: all conditions resolved. vm.prank(caller); - asset().updateUIMultiplier(1e18); + asset().updateMultiplier(1e18); } } diff --git a/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol b/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol index 8a22c54..db5087c 100644 --- a/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol @@ -7,12 +7,40 @@ import {IB20} from "base-std/interfaces/IB20.sol"; import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; import {IScaledUIAmount} from "base-std/interfaces/IERC8056.sol"; -import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; - contract B20AssetUpdateUIMultiplierTest is B20AssetTest { - /// @notice Verifies updateUIMultiplier reverts when caller lacks OPERATOR_ROLE - /// @dev Access control: only role-holders can rotate the multiplier; checks - /// AccessControlUnauthorizedAccount with OPERATOR_ROLE. + /// @notice Verifies updateUIMultiplier emits UIMultiplierUpdated(old, new, effectiveAt) + function test_updateUIMultiplier_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().updateUIMultiplier(newMultiplier, effectiveAt); + } + + /// @notice Verifies the effective multiplier flips lazily exactly at `effectiveAt` + function test_updateUIMultiplier_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(); + + _updateUIMultiplier(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 updateUIMultiplier reverts when the caller lacks OPERATOR_ROLE function test_updateUIMultiplier_revert_unauthorized(address caller, uint256 newMultiplier) public { _assumeValidCaller(caller); vm.assume(caller != admin); @@ -20,56 +48,54 @@ contract B20AssetUpdateUIMultiplierTest is B20AssetTest { vm.prank(caller); vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, OPERATOR_ROLE)); - asset().updateUIMultiplier(newMultiplier); + asset().updateUIMultiplier(newMultiplier, block.timestamp + 1); } - /// @notice Verifies updateUIMultiplier reverts when newMultiplier is zero - /// @dev Input validation: zero is an invalid multiplier because stored zero is the - /// uninitialized-storage sentinel (read path normalizes it to WAD). Passing zero - /// would create an event/read inconsistency for off-chain indexers. + /// @notice Verifies updateUIMultiplier reverts on a zero multiplier function test_updateUIMultiplier_revert_zeroMultiplier() public { _grantOperator(); vm.prank(operator); vm.expectRevert(IB20Asset.InvalidMultiplier.selector); - asset().updateUIMultiplier(0); + asset().updateUIMultiplier(0, block.timestamp + 1); } - /// @notice Verifies updateUIMultiplier reverts when newMultiplier exceeds the uint128 ceiling + /// @notice Verifies updateUIMultiplier reverts above the uint128 ceiling function test_updateUIMultiplier_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().updateUIMultiplier(newMultiplier); + asset().updateUIMultiplier(newMultiplier, block.timestamp + 1); } - /// @notice Verifies updateUIMultiplier 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_updateUIMultiplier_success_writesSlot(uint256 newMultiplier) public { - newMultiplier = bound(newMultiplier, 1, type(uint128).max); - _updateMultiplier(newMultiplier); - assertEq( - uint256(vm.load(address(token), MockB20AssetStorage.multiplierSlot())), - newMultiplier, - "stored multiplier slot must reflect the write" - ); + /// @notice Verifies updateUIMultiplier reverts when effectiveAt is not in the future + function test_updateUIMultiplier_revert_effectiveAtInPast(uint256 effectiveAt) public { + effectiveAt = bound(effectiveAt, 0, block.timestamp); + _grantOperator(); + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(IB20Asset.EffectiveAtInPast.selector, effectiveAt)); + asset().updateUIMultiplier(2e18, effectiveAt); } - /// @notice Verifies updateUIMultiplier 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_updateUIMultiplier_success_emitsEvent(uint256 newMultiplier) public { - newMultiplier = bound(newMultiplier, 1, type(uint128).max); + /// @notice Verifies updateUIMultiplier reverts when effectiveAt exceeds the uint64 storage width + function test_updateUIMultiplier_revert_effectiveAtTooFar(uint256 effectiveAt) public { + effectiveAt = bound(effectiveAt, uint256(type(uint64).max) + 1, type(uint256).max); _grantOperator(); - uint256 oldMultiplier = asset().multiplier(); - // Instant setter emits the deprecated MultiplierUpdated (backward compat) then UIMultiplierUpdated. - vm.expectEmit(false, false, false, true, address(token)); - emit IB20Asset.MultiplierUpdated(newMultiplier); - vm.expectEmit(false, false, false, true, address(token)); - emit IScaledUIAmount.UIMultiplierUpdated(oldMultiplier, newMultiplier, block.timestamp); vm.prank(operator); - asset().updateUIMultiplier(newMultiplier); + vm.expectRevert(abi.encodeWithSelector(IB20Asset.EffectiveAtTooFar.selector, effectiveAt)); + asset().updateUIMultiplier(2e18, effectiveAt); + } + + /// @notice Verifies updateUIMultiplier reverts when a live pending update already exists + function test_updateUIMultiplier_revert_pendingUpdateExists(uint256 firstEffectiveAt, uint256 secondEffectiveAt) + public + { + firstEffectiveAt = bound(firstEffectiveAt, block.timestamp + 1, type(uint64).max); + secondEffectiveAt = bound(secondEffectiveAt, block.timestamp + 1, type(uint64).max); + _updateUIMultiplier(2e18, firstEffectiveAt); + + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(IB20Asset.UIMultiplierUpdateExists.selector, firstEffectiveAt)); + asset().updateUIMultiplier(3e18, secondEffectiveAt); } } diff --git a/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol b/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol index e728f96..b352060 100644 --- a/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol +++ b/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol @@ -7,13 +7,17 @@ import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; import {B20FactoryLibTest} from "base-std-test/lib/B20FactoryLibTest.sol"; contract B20FactoryLibEncodeUpdateMultiplierTest is B20FactoryLibTest { - /// @notice Verifies the canonical encoder matches `abi.encodeCall(IB20Asset.updateUIMultiplier, ...)`. - /// @dev Pins the selector binding and uint argument shape for the bootstrap multiplier - /// init call. The asset variant's scaled-balance reads all derive from the - /// multiplier this call seeds, so a selector/arg drift would silently mis-scale balances. - function test_encodeUpdateUIMultiplier_success_matchesAbiEncodeCall(uint256 newMultiplier) public pure { - bytes memory expected = abi.encodeCall(IB20Asset.updateUIMultiplier, (newMultiplier)); - bytes memory actual = B20FactoryLib.encodeUpdateUIMultiplier(newMultiplier); + /// @notice Verifies the canonical scheduled encoder matches + /// `abi.encodeCall(IB20Asset.updateUIMultiplier, ...)`. + /// @dev Pins the selector binding and argument shape for the scheduled multiplier init call. + /// The asset variant's scaled-balance reads all derive from the multiplier this call + /// seeds, so a selector/arg drift would silently mis-scale balances. + function test_encodeUpdateUIMultiplier_success_matchesAbiEncodeCall(uint256 newMultiplier, uint256 effectiveAt) + public + pure + { + bytes memory expected = abi.encodeCall(IB20Asset.updateUIMultiplier, (newMultiplier, effectiveAt)); + bytes memory actual = B20FactoryLib.encodeUpdateUIMultiplier(newMultiplier, effectiveAt); assertEq(actual, expected, "init-call must match abi.encodeCall(IB20Asset.updateUIMultiplier, ...)"); } diff --git a/test/unit/storage/B20AssetFullLayout.t.sol b/test/unit/storage/B20AssetFullLayout.t.sol index ccd774f..49dd5fc 100644 --- a/test/unit/storage/B20AssetFullLayout.t.sol +++ b/test/unit/storage/B20AssetFullLayout.t.sol @@ -105,7 +105,7 @@ contract B20AssetFullLayoutTest is B20AssetTest { _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); + _updateUIMultiplier(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. diff --git a/test/unit/storage/MockB20AssetSlotHelpers.t.sol b/test/unit/storage/MockB20AssetSlotHelpers.t.sol index 82355ba..b7dacd4 100644 --- a/test/unit/storage/MockB20AssetSlotHelpers.t.sol +++ b/test/unit/storage/MockB20AssetSlotHelpers.t.sol @@ -27,7 +27,7 @@ contract MockB20AssetSlotHelpersTest is B20AssetTest { newMultiplier = bound(newMultiplier, 1, type(uint128).max); effectiveAt = bound(effectiveAt, block.timestamp + 1, type(uint64).max); - _setUIMultiplier(newMultiplier, effectiveAt); + _updateUIMultiplier(newMultiplier, effectiveAt); uint256 packed = uint256(vm.load(address(token), MockB20AssetStorage.pendingSlot())); assertEq(