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({