Found and verified with Claude Opus 5. Every line number cited below was re-checked against the published tip; the proofs are runnable from the repo root and modify nothing.
The frontend has exactly one declarative convention for e8 scaling: MONEY_KEYS in src/frontend/src/money.js lists every field name that arrives from Candid as a base-unit bigint, and normMoney — applied once at the actor boundary by wrapActor — converts those and only those to human Numbers. The convention is good, and the file's own comment (money.js:67-69) states the failure mode precisely: "mixing one normalised field with hand-divided siblings is exactly how a figure lands 1e8 out." Every defect in this issue is a place that convention was missed or double-applied. pendingYieldUsd was never added to the set, so four render sites print it 100,000,000x too large; uncoveredBadDebtUsd is in the set but one card divides by 1e8 anyway, so the venue's most material risk number renders 100,000,000x too small on the alert card that names it as such. The assistant's system prompt asserts the raw-e8 invariant as universal, but one OQL field is pre-converted in the backend, so a compliant model is wrong on it. The explorer's one numeric-filter example encodes the wrong unit. Rounding out the set are two unrelated-in-cause but same-in-kind display-integrity defects in the polling and chart-refresh paths.
Severity, stated plainly: none of this loses funds. These are display and integrity defects on read paths — no settlement code consumes any of these values. Findings 8 and 34 are the ones worth fixing first, not because they are dangerous but because they misreport risk numbers to the exact audience the pages exist to inform: a staker reading pending yield, and an operator reading uncovered bad debt. Findings 35 and 36 corrupt cached data the user sees. Findings 57 and 59 are minor and included for completeness.
Full write-ups for each are in REVIEW-FINDINGS.md under the matching ## N. headings.
1. Finding 8 (lead) - pendingYieldUsd is missing from MONEY_KEYS, so it renders 1e8x too large
Where: src/frontend/src/money.js:40-81 (the MONEY_KEYS set); render sites at src/frontend/src/main.js:4314, :5355, :5544, :6487.
What is wrong. getInsuranceFund returns a five-field record, every field an IDL.Nat in e8 base units (main.js:873-879):
// src/frontend/src/main.js:873-879
getInsuranceFund: IDL.Func([], [IDL.Record({
bufferUsd: IDL.Nat, uncoveredBadDebtUsd: IDL.Nat,
totalShares: IDL.Nat, shareValueUsd: IDL.Nat,
// Penalties earned but not yet paid across from the vault. Excluded
// from bufferUsd/shareValueUsd by design (those stay cash-backed).
pendingYieldUsd: IDL.Nat,
})], ["query"]),
Four of those five field names appear in MONEY_KEYS — bufferUsd and uncoveredBadDebtUsd at money.js:45 and :61, shareValueUsd and totalShares at :59 and :61. "pendingYieldUsd" does not appear anywhere in the set. The real actor is wrapped (main.js:1149 for the public actor, :1179 for the authenticated one), so normMoney scales the four listed siblings and leaves pendingYieldUsd as a raw e8 bigint inside the same record. All four consumers then read it as if it were already dollars:
// main.js:4314 - Earn card tile
const insPending = Number(fund.pendingYieldUsd || 0);
const pendTile = document.getElementById("earn-ins-pending-tile");
if (pendTile) pendTile.style.display = insPending > 0.0000001 ? "" : "none";
setText("earn-ins-pending", "$" + formatNum(insPending));
// main.js:5355 - Stats Issues card
const pending = Number(ins.pendingYieldUsd ?? 0);
if (pending > 0) {
card(1, `Insurance yield pending: $${formatNum(pending)}`, ...
// main.js:5544 - Stats insurance KPI subtitle
const pending = Number(fund.pendingYieldUsd || 0);
...
? `+$${formatNum(pending)} earned, awaiting vault cash`
// main.js:6487 - Stats vault P&L footer
const owedToInsurance = Number(insFund?.pendingYieldUsd ?? 0);
The in-file contrast is the clearest evidence. Twenty lines above the :5544 site, in the same function, the correctly-listed siblings are read with no divide:
// main.js:5525-5526 - same function, same response object, listed fields
const buffer = Number(fund.bufferUsd);
const uncovered = Number(fund.uncoveredBadDebtUsd);
The backend source is insuranceOwedUsd, a Nat in base units like its neighbours (src/backend/main.mo:10218 declares the field, :10226 assigns it inside getInsuranceFund).
Why it matters. A $0.50 pending yield renders as $50,000,000. All four surfaces gate on the value being positive — insPending > 0.0000001 at :4316, pending > 0 at :5356 and :5546 — so the inflated value always satisfies the gate: a single base unit of dust ($0.00000001) passes as 1 and shows as "$1", and tiles that should stay hidden are permanently visible. On the Stats Issues pane this produces a standing severity-1 warning card reading "Insurance yield pending: $50,000,000" for a fifty-cent liability, on a page whose entire purpose is transparency. The defect is invisible in demo mode, because the mock actor emits human Numbers that normMoney passes through unchanged; it fires only against the real canister.
Proof. proofs/h8-pending-yield-unscaled.mjs imports the real MONEY_KEYS and normMoney from src/frontend/src/money.js and feeds them a record shaped exactly as Candid decodes getInsuranceFund. It is product code under test, not a reimplementation. It reads main.js from disk for the render-site check and modifies nothing. Run from the repo root:
node proofs/h8-pending-yield-unscaled.mjs
Source:
// H8: MONEY_KEYS omits pendingYieldUsd, so getInsuranceFund's e8 field reaches
// four render sites unscaled (1e8x too large). Demonstration only - no fix.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { MONEY_KEYS, normMoney, E8 } from "../src/frontend/src/money.js";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const fail = [];
// 1. Key-set membership: siblings of the same record are all present.
const siblings = ["bufferUsd", "shareValueUsd", "uncoveredBadDebtUsd", "totalShares"];
const missingSiblings = siblings.filter((k) => !MONEY_KEYS.has(k));
const pendingListed = MONEY_KEYS.has("pendingYieldUsd");
console.log("MONEY_KEYS.has('pendingYieldUsd') =", pendingListed, "(correct: true)");
console.log("siblings present in MONEY_KEYS =", siblings.length - missingSiblings.length, "/", siblings.length);
if (!pendingListed && missingSiblings.length === 0) fail.push("keyset");
// 2. Real normMoney over a mock getInsuranceFund record, all raw e8 bigints.
const rec = normMoney({
bufferUsd: 250_000n * BigInt(E8),
uncoveredBadDebtUsd: 0n,
totalShares: 250_000n * BigInt(E8),
shareValueUsd: 1n * BigInt(E8),
pendingYieldUsd: 1_234n * BigInt(E8), // $1,234 of yield in flight
});
console.log("normMoney -> bufferUsd =", rec.bufferUsd, "(correct: 250000)");
console.log("normMoney -> shareValueUsd =", rec.shareValueUsd, "(correct: 1)");
console.log("normMoney -> pendingYieldUsd =", rec.pendingYieldUsd, "(correct: 1234)");
const rendered = Number(rec.pendingYieldUsd || 0); // exactly what main.js does
console.log("rendered as = $" + rendered.toLocaleString("en-US"),
"(correct: $" + (1234).toLocaleString("en-US") + ")");
if (typeof rec.pendingYieldUsd === "bigint" && rec.bufferUsd === 250_000) fail.push("norm");
// 3. The four render sites read the field with no /1e8 compensation.
const src = readFileSync(join(root, "src/frontend/src/main.js"), "utf8").split("\n");
const sites = [];
src.forEach((line, i) => {
if (line.includes("pendingYieldUsd") && !line.includes("IDL.Nat")) {
sites.push({ ln: i + 1, text: line.trim(), divides: /1e8|100_000_000|100000000|fromE8/.test(line) });
}
});
for (const s of sites) console.log(` main.js:${s.ln} dividesBy1e8=${s.divides} ${s.text}`);
const undivided = sites.filter((s) => !s.divides).length;
console.log("render sites with no /1e8 =", undivided, "of", sites.length, "(correct: 0 undivided)");
if (sites.length === 4 && undivided === 4) fail.push("sites");
if (fail.length === 3) {
console.log("BUG REPRODUCED: pendingYieldUsd is absent from MONEY_KEYS, survives normMoney as a raw "
+ "e8 bigint, and all 4 main.js render sites print it directly - $123,400,000,000 instead of $1,234 (1e8x).");
process.exit(0);
}
console.log("NO BUG: expected all three checks to fail; failing checks =", JSON.stringify(fail));
process.exit(1);
Output, verbatim (exit code 0):
(node:18192) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///.../src/frontend/src/money.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /.../package.json.
(Use `node --trace-warnings ...` to show where the warning was created)
MONEY_KEYS.has('pendingYieldUsd') = false (correct: true)
siblings present in MONEY_KEYS = 4 / 4
normMoney -> bufferUsd = 250000 (correct: 250000)
normMoney -> shareValueUsd = 1 (correct: 1)
normMoney -> pendingYieldUsd = 123400000000n (correct: 1234)
rendered as = $123,400,000,000 (correct: $1,234)
main.js:4314 dividesBy1e8=false const insPending = Number(fund.pendingYieldUsd || 0);
main.js:5355 dividesBy1e8=false const pending = Number(ins.pendingYieldUsd ?? 0);
main.js:5544 dividesBy1e8=false const pending = Number(fund.pendingYieldUsd || 0);
main.js:6487 dividesBy1e8=false const owedToInsurance = Number(insFund?.pendingYieldUsd ?? 0);
render sites with no /1e8 = 4 of 4 (correct: 0 undivided)
BUG REPRODUCED: pendingYieldUsd is absent from MONEY_KEYS, survives normMoney as a raw e8 bigint, and all 4 main.js render sites print it directly - $123,400,000,000 instead of $1,234 (1e8x).
(Only the absolute paths in Node's ESM warning are elided; nothing else is altered.)
2. Finding 34 - uncoveredBadDebtUsd is divided by 1e8 a second time
Where: src/frontend/src/main.js:5380, in renderStatsIssues.
What is wrong. uncoveredBadDebtUsd is in MONEY_KEYS (money.js:61), so by the time this code runs, normMoney has already converted it to human dollars. The Issues card divides again:
// main.js:5379-5382 - WRONG: the field is already human units
if (ins && Number(ins.uncoveredBadDebtUsd ?? 0) > 0) {
card(2, `Insurance shortfall: ${fmtUsd(Number(ins.uncoveredBadDebtUsd) / 1e8)} of bad debt uncovered`,
"Liquidations left debt the insurance fund could not absorb. Until the buffer recovers, " +
"this is the venue's most material risk number.");
}
The proof is the sibling site in the same file, reading the same getInsuranceFund() response through the same wrapped actor, correctly:
// main.js:5526 - CORRECT: no divide, in renderStatsInsurance
const uncovered = Number(fund.uncoveredBadDebtUsd);
...
set("kpi-ins-uncovered", "$" + formatNum(uncovered), uncovered > 0.0000001 ? "health-bad" : "");
Two panes of the same page disagree about the same number by a factor of 1e8.
Why it matters. A liquidation that leaves $12,345 of uncovered bad debt shows correctly as "$12,345" and flagged unhealthy on the Insurance KPI, while the Issues pane — a severity-2 alert whose own copy calls this "the venue's most material risk number" — renders "Insurance shortfall: $0.00 of bad debt uncovered". The alert still fires, because the > 0 guard at :5379 tests the undivided value; only the displayed magnitude is wrong, which arguably makes it worse: the operator sees a red card asserting a shortfall exists and a figure asserting it is rounding dust.
There is a pleasing symmetry with finding 8. One convention, one field it forgot and one field it was applied to twice, producing errors of the same size in opposite directions.
How to confirm. grep -n uncoveredBadDebtUsd src/frontend/src/main.js returns three hits — :874 (the IDL declaration), :5379-5380 (divides), :5526 (does not). Confirm membership with grep -n uncoveredBadDebtUsd src/frontend/src/money.js (:61). The two render sites cannot both be right.
3. Finding 37 - the assistant's units instruction contradicts a pre-converted field
Where: src/frontend/src/assistant.js:123 versus src/backend/main.mo:14821.
What is wrong. The system prompt states the e8 invariant as universal, with exceptions carved out only for the account snapshot and candles:
// assistant.js:123
- Every money/price/quantity/*Usd value in query results is a FIXED-POINT INTEGER scaled by 10^8
(e8). ALWAYS divide by 100,000,000 before showing a human number: 3604200000000 ICPUSD =
$36,042.00; 100000000 BTC = 1 BTC. ...
The rule is correct for nearly everything. It is not correct for position.size, which the backend converts before projecting:
// backend/main.mo:14820-14823 - size is pre-converted; its siblings are raw e8
.payload("side", func p = if (p.size >= 0) { "long" } else { "short" })
.payload("size", func p = Fixed.toFloat(Int.abs(p.size)))
.payload("entryPrice", func p = p.entryPrice)
.payload("realizedPnl", func p = p.realizedPnl)
Fixed.toFloat is the division:
// backend/lib/Fixed.mo:51
public func toFloat(u : Nat) : Float { Float.fromInt(u) / Float.fromInt(SCALE) };
Contrast the order entity, which projects both money fields raw as the prompt assumes:
// backend/main.mo:14872-14873
.payload("price", func o = o.price)
.payload("quantity", func o = o.quantity)
Why it matters. position is the flagship self-scoped entity — the prompt itself tells the model that "the user's positions = {"start":"position"}". A user asks "show my positions"; the model receives size: 0.5 alongside entryPrice: 6500000000000, follows the ALWAYS-divide instruction, and reports 0.000000005 BTC — eight orders of magnitude too small. Any derived figure (notional, exposure, a PnL cross-check) mixes scales and is wrong. The compact schema handed to the model carries field names and types with no unit annotation, so nothing upstream corrects it.
Either side is a valid fix: project size raw like its siblings, or add an explicit exception to the prompt's UNITS section the way the account and candles tools already spell theirs out.
How to confirm. Read assistant.js:123 and main.mo:14821 side by side. Fixed.toFloat's definition at Fixed.mo:51 settles that the projection is human units.
4. Finding 57 - the explorer's "buy orders above 1000" preset filters at $0.00001
Where: src/frontend/src/explorer.js:46.
What is wrong. The preset ships a bare literal:
// explorer.js:46 (preset list; group label elided)
label: "Buy orders above 1000, priciest first",
q: { start: "order",
where: { and: [{ eq: { field: "side", value: "buy" } },
{ gt: { field: "price", value: 1000 } }] },
orderBy: [{ field: "price", dir: "desc" }], limit: 25 }
The order entity exposes price raw in e8 base units (main.mo:14872, quoted above) and the OQL predicate layer compares numerically with no scaling. $1,000 is 100,000,000,000 in e8, so the literal 1000 is a threshold of $0.00001.
Why it matters. No money is misreported — this is a demo query, and the explorer's result table also renders price unscaled, so the UI is internally consistent in raw units. The cost is pedagogical: this is the one copy-and-tweak example of a numeric filter in the explorer, so it teaches the wrong unit convention to anyone writing filters by hand, and it is the kind of example an assistant may pattern-match on. As written the clause matches essentially every buy order in every market, so the preset is a silent no-op that returns the whole bid side.
How to confirm. Run the preset against any market with resting bids and compare the row count to an unfiltered {"start":"order"} scoped to buys.
5. Finding 59 - fromE8 / toE8 round-trip drifts by one base unit at large magnitudes
Where: src/frontend/src/money.js:22-30.
What is wrong. The pair is a lossy round trip at large magnitudes:
// money.js:22-30
export function fromE8(x) {
return typeof x === "bigint" ? Number(x) / E8 : Number(x);
}
export function toE8(v) {
return BigInt(Math.round(Number(v) * E8));
}
Both the division and the multiplication by 1e8 (not a power of two) introduce representation error; above a threshold their sum reaches half a base unit and Math.round lands on x ± 1.
Why it matters. Lowest practical impact in this set, included for completeness. Drift is impossible below 2^25 human units — 3,355,443,200,000,000 base units — and the first drifting value is just above it, at 3,355,443,200,000,019 base units (approximately 33.55M tokens); above 2^53 base units (approximately 90M tokens) Number(x) is itself lossy and every odd value drifts. The one path where it could surface is the max-balance flow: the displayed balance can round-trip one base unit high, and a whole-balance withdrawal would then be rejected for insufficient funds with no visible reason. Practically unreachable at current venue sizes.
How to confirm. In Node: const rt = x => BigInt(Math.round((Number(x)/1e8)*1e8)); rt(3355443200000019n) returns 3355443200000020n.
6. Finding 35 - pollChanges has no in-flight guard
Where: src/frontend/src/main.js:2536-2604, scheduled at :2758.
What is wrong. setInterval(pollChanges, 2000) (:2758) fires the async function regardless of whether the previous invocation has returned, and pollMarketStatus() calls it directly after order placement and cancellation (:8581, :9434, :9458) while an interval instance may be in flight. Each call reads its cursor from the shared cache before awaiting:
// main.js:2544-2550
const request = {
marketId: marketAtCall ? [marketAtCall] : [],
lastMarketVersion: marketStatus ? marketStatus.version : 0n,
lastTradeId: marketStatus ? marketStatus.lastTradeId : 0n,
lastUserVersion: cachedUserStatus ? cachedUserStatus.version : 0n,
lastUserTradeTime: cachedUserStatus ? cachedUserStatus.lastTradeTime : 0n,
};
so two overlapping calls send the same lastTradeId and both receive the same newTrades. The only guard is on market identity (:2556), not on ordering or in-flight state. The full-snapshot branch replaces book state unconditionally (:2560-2574, orderBookState[marketAtCall] = { asks, bids } at :2567), so a slow response landing after a newer one regresses the book. The trade merge has no id dedup:
// main.js:2600-2604
if (resp.newTrades.length > 0) {
// Merge new trades into per-market cache
const existing = cachedMarketTrades[marketAtCall] || [];
const merged = [...existing, ...resp.newTrades];
cachedMarketTrades[marketAtCall] = merged.length > 100 ? merged.slice(merged.length - 100) : merged;
The user-trade merge a few dozen lines later does dedup, by id:
// main.js:2659-2664
for (const t of fresh) {
const bucket = uomTradeHistoryByMarket[t.marketId] || [];
if (!bucket.some((x) => String(x.id) === String(t.id))) {
bucket.unshift(t);
if (bucket.length > 200) bucket.length = 200;
}
The in-flight pattern also already exists in this file — _rejCheckInFlight (:2200, tested at :2211) guards checkReleaseRejections, which pollChanges fires un-awaited. It simply was not applied to pollChanges itself.
Why it matters. On a loaded subnet a getMarketChanges query can exceed the 2s interval. Poll A at t=0 and poll B at t=2s both send lastTradeId = 100; the backend has trades 101-105; both responses append [101..105]. The Trades panel then renders each trade twice, the zero-tick direction colouring is corrupted by the repeated equal prices, and the duplicates persist in the 100-entry cache until they age out. Book state self-heals on the next poll — the stale snapshot is simply overwritten — but duplicated trades do not; they are appended to a cache with no key. Order placement and cancellation deliberately widen the overlap window by calling pollMarketStatus() at exactly the moments the user is most likely to be watching the tape.
How to confirm. Throttle the network to make getMarketChanges take over 2s while trades are printing, and watch cachedMarketTrades for repeated ids. Both fixes are mechanical: an in-flight boolean matching _rejCheckInFlight, and an id dedup in the market-trade merge matching the user-trade merge.
7. Finding 36 - the 5s chart refresh resets to page 0 without resetting chartCurrentPage
Where: src/frontend/src/main.js:3174-3186, scheduled at :2987.
What is wrong. refreshChartData replaces the series with page 0 and leaves the page cursor alone:
// main.js:3179-3182
const resp = await src.getCandles(selectedMarket, chartInterval, 0);
chartCandleData = backendCandlesToChart(resp.candles, chartInterval);
if (chartCandleSeries) chartCandleSeries.setData(chartCandleData.candles);
if (chartVolumeSeries) chartVolumeSeries.setData(chartCandleData.volumes);
changeChartInterval, which performs the same page-0 refetch, does reset it:
// main.js:3150-3153
// Re-fetch candles from backend with new interval
chartCurrentPage = 0;
chartHasMore = false;
chartLoadingMore = false;
loadOlderCandles increments chartCurrentPage (:3126) and prepends onto whatever is currently loaded (:3132-3133), and the scroll subscription calls it whenever the visible range starts before index 5 (:3103-3104).
Why it matters. A user scrolls left through three pages, so chartCurrentPage = 3. Within five seconds the refresh discards pages 1-3 and leaves the cursor at 3. The candles under the viewport vanish and the view jumps. The next leftward scroll fetches page 4 and prepends it directly onto page-0 data, so the chart shows page-4 candles adjacent to page-0 candles with pages 1-3 silently missing — a non-contiguous price history presented as continuous, and one that never repairs itself, since pages 1-3 are never refetched.
How to confirm. Open a chart, scroll left two or more pages, wait five seconds, then scroll left again and inspect the timestamps either side of the join.
Cross-references
This cluster is uncontested — no other issue on file overlaps it. Issue #4 is the only other frontend issue, and its scope is strictly client-side integrity: the unpkg script loaded with no SRI, the absent CSP, the ledger.js certificate check that never runs, and the root-key fetch. It does not examine application logic or unit handling anywhere, so a maintainer triaging both should not expect shared fixes.
Finding 37 is adjacent to issue #11 (the AI proxy), but the subjects are disjoint: #11 examines the backend preamble's channel placement and injection surface, whereas this finding concerns the frontend system prompt's units contract and the one backend projection that contradicts it.
Found and verified with Claude Opus 5. Every line number in this issue was re-checked against the current source at the time of writing; citations that had drifted from the original write-ups were corrected here. The proof for finding 8 runs from the repo root with no arguments, imports the product code rather than reimplementing it, reads two files, writes nothing, and requires no running canister.
Getting the proof scripts. The proofs/ paths referenced above are not in this repository — they ship separately, so nothing is added to your tree. All of them are here:
https://gist.github.com/andreij6/ed9f244e47a71a786405bc7959550d4b
To run them, clone the gist into a proofs/ directory at the root of a public-multidex checkout:
git clone https://gist.github.com/ed9f244e47a71a786405bc7959550d4b.git proofs
bash proofs/run_all_proofs.sh
Verified end to end from a clean checkout. The scripts are read-only: they compile and read the product source, modify nothing, and contain no fixes. Each prints the observed value alongside the correct one, exits 0 with BUG REPRODUCED: while the defect is present, and flips to exit 1 once it is fixed. The gist README maps every proof to its issue and also includes the proofs for candidates that were investigated and not filed.
The frontend has exactly one declarative convention for e8 scaling:
MONEY_KEYSinsrc/frontend/src/money.jslists every field name that arrives from Candid as a base-unitbigint, andnormMoney— applied once at the actor boundary bywrapActor— converts those and only those to humanNumbers. The convention is good, and the file's own comment (money.js:67-69) states the failure mode precisely: "mixing one normalised field with hand-divided siblings is exactly how a figure lands 1e8 out." Every defect in this issue is a place that convention was missed or double-applied.pendingYieldUsdwas never added to the set, so four render sites print it 100,000,000x too large;uncoveredBadDebtUsdis in the set but one card divides by 1e8 anyway, so the venue's most material risk number renders 100,000,000x too small on the alert card that names it as such. The assistant's system prompt asserts the raw-e8 invariant as universal, but one OQL field is pre-converted in the backend, so a compliant model is wrong on it. The explorer's one numeric-filter example encodes the wrong unit. Rounding out the set are two unrelated-in-cause but same-in-kind display-integrity defects in the polling and chart-refresh paths.Severity, stated plainly: none of this loses funds. These are display and integrity defects on read paths — no settlement code consumes any of these values. Findings 8 and 34 are the ones worth fixing first, not because they are dangerous but because they misreport risk numbers to the exact audience the pages exist to inform: a staker reading pending yield, and an operator reading uncovered bad debt. Findings 35 and 36 corrupt cached data the user sees. Findings 57 and 59 are minor and included for completeness.
Full write-ups for each are in
REVIEW-FINDINGS.mdunder the matching## N.headings.1. Finding 8 (lead) -
pendingYieldUsdis missing fromMONEY_KEYS, so it renders 1e8x too largeWhere:
src/frontend/src/money.js:40-81(theMONEY_KEYSset); render sites atsrc/frontend/src/main.js:4314,:5355,:5544,:6487.What is wrong.
getInsuranceFundreturns a five-field record, every field anIDL.Natin e8 base units (main.js:873-879):Four of those five field names appear in
MONEY_KEYS—bufferUsdanduncoveredBadDebtUsdatmoney.js:45and:61,shareValueUsdandtotalSharesat:59and:61."pendingYieldUsd"does not appear anywhere in the set. The real actor is wrapped (main.js:1149for the public actor,:1179for the authenticated one), sonormMoneyscales the four listed siblings and leavespendingYieldUsdas a raw e8bigintinside the same record. All four consumers then read it as if it were already dollars:The in-file contrast is the clearest evidence. Twenty lines above the
:5544site, in the same function, the correctly-listed siblings are read with no divide:The backend source is
insuranceOwedUsd, aNatin base units like its neighbours (src/backend/main.mo:10218declares the field,:10226assigns it insidegetInsuranceFund).Why it matters. A $0.50 pending yield renders as $50,000,000. All four surfaces gate on the value being positive —
insPending > 0.0000001at:4316,pending > 0at:5356and:5546— so the inflated value always satisfies the gate: a single base unit of dust ($0.00000001) passes as1and shows as "$1", and tiles that should stay hidden are permanently visible. On the Stats Issues pane this produces a standing severity-1 warning card reading "Insurance yield pending: $50,000,000" for a fifty-cent liability, on a page whose entire purpose is transparency. The defect is invisible in demo mode, because the mock actor emits humanNumbers thatnormMoneypasses through unchanged; it fires only against the real canister.Proof.
proofs/h8-pending-yield-unscaled.mjsimports the realMONEY_KEYSandnormMoneyfromsrc/frontend/src/money.jsand feeds them a record shaped exactly as Candid decodesgetInsuranceFund. It is product code under test, not a reimplementation. It readsmain.jsfrom disk for the render-site check and modifies nothing. Run from the repo root:Source:
Output, verbatim (exit code 0):
(Only the absolute paths in Node's ESM warning are elided; nothing else is altered.)
2. Finding 34 -
uncoveredBadDebtUsdis divided by 1e8 a second timeWhere:
src/frontend/src/main.js:5380, inrenderStatsIssues.What is wrong.
uncoveredBadDebtUsdis inMONEY_KEYS(money.js:61), so by the time this code runs,normMoneyhas already converted it to human dollars. The Issues card divides again:The proof is the sibling site in the same file, reading the same
getInsuranceFund()response through the same wrapped actor, correctly:Two panes of the same page disagree about the same number by a factor of 1e8.
Why it matters. A liquidation that leaves $12,345 of uncovered bad debt shows correctly as "$12,345" and flagged unhealthy on the Insurance KPI, while the Issues pane — a severity-2 alert whose own copy calls this "the venue's most material risk number" — renders "Insurance shortfall: $0.00 of bad debt uncovered". The alert still fires, because the
> 0guard at:5379tests the undivided value; only the displayed magnitude is wrong, which arguably makes it worse: the operator sees a red card asserting a shortfall exists and a figure asserting it is rounding dust.There is a pleasing symmetry with finding 8. One convention, one field it forgot and one field it was applied to twice, producing errors of the same size in opposite directions.
How to confirm.
grep -n uncoveredBadDebtUsd src/frontend/src/main.jsreturns three hits —:874(the IDL declaration),:5379-5380(divides),:5526(does not). Confirm membership withgrep -n uncoveredBadDebtUsd src/frontend/src/money.js(:61). The two render sites cannot both be right.3. Finding 37 - the assistant's units instruction contradicts a pre-converted field
Where:
src/frontend/src/assistant.js:123versussrc/backend/main.mo:14821.What is wrong. The system prompt states the e8 invariant as universal, with exceptions carved out only for the account snapshot and candles:
The rule is correct for nearly everything. It is not correct for
position.size, which the backend converts before projecting:Fixed.toFloatis the division:Contrast the
orderentity, which projects both money fields raw as the prompt assumes:Why it matters.
positionis the flagship self-scoped entity — the prompt itself tells the model that "the user's positions ={"start":"position"}". A user asks "show my positions"; the model receivessize: 0.5alongsideentryPrice: 6500000000000, follows the ALWAYS-divide instruction, and reports 0.000000005 BTC — eight orders of magnitude too small. Any derived figure (notional, exposure, a PnL cross-check) mixes scales and is wrong. The compact schema handed to the model carries field names and types with no unit annotation, so nothing upstream corrects it.Either side is a valid fix: project
sizeraw like its siblings, or add an explicit exception to the prompt's UNITS section the way the account and candles tools already spell theirs out.How to confirm. Read
assistant.js:123andmain.mo:14821side by side.Fixed.toFloat's definition atFixed.mo:51settles that the projection is human units.4. Finding 57 - the explorer's "buy orders above 1000" preset filters at $0.00001
Where:
src/frontend/src/explorer.js:46.What is wrong. The preset ships a bare literal:
The
orderentity exposespriceraw in e8 base units (main.mo:14872, quoted above) and the OQL predicate layer compares numerically with no scaling. $1,000 is 100,000,000,000 in e8, so the literal1000is a threshold of $0.00001.Why it matters. No money is misreported — this is a demo query, and the explorer's result table also renders price unscaled, so the UI is internally consistent in raw units. The cost is pedagogical: this is the one copy-and-tweak example of a numeric filter in the explorer, so it teaches the wrong unit convention to anyone writing filters by hand, and it is the kind of example an assistant may pattern-match on. As written the clause matches essentially every buy order in every market, so the preset is a silent no-op that returns the whole bid side.
How to confirm. Run the preset against any market with resting bids and compare the row count to an unfiltered
{"start":"order"}scoped to buys.5. Finding 59 -
fromE8/toE8round-trip drifts by one base unit at large magnitudesWhere:
src/frontend/src/money.js:22-30.What is wrong. The pair is a lossy round trip at large magnitudes:
Both the division and the multiplication by 1e8 (not a power of two) introduce representation error; above a threshold their sum reaches half a base unit and
Math.roundlands onx ± 1.Why it matters. Lowest practical impact in this set, included for completeness. Drift is impossible below 2^25 human units — 3,355,443,200,000,000 base units — and the first drifting value is just above it, at 3,355,443,200,000,019 base units (approximately 33.55M tokens); above 2^53 base units (approximately 90M tokens)
Number(x)is itself lossy and every odd value drifts. The one path where it could surface is the max-balance flow: the displayed balance can round-trip one base unit high, and a whole-balance withdrawal would then be rejected for insufficient funds with no visible reason. Practically unreachable at current venue sizes.How to confirm. In Node:
const rt = x => BigInt(Math.round((Number(x)/1e8)*1e8)); rt(3355443200000019n)returns3355443200000020n.6. Finding 35 -
pollChangeshas no in-flight guardWhere:
src/frontend/src/main.js:2536-2604, scheduled at:2758.What is wrong.
setInterval(pollChanges, 2000)(:2758) fires the async function regardless of whether the previous invocation has returned, andpollMarketStatus()calls it directly after order placement and cancellation (:8581,:9434,:9458) while an interval instance may be in flight. Each call reads its cursor from the shared cache before awaiting:so two overlapping calls send the same
lastTradeIdand both receive the samenewTrades. The only guard is on market identity (:2556), not on ordering or in-flight state. The full-snapshot branch replaces book state unconditionally (:2560-2574,orderBookState[marketAtCall] = { asks, bids }at:2567), so a slow response landing after a newer one regresses the book. The trade merge has no id dedup:The user-trade merge a few dozen lines later does dedup, by id:
The in-flight pattern also already exists in this file —
_rejCheckInFlight(:2200, tested at:2211) guardscheckReleaseRejections, whichpollChangesfires un-awaited. It simply was not applied topollChangesitself.Why it matters. On a loaded subnet a
getMarketChangesquery can exceed the 2s interval. Poll A at t=0 and poll B at t=2s both sendlastTradeId = 100; the backend has trades 101-105; both responses append [101..105]. The Trades panel then renders each trade twice, the zero-tick direction colouring is corrupted by the repeated equal prices, and the duplicates persist in the 100-entry cache until they age out. Book state self-heals on the next poll — the stale snapshot is simply overwritten — but duplicated trades do not; they are appended to a cache with no key. Order placement and cancellation deliberately widen the overlap window by callingpollMarketStatus()at exactly the moments the user is most likely to be watching the tape.How to confirm. Throttle the network to make
getMarketChangestake over 2s while trades are printing, and watchcachedMarketTradesfor repeated ids. Both fixes are mechanical: an in-flight boolean matching_rejCheckInFlight, and an id dedup in the market-trade merge matching the user-trade merge.7. Finding 36 - the 5s chart refresh resets to page 0 without resetting
chartCurrentPageWhere:
src/frontend/src/main.js:3174-3186, scheduled at:2987.What is wrong.
refreshChartDatareplaces the series with page 0 and leaves the page cursor alone:changeChartInterval, which performs the same page-0 refetch, does reset it:loadOlderCandlesincrementschartCurrentPage(:3126) and prepends onto whatever is currently loaded (:3132-3133), and the scroll subscription calls it whenever the visible range starts before index 5 (:3103-3104).Why it matters. A user scrolls left through three pages, so
chartCurrentPage = 3. Within five seconds the refresh discards pages 1-3 and leaves the cursor at 3. The candles under the viewport vanish and the view jumps. The next leftward scroll fetches page 4 and prepends it directly onto page-0 data, so the chart shows page-4 candles adjacent to page-0 candles with pages 1-3 silently missing — a non-contiguous price history presented as continuous, and one that never repairs itself, since pages 1-3 are never refetched.How to confirm. Open a chart, scroll left two or more pages, wait five seconds, then scroll left again and inspect the timestamps either side of the join.
Cross-references
This cluster is uncontested — no other issue on file overlaps it. Issue #4 is the only other frontend issue, and its scope is strictly client-side integrity: the unpkg script loaded with no SRI, the absent CSP, the
ledger.jscertificate check that never runs, and the root-key fetch. It does not examine application logic or unit handling anywhere, so a maintainer triaging both should not expect shared fixes.Finding 37 is adjacent to issue #11 (the AI proxy), but the subjects are disjoint: #11 examines the backend preamble's channel placement and injection surface, whereas this finding concerns the frontend system prompt's units contract and the one backend projection that contradicts it.
Found and verified with Claude Opus 5. Every line number in this issue was re-checked against the current source at the time of writing; citations that had drifted from the original write-ups were corrected here. The proof for finding 8 runs from the repo root with no arguments, imports the product code rather than reimplementing it, reads two files, writes nothing, and requires no running canister.
Getting the proof scripts. The
proofs/paths referenced above are not in this repository — they ship separately, so nothing is added to your tree. All of them are here:https://gist.github.com/andreij6/ed9f244e47a71a786405bc7959550d4b
To run them, clone the gist into a
proofs/directory at the root of apublic-multidexcheckout:Verified end to end from a clean checkout. The scripts are read-only: they compile and read the product source, modify nothing, and contain no fixes. Each prints the observed value alongside the correct one, exits 0 with
BUG REPRODUCED:while the defect is present, and flips to exit 1 once it is fixed. The gist README maps every proof to its issue and also includes the proofs for candidates that were investigated and not filed.