Skip to content

OhShii Labs review, round 2 — 3/6 · 5 findings (#25.1–#25.5): the season boundary clears one half of five paired ledgers #25

Description

@rvnt9999

Round 2, part 3 of 6. resetSeason (src/backend/main.mo:14233) is not a stray dev hook — the
comment block at :14225-14232 documents it as the Phase N→N+1 competition boundary, with a
written reset-day runbook. It is gated only by if (IS_PRODUCTION) { return #err } at :14234
plus requireController, and DEPLOY_MODE = #play (main.mo:93), so it is live on the
deployment running at multidex.ai today
.

Everything below is one shape: performWorldWipe(forSeason = true) clears one member of a pair
and leaves the other standing. #production severities are mostly N/A because :14234 refuses
there — but the class is what matters if a reset path is ever wanted on a value-bearing
posture, and we state it per item.

Delineation: this is not #18 Finding 25 (andreij6), which corrects our own #10 item 1 about the
delete loop under resetExchange. We accept that correction, and it is in fact the premise
for item 4 below: the forSeason branch deliberately detaches without deleting.


1. The reset re-arms the wrong allowance bucket, and every Google-verified player is locked out of deposits

Where: main.mo:14072 (the clear), :5390-5391, :5406-5407, :5459, :5692

Severity: #play HIGH. This is the item we would fix first.

There are two allowance ledgers. playDepositUsedUsd is written only at :5407, reachable only
when playBucketFor yields #principal. On #play, playBucketFor returns #err for any
unbound principal (:5390-5391), so the only bucket a #play deposit can ever debit is
#emailplayDepositUsedByEmail (:5406). playDepositUsedUsd is provably empty on this
deployment.

performWorldWipe's season branch clears playDepositUsedUsd at :14072. That is a no-op.
playDepositUsedByEmail is untouched, and repo-wide it has no Map.clear, no Map.delete,
no admin setter and no dev hook anywhere in src/backend/ (main.mo plus all three mixins).

s1  Player verifies with Google and deposits through the season; playDepositReserve debits
    playDepositUsedByEmail[h] up to the $100k cap.
s2  Operator runs resetSeason → performWorldWipe(true). :14057-14063 zeroes every wallet.
    :14072 clears the empty map. :14073 clears playReservedUnits. The email map survives.
s3  New season: the player has $0 equity and playUsedOf(#email) == cap, so :5692 refuses
    every deposit with "only $0 of the $100000 lifetime allowance remains".
    No code path can lower that number.

To be exact about blast radius: a player who consumed the full $100k is hard-locked; one who
used $40k resumes with $60k of a nominally re-armed allowance. Both are wrong; the first is
unrecoverable without a code change.

The failure is silent in a smoke test. Unbound and fresh principals are unaffected, so a
reset-day check run from a new identity passes while every real player is bricked.

Fix direction. Clear the bucket the debit actually resolves to —
Map.clear(playDepositUsedByEmail) alongside :14072. Better, route the reset through the same
resolver the debit uses: playDebit/playUsedOf (:5397-5409) already switch on PlayBucket,
so a playClearAll() next to them stops the two sides drifting again. The fix must cover
both maps, and the test must pin both — one of two is how this arrived.


2. The reset clears playReservedUnits while the Bridge's claimables survive in another canister

Where: main.mo:14073, :5288-5316, :5340-5346, :5658-5662; src/bridge/main.mo ledgers

Severity: #play HIGH; the class is CRITICAL on a value-bearing posture.

A season reset is a DEX-side message. It clears playReservedUnits — the reservation ledger
recording units the DEX already valued and admitted — while the Bridge, a separate canister,
keeps every unclaimed confirmed − claimed balance. A claim that was already paid for under the
old reservation is thrown back into the mark-valued excess gate and re-charged against the
allowance; composed with item 1 it can be refused outright.

The core defect is a double debit of the allowance; permanent stranding is the near-cap
subcase, not the general outcome.

This extends our own #5 item 1, which established that the two ledgers diverge after a Bridge
--mode reinstall. That was an out-of-band operation. This is the same divergence reached
through the supported reset path.

Fix direction. A boundary that clears reservations must either be a two-phase operation the
Bridge participates in, or must not clear reservations at all.


3. The unshipped-history precondition ignores accounts.journal, which the same message then discards

Where: main.mo:14239 (the gate), :14135 (the clear), :6688-6691

resetSeason's precondition checks that history has been shipped before sealing the season, but
it reads the event queue only. accounts.journal — the ledger-row journal that
drainLedgerJournal feeds into the shipper every heartbeat (:6170) — is not consulted, and the
same message clears it at :14135.

So the sealed season tape can be short of its final #delta rows while the gate reports the
season fully shipped. The window is exactly the journal depth at reset time, which is larger when
the venue was busy — i.e. at the end of an active season, which is when a reset happens.

Severity is #play MEDIUM, and rises to the extent the operator engaged
setTestTimersPaused across the boundary (the runbook's own suggestion), because a paused
heartbeat is a heartbeat that is not draining the journal.

Fix direction. Include accounts.journal emptiness in the :14239 precondition, or drain it
synchronously before the seal.


4. performWorldWipe clears _priceRefreshInFlight, a single-flight flag it does not own

Where: main.mo:13970, :13398-13402, :13412

tickPriceRefresh owns _priceRefreshInFlight: it checks at :13398 and sets at :13399.
performWorldWipe clears it at :13970 while a refresh may be parked mid-fan-out across the
oracle's parallel outcalls, so the next tick's single-flight check passes and two refreshes run
concurrently.

The sibling path in the same file does this correctly and is worth copying rather than inventing:
the ship path uses an epoch bump (_captureEpoch), so in-flight work discovers it belongs to
a dead epoch and abandons itself, instead of another owner reaching in and clearing its flag.

This extends our own #9 item 4 (tickPriceRefresh clears its in-flight flag outside finally)
by naming a second writer of the same flag.

Fix direction. Bump an oracle epoch in the wipe and have refreshMultiSourcePrice discard a
result whose epoch no longer matches, rather than clearing another function's flag.


5. After a season reset, the prior season's history answers {events = []; total = 0} — a success

Where: main.mo:14161, :14167-14169, :6775-6794 (getMyArchivedEvents), :6786

The forSeason branch sets archive0 := null, _archiveNext := null and
List.clear(_archivesSealed). The canisters live on; only the routing set is emptied.
getMyArchivedEvents builds its known set from exactly those sources and, on a miss, takes
if (not known) { return { events = []; total = 0 } } at :6786.

So every event of the whole prior season is reported as absent, with a success shape, to a
caller who has no way to distinguish "you have no history" from "your history is no longer
reachable from here".

Same federation surface as our #8 item 1 (archiveExecute over-serving other users' rows),
opposite polarity: that one returns too much, this one returns too little while reporting
success. A SeasonRecord that advertises the sealed chain as the permanent ledger makes the
contradiction explicit.

Fix direction. Keep sealed season archives in a separate, additive registry that the read
path consults even after the active chain is detached, and distinguish "no rows" from "chain
detached" in the response.

(The cycle-funding consequence of the same detach is item 1 of 4/6.)

— Ravenith, OhShii Labs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions