Audience: teams already integrated against the B20 Asset multiplier surface on Beryl (live today). This note covers only the multiplier and ERC-8056 changes landing at the Cobalt hardfork.
Summary
At Cobalt, the B20 Asset multiplier surface becomes ERC-8056 (“Scaled UI Amount”) conformant and gains a scheduled multiplier setter for corporate actions. Nothing you call today breaks: every Beryl selector, event topic, and error keeps its exact 4-byte selector or topic0 and stays dialable at Cobalt. The deprecations below are advisory, not enforced. To migrate, adopt the canonical ERC-8056 names (uiMultiplier, toUIAmount/fromUIAmount,
balanceOfUI, totalSupplyUI), and move routine multiplier changes from the instant
updateMultiplier(uint256) to the scheduled updateUIMultiplier(uint256,uint256).
Cobalt hasn’t gone live yet. Until it activates, only the Beryl surface exists on-chain.
Mapping table
The selectors and topic0s below are the real values from the frozen ABIs:abi/v1.rs for Beryl,
abi/v2.rs for Cobalt. Every Beryl symbol keeps its selector at Cobalt.
Functions
OPERATOR_ROLE() 0xf5b541a6, WAD_PRECISION() 0x664808a8, announce(...) 0x595135dd,
isAnnouncementIdUsed(string) 0xc0da474e, batchMint(...) 0x68573107,
extraMetadata(string) 0x4ddf9da0, and updateExtraMetadata(string,string) 0xb2851ef5 carry
over unchanged.
Events
Errors
New at Cobalt: adopt these
Scheduled-update lifecycle
updateUIMultiplier(newMultiplier, effectiveAt) is the canonical path for corporate actions, such
as stock splits and reinvested dividends. Only one pending update can be live at a time.
- Schedule: call
updateUIMultiplier(newMultiplier, effectiveAt). This requiresOPERATOR_ROLE, andeffectiveAtmust be strictly in the future. - Read the pending update: while it’s live,
newUIMultiplier()returns the scheduled target,effectiveAt()returns the flip timestamp, anduiMultiplier()/multiplier()still return the current value. - Let it mature: once
block.timestamp >= effectiveAt,uiMultiplier()/multiplier()flip on read. No event fires at maturation. - Or cancel it:
cancelUIMultiplierUpdate()clears a live pending update and emitsUIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt).
announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...).
ERC-8056 view aliases
uiMultiplier()returns the same value asmultiplier().toUIAmount(raw)returns the same value astoScaledBalance(raw).fromUIAmount(ui)returns the same value astoRawBalance(ui).balanceOfUI(account)returns the same value asscaledBalanceOf(account).totalSupplyUI()equalstotalSupply() * uiMultiplier() / WAD_PRECISION.
Bound getter
MAX_UI_MULTIPLIER() returns type(uint128).max, the ceiling both setters enforce. This is the
overflow guard that keeps balance * multiplier inside uint256.
updateMultiplier(uint256) remains as an instant admin failsafe
updateMultiplier(uint256) sets the multiplier immediately and clears any live pending update. It’s
a deprecated admin failsafe, kept for tech debt and emergency overrides, not routine use: use it to
instantly reverse a scheduling mistake, and pair it with pausing in most cases.
Guarantees and edge cases
Q: A scheduled update can be canceled. How do external consumers detect the cancellation?cancelUIMultiplierUpdate() emits UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt) (topic0 0x8838…1cad); so does the instant setter, when it supersedes a live
pending update. Watch that topic to retract a pending flip you previously staged from
UIMultiplierUpdated.
Q: If the admin uses the instant failsafe, how do off-chain indexers keep a linear, gap-free
UI-multiplier lifecycle?
The instant updateMultiplier(uint256) emits both the deprecated MultiplierUpdated(uint256) and
the ERC-8056 UIMultiplierUpdated(old, new, block.timestamp) (and, if it clears a live pending
update, UIMultiplierUpdateCancelled first). Every multiplier change, scheduled or emergency,
appears on the single UIMultiplierUpdated stream, so following that one event never misses a
change. The legacy MultiplierUpdated topic stays available for indexers that haven’t migrated.
Q: How do I tell a live pending update apart from one that already matured, or none at all?
A pending update is live if effectiveAt() > block.timestamp. While it’s live, newUIMultiplier()
returns the scheduled target, which differs from uiMultiplier(). After maturation,
uiMultiplier() already reflects the new value, newUIMultiplier() == uiMultiplier(), and
effectiveAt() stays at the now-past flip timestamp until the next schedule, instant update, or
cancel overwrites it. So a nonzero effectiveAt() that’s <= block.timestamp means “already
applied,” not “pending.” If no update has ever been scheduled, effectiveAt() == 0.
Q: What happens if I schedule an update while one is already pending?
It reverts UIMultiplierUpdateExists(effectiveAt), but only a live pending update blocks the call.
A matured (stale) pending update is silently folded into the current multiplier and overwritten. To
replace a live schedule, call cancelUIMultiplierUpdate() then updateUIMultiplier(...), atomically,
via announce.
Q: What are the bounds on effectiveAt?
It must be strictly in the future: effectiveAt <= block.timestamp reverts
EffectiveAtInPast(effectiveAt). It must also fit the on-chain field:
effectiveAt > type(uint64).max reverts EffectiveAtTooFar(effectiveAt).
Q: What are the bounds on the multiplier?
0 < newMultiplier <= MAX_UI_MULTIPLIER() (type(uint128).max). Zero or above reverts
InvalidMultiplier(). This applies to both updateUIMultiplier and updateMultiplier. You can
read the ceiling from MAX_UI_MULTIPLIER() without risking the revert.
Q: Do raw balances or Transfer semantics change?
No. The multiplier is purely cosmetic: it rescales only the UI/scaled view. balanceOf,
transfer, totalSupply, and Transfer stay raw, and no multiplier change, scheduled or instant,
affects them. Only the *UI / scaled reads move.