diff --git a/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx b/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx index b8913f925..36a2c2216 100644 --- a/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx +++ b/docs/build-on-base/integrate-defi/list-tokenized-stocks.mdx @@ -44,13 +44,17 @@ Multiplier updates come in two forms: - **Instant (deprecated):** [`updateMultiplier`](/specifications/b20/reference/interfaces/ib20-asset/update-multiplier) applies immediately and clears any pending scheduled change. It is a retained emergency failsafe; prefer the scheduled path. - **Cancellation:** clear a pending scheduled update with [`cancelUIMultiplierUpdate`](/specifications/b20/reference/interfaces/ib20-asset/cancel-ui-multiplier-update). -The B20 contract provides several helper functions for common calculations, where `raw` is the number of B20 units and `scaled` is the quantity of stocks redeemable: +The B20 contract provides ERC-8056 helper functions for common calculations, where `raw` is the number of B20 units and `ui` is the quantity of stocks redeemable: | Function | Description | |----------|-------------| -| [`scaledBalanceOf(account)`](/specifications/b20/reference/interfaces/ib20-asset/scaled-balance-of) | Raw balance × multiplier | -| [`toScaledBalance(raw)`](/specifications/b20/reference/interfaces/ib20-asset/to-scaled-balance) | Convert raw amount to scaled | -| [`toRawBalance(scaled)`](/specifications/b20/reference/interfaces/ib20-asset/to-raw-balance) | Convert scaled amount to raw | +| [`uiMultiplier()`](/specifications/b20/reference/interfaces/ib20-asset/ui-multiplier) | Current effective multiplier (WAD) | +| [`balanceOfUI(account)`](/specifications/b20/reference/interfaces/ib20-asset/balance-of-ui) | Raw balance × multiplier | +| [`totalSupplyUI()`](/specifications/b20/reference/interfaces/ib20-asset/total-supply-ui) | Raw total supply × multiplier | +| [`toUIAmount(raw)`](/specifications/b20/reference/interfaces/ib20-asset/to-ui-amount) | Convert raw amount to UI amount | +| [`fromUIAmount(ui)`](/specifications/b20/reference/interfaces/ib20-asset/from-ui-amount) | Convert UI amount to raw | + +`multiplier()` and `scaledBalanceOf(account)` remain the canonical B20 names; `uiMultiplier()` and `balanceOfUI(account)` are their ERC-8056 aliases and return the same values. `toScaledBalance` and `toRawBalance` remain callable but are deprecated in favor of `toUIAmount` and `fromUIAmount`. See the [Cobalt multiplier changelog](/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier#functions). ### Policies @@ -75,7 +79,7 @@ Two design points: - Announcements can be atomically bundled with the token change they describe (for example, the multiplier update for a stock split), keeping onchain records clean. - Descriptions are intentionally human-readable onchain to support public reporting requirements. -**Admin actions:** admin operations and `updateMultiplier` ([`OPERATOR_ROLE`](/specifications/b20/reference/interfaces/ib20-asset/operator-role)) execute immediately when the role holder calls; the B20 standard has no built-in timelock. The `Announcement` and `EndAnnouncement` events are public notice, not an enforced delay. Any timelock or multisig is applied by the issuer at the governance layer. +**Admin actions:** admin operations and multiplier updates ([`OPERATOR_ROLE`](/specifications/b20/reference/interfaces/ib20-asset/operator-role)) execute when the role holder calls (scheduled multiplier updates flip at `effectiveAt`); the B20 standard has no built-in timelock. The `Announcement` and `EndAnnouncement` events are public notice, not an enforced delay. Any timelock or multisig is applied by the issuer at the governance layer. ### Extra Metadata @@ -164,7 +168,7 @@ Two common patterns are used to determine value: For historical OHLC and time-series data, use market-data providers or query Chainlink round history by `roundId`. -Since prices are total-return (multiplier-adjusted), reconstruct the series consistently by applying the multiplier history ([`MultiplierUpdated`](/specifications/b20/reference/interfaces/ib20-asset) events, emitted by both scheduled and instant multiplier changes) if starting from raw share prices. +Since prices are total-return (multiplier-adjusted), reconstruct the series consistently by applying the multiplier history if starting from raw share prices. Build that history from [`UIMultiplierUpdated`](/specifications/b20/reference/interfaces/ib20-asset) events, emitted by both scheduled and instant multiplier changes, and date each change by its `effectiveAtTimestamp`, not the block time. Drop any scheduled update later cancelled by a `UIMultiplierUpdateCancelled` event: it emitted `UIMultiplierUpdated` but never took effect. ## Contract Addresses diff --git a/docs/snippets/AssetDemo.jsx b/docs/snippets/AssetDemo.jsx index d456e344a..d4625e9e4 100644 --- a/docs/snippets/AssetDemo.jsx +++ b/docs/snippets/AssetDemo.jsx @@ -25,7 +25,7 @@ export const AssetDemo = ({ flow }) => { const moduleUrl = (source) => URL.createObjectURL(new Blob([source], { type: "text/javascript" })); const [aaSource, engineSource] = await Promise.all([ fetchText("/static/aa.txt"), - fetchText("/static/vibenet-engine.txt?v=4"), + fetchText("/static/vibenet-engine.txt?v=5"), ]); const aaUrl = moduleUrl(aaSource); const rewritten = engineSource.replace('"./aa.txt"', JSON.stringify(aaUrl)); @@ -214,11 +214,11 @@ export const AssetDemo = ({ flow }) => { { stage: "Load", action: "Load balances", text: "Alice holds 100 raw shares and Bob holds 50.", summary: [["Operation", "Load balances"], ["Multiplier", M("1.0 WAD")], ["Holders", "Alice, Bob"]], - run: (s) => { s.balances.Alice = 100; s.balances.Bob = 50; return { entries: [nfo("multiplier()", "1.0 WAD")], caption: "Raw balances and displayed balances currently match." }; } }, + run: (s) => { s.balances.Alice = 100; s.balances.Bob = 50; return { entries: [nfo("uiMultiplier()", "1.0 WAD")], caption: "Raw balances and displayed balances currently match." }; } }, { stage: "Split", action: "Run split", - text: "Apply the board-approved 2-for-1 split.", - summary: [["Operation", "2-for-1 split"], ["Multiplier", M("1.0 → 2.0 WAD")], ["Symbol", TOKEN], ["Network", NETWORK]], - run: (s) => { s.multiplier = 2; return { entries: [ok("MultiplierUpdated", "1.0 → 2.0 WAD"), nfo("scaledBalanceOf(Alice)", "200 EXM")], caption: "Displayed balances double while raw balances remain unchanged." }; } }, + text: "Schedule the board-approved 2-for-1 split with updateUIMultiplier and let it take effect.", + summary: [["Operation", "updateUIMultiplier"], ["Multiplier", M("1.0 → 2.0 WAD")], ["Symbol", TOKEN], ["Network", NETWORK]], + run: (s) => { s.multiplier = 2; return { entries: [ok("UIMultiplierUpdated", "1.0 → 2.0 WAD"), nfo("balanceOfUI(Alice)", "200 EXM")], caption: "Displayed balances double at effectiveAt while raw balances remain unchanged." }; } }, ], }, pause: { @@ -550,24 +550,28 @@ export const AssetDemo = ({ flow }) => { return { entries: [ txOk(engine, "B20Created", short(created.token), created), - nfo("multiplier()", `${state.multiplier}.0 WAD`, engine.explorerAddress(ctx.token)), + nfo("uiMultiplier()", `${state.multiplier}.0 WAD`, engine.explorerAddress(ctx.token)), ], caption: "Raw and displayed balances currently match.", }; }, async (engine, ctx, state) => { - const tx = await engine.updateMultiplier({ token: ctx.token, multiplier: 2n * 10n ** 18n }); + // effectiveAt must be strictly in the future when the call lands. The margin covers a + // faucet top-up, the cross-tab send lock, or a slow RPC before inclusion. + const effectiveAt = (await engine.latestTimestamp()) + 15n; + const tx = await engine.updateUIMultiplier({ token: ctx.token, multiplier: 2n * 10n ** 18n, effectiveAt }); + await engine.waitForTimestamp(effectiveAt); const [multiplier, displayed] = await Promise.all([ engine.assetMultiplier(ctx.token), - engine.scaledBalanceOf(ctx.token, ctx.addresses.Alice), + engine.balanceOfUI(ctx.token, ctx.addresses.Alice), ]); state.multiplier = Number(multiplier / 10n ** 18n); return { entries: [ - txOk(engine, "MultiplierUpdated", "1.0 → 2.0 WAD", tx), - nfo("scaledBalanceOf(Alice)", `${engine.displayUnits(displayed)} EXM`, engine.explorerAddress(ctx.token)), + txOk(engine, "UIMultiplierUpdated", "1.0 → 2.0 WAD", tx), + nfo("balanceOfUI(Alice)", `${engine.displayUnits(displayed)} EXM`, engine.explorerAddress(ctx.token)), ], - caption: "Displayed balances doubled while the raw balances stayed unchanged.", + caption: "Displayed balances doubled at effectiveAt while the raw balances stayed unchanged.", }; }, ], @@ -646,7 +650,7 @@ export const AssetDemo = ({ flow }) => { const out = await LIVE_RUNNERS[active][stepIndex](engine, ctx, state); if (ctx.token) { try { - setAccountTokenBalance(engine.displayUnits(await engine.scaledBalanceOf(ctx.token, ctx.account))); + setAccountTokenBalance(engine.displayUnits(await engine.balanceOfUI(ctx.token, ctx.account))); } catch { setAccountTokenBalance(null); } diff --git a/docs/snippets/B20PlaygroundDemo.jsx b/docs/snippets/B20PlaygroundDemo.jsx index fea1272da..6d48828be 100644 --- a/docs/snippets/B20PlaygroundDemo.jsx +++ b/docs/snippets/B20PlaygroundDemo.jsx @@ -149,10 +149,10 @@ export const B20FlowDemo = ({ flow }) => { s.nonces[owner] = current + 1; return entries.push(emit("Approval", `${owner} → ${spender} · ${amt} (permit, relayed by ${me})`, 0)); } - case "updateMultiplier": { + case "updateUIMultiplier": { if (!roleHas("OPERATOR_ROLE", me)) return entries.push(revert("AccessControlUnauthorizedAccount", `${me} lacks OPERATOR_ROLE`)); s.token = { ...s.token, multiplier: op.value }; - return entries.push(emit("MultiplierUpdated", `${op.value}× (WAD)`, 0)); + return entries.push(emit("UIMultiplierUpdated", `${op.value}× (WAD) · takes effect at effectiveAt`, 0)); } case "announceBatchMint": { const { recipients, amt, id } = op; @@ -358,10 +358,10 @@ export const B20FlowDemo = ({ flow }) => { stage: "Split", action: "Run the split", text: "The board declares a 2-for-1 split.", - summary: [["Operation", "updateMultiplier"], ["Role", "OPERATOR_ROLE"], ["Multiplier", M("2.0×")], ["Network", NETWORK]], + summary: [["Operation", "updateUIMultiplier"], ["Role", "OPERATOR_ROLE"], ["Multiplier", M("2.0×")], ["Effective", "scheduled effectiveAt"], ["Network", NETWORK]], run: (s) => ({ - entries: runOps(s, [{ as: "Issuer", type: "updateMultiplier", value: 2.0 }]), - caption: "Every balance doubles in one call, without a migration or a new contract.", + entries: runOps(s, [{ as: "Issuer", type: "updateUIMultiplier", value: 2.0 }]), + caption: "One call schedules the split. At effectiveAt every displayed balance doubles, without a migration or a new contract.", }), }, { @@ -465,7 +465,7 @@ export const B20FlowDemo = ({ flow }) => {
{heading}
{scaled && sim.token.multiplier !== 1 && ( - multiplier() = {sim.token.multiplier}× + uiMultiplier() = {sim.token.multiplier}× )}
diff --git a/docs/snippets/StablecoinDemo.jsx b/docs/snippets/StablecoinDemo.jsx index 71ed8a659..ab682de9f 100644 --- a/docs/snippets/StablecoinDemo.jsx +++ b/docs/snippets/StablecoinDemo.jsx @@ -26,7 +26,7 @@ export const StablecoinDemo = ({ flow }) => { const moduleUrl = (source) => URL.createObjectURL(new Blob([source], { type: "text/javascript" })); const [aaSource, engineSource] = await Promise.all([ fetchText("/static/aa.txt"), - fetchText("/static/vibenet-engine.txt?v=4"), + fetchText("/static/vibenet-engine.txt?v=5"), ]); const aaUrl = moduleUrl(aaSource); const rewritten = engineSource.replace('"./aa.txt"', JSON.stringify(aaUrl)); diff --git a/docs/specifications/b20/reference/constants.mdx b/docs/specifications/b20/reference/constants.mdx index 16148c6a0..c8cfa4df0 100644 --- a/docs/specifications/b20/reference/constants.mdx +++ b/docs/specifications/b20/reference/constants.mdx @@ -61,5 +61,5 @@ description: "B20 precompile addresses, role identifiers, policy scopes, and val | Name | Value | Purpose | |---|---|---| -| `WAD_PRECISION` | `1e18` | Fixed-point precision used to scale `multiplier`; `multiplier`, `toUIAmount`, and `fromUIAmount` all divide/multiply by this. | -| `MAX_UI_MULTIPLIER` | `type(uint128).max` | Maximum multiplier the setters accept — the overflow guard enforced by `updateMultiplier` and `updateUIMultiplier`. Exposed so callers can read the bound without triggering `InvalidMultiplier`. | +| `WAD_PRECISION` | `1e18` | Fixed-point precision used to scale `uiMultiplier`; `uiMultiplier`, `toUIAmount`, and `fromUIAmount` all divide/multiply by this. | +| `MAX_UI_MULTIPLIER` | `type(uint128).max` | Maximum multiplier the setters accept. It is the overflow guard enforced by `updateUIMultiplier` and the deprecated `updateMultiplier`. Exposed so callers can read the bound without triggering `InvalidMultiplier`. | diff --git a/docs/static/vibenet-engine.txt b/docs/static/vibenet-engine.txt index 0f7b946bd..dab04167f 100644 --- a/docs/static/vibenet-engine.txt +++ b/docs/static/vibenet-engine.txt @@ -25,7 +25,7 @@ import { } from "./aa.txt"; export const CHAIN_ID = 84538453; -export const ENGINE_VERSION = 4; +export const ENGINE_VERSION = 5; export const NETWORK_NAME = "Base Vibenet"; export const API_URL = "https://api.vibes.base.org"; export const ACCOUNT_RPC = `${API_URL}/api/vibenet/account/rpc`; @@ -174,9 +174,11 @@ const assetAbi = [ { type: "function", name: "isAnnouncementIdUsed", stateMutability: "view", inputs: [{ type: "string" }], outputs: [{ type: "bool" }] }, { type: "function", name: "updateExtraMetadata", stateMutability: "nonpayable", inputs: [{ type: "string" }, { type: "string" }], outputs: [] }, { type: "function", name: "extraMetadata", stateMutability: "view", inputs: [{ type: "string" }], outputs: [{ type: "string" }] }, - { type: "function", name: "updateMultiplier", stateMutability: "nonpayable", inputs: [{ type: "uint256" }], outputs: [] }, - { type: "function", name: "multiplier", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, - { type: "function", name: "scaledBalanceOf", stateMutability: "view", inputs: [{ type: "address" }], outputs: [{ type: "uint256" }] }, + { type: "function", name: "updateUIMultiplier", stateMutability: "nonpayable", inputs: [{ type: "uint256" }, { type: "uint256" }], outputs: [] }, + { type: "function", name: "uiMultiplier", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, + { type: "function", name: "newUIMultiplier", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, + { type: "function", name: "effectiveAt", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] }, + { type: "function", name: "balanceOfUI", stateMutability: "view", inputs: [{ type: "address" }], outputs: [{ type: "uint256" }] }, ]; const client = createPublicClient({ @@ -668,17 +670,36 @@ export async function isAnnouncementIdUsed(token, id) { return readFunction(token, assetAbi, "isAnnouncementIdUsed", [id]); } -export async function updateMultiplier({ token, multiplier }) { - const data = encodeFunctionData({ abi: assetAbi, functionName: "updateMultiplier", args: [BigInt(multiplier)] }); - return sendCalls({ calls: [{ to: token, data }], metadata: "Update asset multiplier" }); +export async function latestTimestamp() { + const block = await rpc("eth_getBlockByNumber", ["latest", false]); + return BigInt(block.timestamp); +} + +// Resolves once the chain head reaches `timestamp`, so a scheduled +// multiplier has matured before the demo reads it back. +export async function waitForTimestamp(timestamp, { timeoutMs = 60_000 } = {}) { + const deadline = Date.now() + timeoutMs; + while ((await latestTimestamp()) < BigInt(timestamp)) { + if (Date.now() > deadline) throw new Error("Timed out waiting for the scheduled multiplier to take effect"); + await new Promise((resolve) => setTimeout(resolve, 250)); + } +} + +export async function updateUIMultiplier({ token, multiplier, effectiveAt }) { + const data = encodeFunctionData({ + abi: assetAbi, + functionName: "updateUIMultiplier", + args: [BigInt(multiplier), BigInt(effectiveAt)], + }); + return sendCalls({ calls: [{ to: token, data }], metadata: "Schedule asset multiplier update" }); } export async function assetMultiplier(token) { - return readFunction(token, assetAbi, "multiplier"); + return readFunction(token, assetAbi, "uiMultiplier"); } -export async function scaledBalanceOf(token, address) { - return readFunction(token, assetAbi, "scaledBalanceOf", [address]); +export async function balanceOfUI(token, address) { + return readFunction(token, assetAbi, "balanceOfUI", [address]); } export async function assetMetadata(token, key) { @@ -691,7 +712,7 @@ export async function assetDetails(token) { readFunction(token, b20Abi, "decimals"), readFunction(token, b20Abi, "totalSupply"), readFunction(token, b20Abi, "supplyCap"), - readFunction(token, assetAbi, "multiplier"), + readFunction(token, assetAbi, "uiMultiplier"), ]); return { name, decimals: Number(decimals), totalSupply, supplyCap, multiplier }; } @@ -864,9 +885,11 @@ const api = { batchMint, announceDistribution, isAnnouncementIdUsed, - updateMultiplier, + latestTimestamp, + waitForTimestamp, + updateUIMultiplier, assetMultiplier, - scaledBalanceOf, + balanceOfUI, assetMetadata, assetDetails, createPolicy, diff --git a/docs/upgrades/beryl/b20.mdx b/docs/upgrades/beryl/b20.mdx index 092b46ead..7f03c7e76 100644 --- a/docs/upgrades/beryl/b20.mdx +++ b/docs/upgrades/beryl/b20.mdx @@ -232,6 +232,10 @@ A WAD-precision rebase multiplier applied to all balance reads. Raw balances are | `toRawBalance(scaled)` | Convert scaled amount to raw | | `updateMultiplier(newMultiplier)` | Update the multiplier. Gated by `OPERATOR_ROLE`. | + +These are the Beryl names. Cobalt adds the ERC-8056 interface: `uiMultiplier` and `balanceOfUI` alias `multiplier` and `scaledBalanceOf`, which stay canonical; `toUIAmount` and `fromUIAmount` replace the deprecated `toScaledBalance` and `toRawBalance`; and the scheduled `updateUIMultiplier` setter replaces `updateMultiplier`, which stays callable as a deprecated emergency setter. The scheduled setter emits only `UIMultiplierUpdated`; the legacy `MultiplierUpdated` event is emitted only by `updateMultiplier`. See the [Cobalt multiplier changelog](/base-chain/specs/reference/b20/changelog/02-cobalt-b20asset-multiplier#functions). + + #### Announcements On-chain disclosure brackets that wrap sensitive operations (e.g. batch mints, multiplier updates) with a public notice period. Gated by `OPERATOR_ROLE`. diff --git a/examples/verified-doc-samples/typescript/src/b20/abi.ts b/examples/verified-doc-samples/typescript/src/b20/abi.ts index bd657a750..4f1a1280e 100644 --- a/examples/verified-doc-samples/typescript/src/b20/abi.ts +++ b/examples/verified-doc-samples/typescript/src/b20/abi.ts @@ -31,14 +31,11 @@ const assetExtraAbi = parseAbi([ "function batchMint(address[],uint256[])", "function announce(bytes[],string,string,string)", "function isAnnouncementIdUsed(string) view returns (bool)", - "function updateMultiplier(uint256)", "function updateUIMultiplier(uint256,uint256)", "function cancelUIMultiplierUpdate()", - "function multiplier() view returns (uint256)", "function uiMultiplier() view returns (uint256)", "function newUIMultiplier() view returns (uint256)", "function effectiveAt() view returns (uint256)", - "function scaledBalanceOf(address) view returns (uint256)", "function balanceOfUI(address) view returns (uint256)", ]); diff --git a/scripts/test-vibenet-engine.mjs b/scripts/test-vibenet-engine.mjs index 3d633f5a2..2668d0ffa 100644 --- a/scripts/test-vibenet-engine.mjs +++ b/scripts/test-vibenet-engine.mjs @@ -25,11 +25,11 @@ assert.match(engineSource, /from "\.\/aa\.txt"/, "engine keeps the loader-rewrit assert.match(snippet, /replace\('\"\.\/aa\.txt\"'/, "snippet rewrites the AA specifier before Blob evaluation"); assert.doesNotMatch(snippet, /^\s*import\s/m, "Mintlify snippet must not contain imports"); assert.match(snippet, /fetchText\("\/static\/aa\.txt"\)/, "AA artifact is fetched lazily by the snippet loader"); -assert.match(snippet, /fetchText\("\/static\/vibenet-engine\.txt\?v=4"\)/, "versioned shared engine is fetched by the snippet loader"); +assert.match(snippet, /fetchText\("\/static\/vibenet-engine\.txt\?v=5"\)/, "versioned shared engine is fetched by the snippet loader"); assert.match(assetSnippet, /replace\('\"\.\/aa\.txt\"'/, "Asset snippet rewrites the AA specifier before Blob evaluation"); assert.doesNotMatch(assetSnippet, /^\s*import\s/m, "Asset Mintlify snippet must not contain imports"); assert.match(assetSnippet, /fetchText\("\/static\/aa\.txt"\)/, "Asset snippet lazy-loads the AA artifact"); -assert.match(assetSnippet, /fetchText\("\/static\/vibenet-engine\.txt\?v=4"\)/, "Asset snippet uses the versioned shared engine"); +assert.match(assetSnippet, /fetchText\("\/static\/vibenet-engine\.txt\?v=5"\)/, "Asset snippet uses the versioned shared engine"); const tempAa = "/tmp/base-docs-aa-test.mjs"; const tempEngine = "/tmp/base-docs-vibenet-engine-test.mjs"; @@ -41,7 +41,7 @@ await writeFile( const engine = await import(`${pathToFileURL(tempEngine).href}?v=${Date.now()}`); assert.equal(engine.CHAIN_ID, 84538453); -assert.equal(engine.ENGINE_VERSION, 4); +assert.equal(engine.ENGINE_VERSION, 5); assert.equal(engine.ASSET_FEATURE, "0xcdcc772fe4cbdb1029f822861176d09e646db96723d4c1e82ddfdeb8163ef54c"); assert.equal(engine.units(1), 1_000_000n); assert.equal(engine.displayUnits(25_500_000n), 25.5); @@ -55,7 +55,10 @@ assert.equal(typeof engine.seize, "function"); assert.equal(typeof engine.createAsset, "function"); assert.equal(typeof engine.configureAssetControls, "function"); assert.equal(typeof engine.announceDistribution, "function"); -assert.equal(typeof engine.updateMultiplier, "function"); +assert.equal(typeof engine.updateUIMultiplier, "function"); +assert.equal(typeof engine.balanceOfUI, "function"); +assert.equal(typeof engine.waitForTimestamp, "function"); +assert.equal(engine.updateMultiplier, undefined, "demos use the Cobalt scheduled setter, not the deprecated instant one"); assert.equal(typeof engine.assetDetails, "function"); const memoTopic = engine.memoToBytes32("invoice-8842"); const decodedMemo = engine.readMemoFromReceipt({