diff --git a/docs/B20/Asset.md b/docs/B20/Asset.md index fd7db2d1..67384bda 100644 --- a/docs/B20/Asset.md +++ b/docs/B20/Asset.md @@ -6,17 +6,17 @@ 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` 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` (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 -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 `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 `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). -`updateMultiplier(newMultiplier)` is retained as an **instant failsafe / emergency override**: it sets the multiplier immediately, stamping `effectiveAt = block.timestamp` and clearing any pending update. +`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()`). @@ -27,13 +27,14 @@ 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` 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 `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 `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 -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 @@ -58,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, @@ -82,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`, `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 a9d56f25..a579a5af 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`, `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)`). | +| `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): `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 transfer receiver policy on `to`, the `SEIZE_RECEIVER_POLICY` gate on `to` (unset = allow-any, configured = destination must be authorized, else `PolicyForbids`), 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 2be56e9e..cfdbd5d3 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) diff --git a/script/smoke/config.py b/script/smoke/config.py index 8fbd45ba..5dd1531e 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/script/smoke/journeys/asset_lifecycle.py b/script/smoke/journeys/asset_lifecycle.py index 9e1e0601..16f0f6df 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 9f84208b..8518dbb7 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` +`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,11 +23,12 @@ 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; -# MultiplierUpdateCancelled 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 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)" -CANCELLED = "MultiplierUpdateCancelled(uint256,uint256)" +CANCELLED = "UIMultiplierUpdateCancelled(uint256,uint256)" V1_UPDATED = "MultiplierUpdated(uint256)" WAD = config.amt(1, 18) @@ -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: updateMultiplier(2e18) — V2 emits UIMultiplierUpdated, not 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.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") + 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") @@ -77,27 +78,27 @@ 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: - # No live pending exists yet, so ScheduleOverlap 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") @@ -110,40 +111,40 @@ 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 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 -> MultiplierUpdateCancelled, effectiveAt() == 0") - receipt = c.send(tok.functions.cancelScheduledMultiplier(), c.deployer) - cancelled = c.event_args(receipt, tok, "MultiplierUpdateCancelled") + 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"]], [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, "cancelUIMultiplierUpdate with nothing scheduled -> UIMultiplierUpdateDoesNotExist") + c.expect_revert("UIMultiplierUpdateDoesNotExist", tok.functions.cancelUIMultiplierUpdate(), 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 + deprecated MultiplierUpdated") cleared_target, cleared_sched = config.amt(5, 18), _now(c) + 3600 - c.send(tok.functions.setUIMultiplier(cleared_target, cleared_sched), c.deployer) + 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, "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_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)") @@ -154,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 29b7037e..6afd4bd8 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 "./IScaledUIAmount.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 //////////////////////////////////////////////////////////////*/ @@ -22,29 +34,29 @@ 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 - /// 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 ScheduleOverlap(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 NoScheduledMultiplier(); + /// @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. /// @@ -73,12 +85,20 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti EVENTS //////////////////////////////////////////////////////////////*/ - /// @notice A scheduled multiplier update was cancelled. Emitted by `cancelScheduledMultiplier`, - /// and by `updateMultiplier` when it clears a live pending update. + /// @notice Deprecated multiplier-change event. The instant setter (`updateUIMultiplier` / + /// `updateMultiplier`) emits this alongside `UIMultiplierUpdated` so indexers on the + /// 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 `cancelUIMultiplierUpdate`, + /// 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. - 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); @@ -93,8 +113,8 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti ROLE CONSTANTS //////////////////////////////////////////////////////////////*/ - /// @notice Required to call `announce`, `setUIMultiplier`, `cancelScheduledMultiplier`, and - /// `updateMultiplier`. The metadata setters (`updateName`, `updateSymbol`, + /// @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. function OPERATOR_ROLE() external view returns (bytes32); @@ -107,6 +127,13 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// @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 //////////////////////////////////////////////////////////////*/ @@ -155,15 +182,18 @@ 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`. + /// @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 Converts a scaled balance back to its raw representation: - /// `scaledBalance * WAD_PRECISION / multiplier`. + /// @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 @@ -174,36 +204,37 @@ interface IB20Asset is IB20, IERC165, IScaledUIAmount, IScaledUIAmountNewUIMulti /// @return rawBalance Raw balance at the current multiplier. function toRawBalance(uint256 scaledBalance) external view returns (uint256 rawBalance); - /// @notice Convenience for `toScaledBalance(balanceOf(account))`. + /// @notice Convenience for `toUIAmount(balanceOf(account))`. /// /// @param account Account whose scaled balance is being queried. /// /// @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 `ScheduleOverlap` 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 `NoScheduledMultiplier` 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`. diff --git a/src/interfaces/IScaledUIAmount.sol b/src/interfaces/IERC8056.sol similarity index 68% rename from src/interfaces/IScaledUIAmount.sol rename to src/interfaces/IERC8056.sol index f5198486..c99cbe11 100644 --- a/src/interfaces/IScaledUIAmount.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/src/lib/B20FactoryLib.sol b/src/lib/B20FactoryLib.sol index d288cd2b..4b620530 100644 --- a/src/lib/B20FactoryLib.sol +++ b/src/lib/B20FactoryLib.sol @@ -204,22 +204,24 @@ library B20FactoryLib { return abi.encodeCall(IB20Asset.updateExtraMetadata, (key, value)); } - /// @notice Encodes a bootstrap initCall to `IB20Asset.updateMultiplier`. + /// @notice Encodes an initCall / announce inner call to the canonical scheduled + /// `IB20Asset.updateUIMultiplier`. /// @param newMultiplier New multiplier, scaled to `WAD_PRECISION`. - function encodeUpdateMultiplier(uint256 newMultiplier) internal pure returns (bytes memory) { - return abi.encodeCall(IB20Asset.updateMultiplier, (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 an initCall / announce inner call to `IB20Asset.setUIMultiplier` + /// @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`. - /// @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)); + function encodeUpdateMultiplier(uint256 newMultiplier) internal pure returns (bytes memory) { + return abi.encodeCall(IB20Asset.updateMultiplier, (newMultiplier)); } - /// @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 6743e8dc..087bfd96 100644 --- a/test/lib/B20AssetTest.sol +++ b/test/lib/B20AssetTest.sol @@ -73,8 +73,8 @@ 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 (deprecated instant + /// `updateMultiplier`), lazily granting `OPERATOR_ROLE` on first call. function _updateMultiplier(uint256 newMultiplier) internal { _grantOperator(); vm.prank(operator); @@ -83,18 +83,18 @@ contract B20AssetTest is B20Test { /// @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 6420adea..4902e099 100644 --- a/test/lib/mocks/MockB20Asset.sol +++ b/test/lib/mocks/MockB20Asset.sol @@ -7,8 +7,9 @@ import {IERC165} from "base-std/interfaces/IERC165.sol"; import { IScaledUIAmount, IScaledUIAmountNewUIMultiplier, - IScaledUIAmountBalances -} from "base-std/interfaces/IScaledUIAmount.sol"; + IScaledUIAmountBalances, + IScaledUIAmountConversion +} 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"; @@ -70,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 // ============================================================ @@ -153,12 +158,26 @@ 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` with identical behavior; declared deprecated in + /// `IB20Asset` but kept in the interface for backward compatibility. function toScaledBalance(uint256 rawBalance) external view returns (uint256) { - return (rawBalance * _multiplier()) / WAD_PRECISION; + return _toUIAmount(rawBalance); } + /// @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 (scaledBalance * WAD_PRECISION) / _multiplier(); + return _fromUIAmount(scaledBalance); } function scaledBalanceOf(address account) external view returns (uint256) { @@ -180,15 +199,15 @@ 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) { - if (newMultiplier == 0 || newMultiplier > type(uint128).max) revert InvalidMultiplier(); + 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_); 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 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; @@ -202,43 +221,38 @@ 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 NoScheduledMultiplier(); + if (pendingEff <= block.timestamp) revert UIMultiplierUpdateDoesNotExist(); delete $.pending; - emit MultiplierUpdateCancelled(pendingMult, pendingEff); + emit UIMultiplierUpdateCancelled(pendingMult, pendingEff); } - /// @notice Sets the current multiplier immediately and clears any pending. + /// @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) { - if (newMultiplier == 0 || newMultiplier > type(uint128).max) revert InvalidMultiplier(); - MockB20AssetStorage.Layout storage $ = MockB20AssetStorage.layout(); - uint256 pendingMult = $.pending.multiplier; - uint256 pendingEff = $.pending.effectiveAt; - bool livePending = pendingEff > block.timestamp; - - uint256 old = _multiplier(); - $.multiplier = newMultiplier; - if (pendingEff != 0) delete $.pending; - if (livePending) emit MultiplierUpdateCancelled(pendingMult, pendingEff); - emit UIMultiplierUpdated(old, newMultiplier, block.timestamp); + _updateMultiplierNow(newMultiplier); } // ============================================================ // 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 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 - || interfaceId == type(IScaledUIAmountBalances).interfaceId; + || interfaceId == type(IScaledUIAmountBalances).interfaceId + || interfaceId == type(IScaledUIAmountConversion).interfaceId; } // ============================================================ @@ -280,6 +294,37 @@ 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 > MAX_UI_MULTIPLIER) 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 the deprecated V1 event alongside the ERC-8056 event for backward compatibility. + emit MultiplierUpdated(newMultiplier); + 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 05ce1f4c..637cf3f5 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( @@ -84,29 +84,38 @@ 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 `updateMultiplier` must emit the ERC-8056 topic and never the legacy topic. - function test_multiplierEvent_success_widenedToUIMultiplierUpdated(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().updateMultiplier(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" ); assertEq( - _firstLogIndex(logs, LEGACY_MULTIPLIER_UPDATED_SIG), -1, "legacy MultiplierUpdated(uint256) must be gone" + _firstLogIndex(logs, LEGACY_MULTIPLIER_UPDATED_SIG), + -1, + "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)" ); } /// @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 +124,22 @@ 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` 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()); + + assertEq(asset().toScaledBalance(amount), asset().toUIAmount(amount), "toScaledBalance must equal toUIAmount"); + assertEq(asset().toRawBalance(amount), asset().fromUIAmount(amount), "toRawBalance must equal fromUIAmount"); } // ============================================================ @@ -123,7 +147,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 +169,43 @@ 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().updateUIMultiplier(newMultiplier, block.timestamp + 1); + } + + /// @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); asset().updateMultiplier(newMultiplier); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertGt( + _firstLogIndex(logs, UI_MULTIPLIER_UPDATED_SIG), + -1, + "deprecated updateMultiplier must emit the ERC-8056 UIMultiplierUpdated" + ); + assertGt( + _firstLogIndex(logs, LEGACY_MULTIPLIER_UPDATED_SIG), + -1, + "deprecated updateMultiplier must also emit MultiplierUpdated" + ); + 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/B20Asset/announcement/announce.t.sol b/test/unit/B20Asset/announcement/announce.t.sol index f8e4a18b..f403eeee 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 5bd6f847..aafcc90d 100644 --- a/test/unit/B20Asset/constants/precisionConstants.t.sol +++ b/test/unit/B20Asset/constants/precisionConstants.t.sol @@ -5,10 +5,18 @@ 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"); } + + /// @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"); + } } diff --git a/test/unit/B20Asset/erc165/supportsInterface.t.sol b/test/unit/B20Asset/erc165/supportsInterface.t.sol index bdb5126c..6ef4cd3c 100644 --- a/test/unit/B20Asset/erc165/supportsInterface.t.sol +++ b/test/unit/B20Asset/erc165/supportsInterface.t.sol @@ -7,8 +7,9 @@ import {IERC165} from "base-std/interfaces/IERC165.sol"; import { IScaledUIAmount, IScaledUIAmountNewUIMultiplier, - IScaledUIAmountBalances -} from "base-std/interfaces/IScaledUIAmount.sol"; + IScaledUIAmountBalances, + IScaledUIAmountConversion +} from "base-std/interfaces/IERC8056.sol"; contract B20AssetSupportsInterfaceTest is B20AssetTest { // Published ERC-8056 / ERC-165 interface identifiers. @@ -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/cancelScheduledMultiplier.t.sol b/test/unit/B20Asset/multiplier/cancelUIMultiplierUpdate.t.sol similarity index 60% rename from test/unit/B20Asset/multiplier/cancelScheduledMultiplier.t.sol rename to test/unit/B20Asset/multiplier/cancelUIMultiplierUpdate.t.sol index cc828f10..009b25cb 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" @@ -26,58 +26,58 @@ contract B20AssetCancelScheduledMultiplierTest is B20AssetTest { assertEq(asset().newUIMultiplier(), asset().uiMultiplier(), "no-live-pending: newUIMultiplier == uiMultiplier"); } - /// @notice Verifies cancel emits MultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt) - function test_cancelScheduledMultiplier_success_emitsEvent(uint256 newMultiplier, uint256 effectiveAt) public { + /// @notice Verifies cancel emits UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt) + 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.MultiplierUpdateCancelled(newMultiplier, effectiveAt); + 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.NoScheduledMultiplier.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.NoScheduledMultiplier.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 new file mode 100644 index 00000000..ed02191c --- /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. + /// `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); + _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/materialize.t.sol b/test/unit/B20Asset/multiplier/materialize.t.sol index 71828b9b..4f83421a 100644 --- a/test/unit/B20Asset/multiplier/materialize.t.sol +++ b/test/unit/B20Asset/multiplier/materialize.t.sol @@ -6,20 +6,20 @@ import {Vm} from "forge-std/Vm.sol"; import {B20AssetTest} from "base-std-test/lib/B20AssetTest.sol"; import {IB20Asset} from "base-std/interfaces/IB20Asset.sol"; -import {IScaledUIAmount} from "base-std/interfaces/IScaledUIAmount.sol"; +import {IScaledUIAmount} from "base-std/interfaces/IERC8056.sol"; import {MockB20AssetStorage} from "base-std-test/lib/mocks/MockB20Storage.sol"; /// @notice A matured-but-uncancelled pending must be folded into the current multiplier before any /// set/cancel overwrites slot 4, so a scheduled change is never silently lost. contract B20AssetMaterializeTest is B20AssetTest { - bytes32 internal constant CANCELLED_SIG = keccak256("MultiplierUpdateCancelled(uint256,uint256)"); + 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"); @@ -49,13 +49,15 @@ contract B20AssetMaterializeTest is B20AssetTest { 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(); _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 IB20Asset.MultiplierUpdated(instant); vm.expectEmit(false, false, false, true, address(token)); emit IScaledUIAmount.UIMultiplierUpdated(old, instant, block.timestamp); vm.prank(operator); @@ -68,11 +70,11 @@ 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; - _setUIMultiplier(matured, effectiveAt); + _updateUIMultiplier(matured, effectiveAt); vm.warp(effectiveAt + 1); uint256 instant = 5e18; @@ -85,7 +87,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/newUIMultiplier.t.sol b/test/unit/B20Asset/multiplier/newUIMultiplier.t.sol index 69705474..6334cc47 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 6f8870b8..ba968cd3 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/toRawBalance.t.sol b/test/unit/B20Asset/multiplier/toRawBalance.t.sol deleted file mode 100644 index 56808c9b..00000000 --- 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 c86db367..00000000 --- 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 00000000..36b96e9f --- /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. + /// `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); + _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); + } +} diff --git a/test/unit/B20Asset/multiplier/updateMultiplier.t.sol b/test/unit/B20Asset/multiplier/updateMultiplier.t.sol index ff4ad443..aeb80dd1 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"; @@ -64,6 +64,9 @@ contract B20AssetUpdateMultiplierTest 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); diff --git a/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol b/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol similarity index 55% rename from test/unit/B20Asset/multiplier/setUIMultiplier.t.sol rename to test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol index 2f1a1d9c..db5087c4 100644 --- a/test/unit/B20Asset/multiplier/setUIMultiplier.t.sol +++ b/test/unit/B20Asset/multiplier/updateUIMultiplier.t.sol @@ -5,11 +5,11 @@ 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) - function test_setUIMultiplier_success_emitsEvent(uint256 newMultiplier, uint256 effectiveAt) public { +contract B20AssetUpdateUIMultiplierTest is B20AssetTest { + /// @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(); @@ -18,17 +18,17 @@ contract B20AssetSetUIMultiplierTest is B20AssetTest { vm.expectEmit(false, false, false, true, address(token)); emit IScaledUIAmount.UIMultiplierUpdated(oldMultiplier, newMultiplier, effectiveAt); vm.prank(operator); - asset().setUIMultiplier(newMultiplier, effectiveAt); + asset().updateUIMultiplier(newMultiplier, effectiveAt); } /// @notice Verifies the effective multiplier flips lazily exactly at `effectiveAt` - function test_setUIMultiplier_success_lazyFlipAtBoundary(uint256 newMultiplier) public { + 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(); - _setUIMultiplier(newMultiplier, effectiveAt); + _updateUIMultiplier(newMultiplier, effectiveAt); vm.warp(effectiveAt - 1); assertEq(asset().uiMultiplier(), oldMultiplier, "T-1: must still read the old multiplier"); @@ -40,60 +40,62 @@ contract B20AssetSetUIMultiplierTest is B20AssetTest { 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 { + /// @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); vm.assume(caller != operator); vm.prank(caller); vm.expectRevert(abi.encodeWithSelector(IB20.AccessControlUnauthorizedAccount.selector, caller, OPERATOR_ROLE)); - asset().setUIMultiplier(newMultiplier, block.timestamp + 1); + asset().updateUIMultiplier(newMultiplier, block.timestamp + 1); } - /// @notice Verifies setUIMultiplier reverts on a zero multiplier - function test_setUIMultiplier_revert_zeroMultiplier() public { + /// @notice Verifies updateUIMultiplier reverts on a zero multiplier + function test_updateUIMultiplier_revert_zeroMultiplier() public { _grantOperator(); vm.prank(operator); vm.expectRevert(IB20Asset.InvalidMultiplier.selector); - asset().setUIMultiplier(0, block.timestamp + 1); + asset().updateUIMultiplier(0, block.timestamp + 1); } - /// @notice Verifies setUIMultiplier reverts above the uint128 ceiling - function test_setUIMultiplier_revert_aboveUint128Ceiling(uint256 newMultiplier) public { + /// @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().setUIMultiplier(newMultiplier, block.timestamp + 1); + asset().updateUIMultiplier(newMultiplier, block.timestamp + 1); } - /// @notice Verifies setUIMultiplier reverts when effectiveAt is not in the future - function test_setUIMultiplier_revert_effectiveAtInPast(uint256 effectiveAt) public { + /// @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().setUIMultiplier(2e18, effectiveAt); + asset().updateUIMultiplier(2e18, effectiveAt); } - /// @notice Verifies setUIMultiplier reverts when effectiveAt exceeds the uint64 storage width - function test_setUIMultiplier_revert_effectiveAtTooFar(uint256 effectiveAt) public { + /// @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(); vm.prank(operator); vm.expectRevert(abi.encodeWithSelector(IB20Asset.EffectiveAtTooFar.selector, effectiveAt)); - asset().setUIMultiplier(2e18, effectiveAt); + asset().updateUIMultiplier(2e18, effectiveAt); } - /// @notice Verifies setUIMultiplier reverts when a live pending update already exists - function test_setUIMultiplier_revert_scheduleOverlap(uint256 firstEffectiveAt, uint256 secondEffectiveAt) public { + /// @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); - _setUIMultiplier(2e18, firstEffectiveAt); + _updateUIMultiplier(2e18, firstEffectiveAt); vm.prank(operator); - vm.expectRevert(abi.encodeWithSelector(IB20Asset.ScheduleOverlap.selector, firstEffectiveAt)); - asset().setUIMultiplier(3e18, secondEffectiveAt); + 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 f6e0b136..b352060f 100644 --- a/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol +++ b/test/unit/B20FactoryLib/encodeUpdateMultiplier.t.sol @@ -7,10 +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, ...)`. - /// @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. + /// @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, ...)"); + } + + /// @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); diff --git a/test/unit/storage/B20AssetFullLayout.t.sol b/test/unit/storage/B20AssetFullLayout.t.sol index ccd774f0..49dd5fc7 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 82355ba9..b7dacd4f 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(