OhShii Labs review, 2/8: unvalidated input reaching permanent state, and cycle burn with no cross-caller ceiling
Second of eight. Theme: paths where a free or near-free caller writes unbounded data into stable state, or drives cycle spend, with no global bound. The Menese team's #2 covers the algorithmic side of bounding work; this covers the input side and the cycle side. They do not overlap except where noted.
DEPLOY_MODE = #play (main.mo:93), so inspect (main.mo:7846-7871) ends at not IS_PRODUCTION and admits every non-anonymous principal, registered or not. Its own comment concedes the consequence: "Play caveat, accepted for v1: unregistered spam isn't shed at this gate."
1. setUserPreferences persists an unbounded, unvalidated blob per free identity, with no eviction and no purge path
Where: src/backend/mixins/UserAccount.mo:109-113; type at src/backend/lib/Types.mo:198-201; map at src/backend/main.mo:3507
public shared (msg) func setUserPreferences(prefs : Types.UserPreferences) : async () {
requireAuth(msg.caller);
let key = Principal.toText(msg.caller);
Map.add(userPreferences, Text.compare, key, prefs);
};
public type UserPreferences = { recentMarkets : [Text]; lastMarket : ?Text };
Why it matters. requireAuth rejects only the anonymous principal; it is not an authorization boundary. There is no cap on recentMarkets, none on each Text, none on lastMarket. The only ceiling is the ~2 MiB ingress limit. The frontend voluntarily slices to 3 entries (main.js:8509) — a client-side convention, not a control.
This is structurally the one write endpoint reachable before any anti-Sybil control applies: the inspect comment at :7873 names "preference sync on sign-in" as precisely why unregistered principals are admitted.
userPreferences is a plain let in a persistent actor, so implicitly stable. We grepped every occurrence: it is never cleared by performWorldWipe / resetExchange / resetSeason (which clear ~60 maps at :13928-14048), and there is no admin purge method. Recovery would need --mode reinstall, which destroys the exchange ledger.
Roughly 2,700 calls from rotating free identities reaches WASM_MEMORY_LIMIT_BYTES (main.mo:120) — the wall your own comment at :117 records having hit once already ("the order-map leak bricked updates").
Suggested direction. Validate before persisting — reject recentMarkets.size() > 8, reject entries not present in markets, cap lastMarket length; add Map.clear(userPreferences) to performWorldWipe; and add a HARD_CAP on Map.size(userPreferences) that refuses new principals above it. A cheaper blanket fix for this whole class: Motoko's inspect supports the arg : Blob form, so return false when arg.size() exceeds ~16 KiB is one line, needs no per-method variant enumeration (the stated reason the current gate is caller-only), and closes every oversized-payload variant at once.
2. Bridge claim(asset : Text) writes a permanent ledger row for an unvalidated asset string before the rejection
Where: src/bridge/main.mo:229-237, :111-117, :40, :338-341
let l = ledgerOf(msg.caller, asset); // get-or-CREATE — writes here
let claimable = if (l.confirmed > l.claimed) { l.confirmed - l.claimed } else { 0 };
if (claimable == 0) { return #err("Nothing to claim for " # asset) }; // too late
case null { let l : Ledger = { var confirmed = 0; var pending = 0; var claimed = 0 };
Map.add(ledgers, Text.compare, k, l); l };
Why it matters. ASSETS (:40) is referenced only by getSupportedAssets and the devConfirmDeposits loop — never as a membership test — and grep -n "Text.size" src/bridge/main.mo returns nothing. Returning #err from an update is a normal return, not a trap, so the Map.add commits. ledgers is implicitly stable, as the file's own comment at :313 notes; the Bridge has no admin wipe, and postupgrade (:333) clears only claiming/admitting. One authenticated principal, no Sybil needed.
Note the recovery trap this creates: a reinstall erases every user's confirmed/claimed high-water, but the DEX's creditedSeq is not reset, so a reinstalled Bridge restarts seq at 0 and every subsequent creditAndRegister no-ops (seq <= prevSeq → #ok, main.mo:5246). The DEX guard is correct; the operational consequence is that outstanding claimables become permanently unclaimable.
Suggested direction. Validate asset against ASSETS as the first statement of claim and devSimulateDeposit; move ledgerOf below the claimable == 0 check so a no-op claim writes nothing.
Adjacent, worth stating separately: the Bridge has no posture concept at all — grep -n "IS_PRODUCTION\|IS_DEV\|DEPLOY_MODE\|requireDevHook" src/bridge/main.mo returns zero matches. devSimulateDeposit/devConfirmDeposits are labelled "DEV ONLY (the real Bridge has NO such methods)" but carry no gate; their safety on #play is entirely outsourced to the DEX's playDepositCap(), which returns null on both #dev and #production. On a flip to #production those two methods plus claim mint real balance. docs/pre-mainnet-checklist.md has no Bridge step. A posture interlock mirroring UserAccount.withdraw:189 would close it in the code that ships rather than in the checklist.
3. createMarginPool(name : Text, …) stores an unbounded name; the 64-pool cap is per-principal
Where: src/backend/main.mo:9241-9264; Pool.name : Text at src/backend/lib/MarginPools.mo:28-34
The comment at :9244-9246 identifies pool count as a DoS amplifier and caps it at MAX_POOLS_PER_OWNER = 64 — but the cap is on count, not payload, and it is per-principal, so free to multiply. name is never length-checked. No funding, no registration, no minimum balance is required to reach it.
24 identities × 64 calls with a large name reaches the same memory wall as item 1, from a different door. Every pool row also creates poolByPrincipal and ownerPoolCount entries and a MarginEngine.open account, none of which are ever deleted for an unfunded pool — and there is no close/delete path anywhere (grep "Map.delete(marginPools" returns nothing) despite the cap message saying "Reuse or close an existing pool."
Suggested direction. Cap name at ~64 chars at entry; require registeredUsers membership (the gate aiComplete already uses at :12199-12202) or a minimum fundMarginPool in the same call; and add a closeMarginPool that deletes from all three maps when a pool has zero debt, zero balance and no open orders.
4. aiComplete has no prompt length bound, so per-call cycle cost is caller-controlled — with no global cap and no meaningful floor
Where: src/backend/main.mo:12190 (entry), :12227, :12280-12290, :12076-12080 (limits), :11936 (outcallCycles)
let guarded = AI_GUARD_PREAMBLE # prompt; // :12227 — no size check anywhere
let req : HttpRequestArgs = {
max_response_bytes = ?100_000;
body = ?Blob.toArray(Text.encodeUtf8(bodyText)); // :12284 — caller-sized
Why it matters. Outcall cost is base + 400·n·request_bytes + 800·n·max_response_bytes, and request_bytes is dominated by prompt, which has no length check on any path. The rate limiter (10/min, 100/h, 250/24h) bounds call count, not cost.
On a 13-node subnet the fixed floor is 49,140,000 + 100,000 × 10,400 ≈ 1.09 B cycles per call; a 1.5 MB prompt adds 1,550,000 × 5,200 ≈ 8.06 B, giving ≈9.15 B cycles/call against a legitimate frontend prompt of ~8–15 KB (≈1.13 B) — roughly an 8× per-call amplification, and the same ratio holds under single-node pricing if is_replicated = false is honoured. One principal at the rate limit is ~2.29 T/day; ten are ~9.15 T/h. AUTO_FUEL_HEADROOM_MIN is 2 T with a 10-minute cooldown (:10493-10496), so the drain outruns self-funding.
docs/deploy-to-subnet.md:149 already anticipates the shape of this ("launch traffic is many principals") while quoting limits (20/min · 200/h · 500/24h) that no longer match the code's 10/100/250.
Suggested direction. if (Text.size(prompt) > AI_MAX_PROMPT_BYTES) { return #err(...) } with ~32 KiB — four times the real frontend prompt. Also add ("maxOutputTokens", #number(#int(4096))) to the Gemini generationConfig: the Anthropic branch bounds output with max_tokens: 4096 (:12259) but the Gemini branch sets only temperature, so a caller can force a response that exceeds max_response_bytes and is rejected after the full charge is paid.
5. There is no global cross-caller rate cap and no cycle-reserve floor on any ingress method
Where: the gate at main.mo:7846-7871; src/backend/lib/RateLimit.mo is used at exactly one site, main.mo:12097
We searched for each defence layer rather than assuming:
- Cycle floor on inbound methods: absent.
Cycles.balance() appears at :6163, 6316, 6337, 6429, 6480, 6509, 6821, 7276, 7718, 10515, 11938 — every one is an outbound-funding affordability check. None gates an inbound call.
- Global cross-caller cap: absent.
grep -n "globalWindow\|globalCount\|MIN_CYCLES\|GLOBAL_RATE" src/backend/main.mo → zero hits. The only global valve, recomputeShedFloor (:6115-6122), keys off staged-order depth, not call rate, and only affects registered callers.
- Per-caller cap: one method.
- Economic gate: one method.
The cheapest faucet we found is _internet_identity_sign_in_start (.mops/identity-attributes@0.4.1/src/lib.mo:47-49, included at main.mo:8119) — public shared func with no caller binding at all, and Random.blob in mo:core is rawRand (.mops/core@2.5.0/src/Random.mo:29-31), a management-canister call on every invocation. Per call the canister pays ≈6.5–7 M cycles; the caller pays nothing. At 100 calls/s that is ~56–60 T/day; at 1,000 calls/s ~560–600 T/day, i.e. ~21 h and ~2 h respectively to the freezing threshold on 50 T of headroom. Because tickArchiveFuel/tickBridgeFuel/tickArbFuel stop feeding at freezeLimit + 5 T, the archive and bridge starve first. Per main.mo:6250-6258 a frozen archive is a stable state that already caused a live 700k-event backlog on 2026-07-11.
Suggested direction. A MIN_CYCLES_RESERVE floor at the top of every ingress update; one global (windowStartNs, count) tuple checked in every update — the only Sybil-proof primitive and two comparisons; and a caller binding plus registeredUsers on _internet_identity_sign_in_start.
6. aiActionExecuted writes the map the aiComplete registration gate exists to protect
Where: main.mo:12313-12317; contrast :12199-12202; map at :12142
aiComplete's own comment states the registration gate exists because otherwise there is "unbounded growth of the never-evicted aiCallLog/aiUsage maps". aiActionExecuted, 120 lines below, writes aiUsage with only requireAuth. The Text.size(method) > 64 check bounds nothing — the argument is discarded, while the caller principal (the actual map key) is unbounded in cardinality. adminClearAiBan clears only aiBanUntil and aiRefusalLog, so there is no operator remedy short of an upgrade that drops the map. Row size is fixed, so this is slow growth rather than the 2 MiB class, but it is a clean bypass of the stated invariant.
Suggested direction. Apply the same registeredUsers gate, and add lazy eviction with a hard cap on both maps.
7. A trap in any synchronous heartbeat subtask stops all maintenance permanently
Where: main.mo:6140-6189; notably :6170 (drainLedgerJournal), :6184 (sweepStaleUserOrders), :6174 (tickTier), :6186 (tickDeadman); journal at src/backend/lib/Accounts.mo:25,45
Same failure class as Menese #2 item 1, different trigger — both need fixing. Theirs is processDeferredExpiry's uncapped O(K²) matcher; this is the heartbeat body itself.
We verified with the repo's pinned moc 1.9.0 that ignore f() where f : () -> async T schedules a separate message (output ordering shows the caller's continuation running before the callee's body). So the heartbeat mixes two dispatch styles with very different blast radii: ignore tickAmm() / tickLiquidations() / tickShipEvents() / tickPriceRefresh() are trap-isolated, while reapClosedOrders() / drainLedgerJournal() / tickTier(now) / tickHeatmaps() / tickLeaderboardShard() / settleInsuranceArrears() / sweepStaleUserOrders(now) / tickCandleFill() / tickDeadman() run inside the heartbeat message with no isolation.
Four properties turn one trap into a permanent outage:
- The throttle stamp rolls back with the trap.
_lastTtlSweepNs := now is assigned in the same message, so a later trap undoes it and the next heartbeat re-enters identical work — every round, forever.
- Calls issued by a trapping message are never sent.
tickLiquidations, finaliseExpiredPending (which drives GEPTOR releases), tickShipEvents, tickPriceRefresh, tickAutoFuel all stop. Staged orders never release, so user funds stay locked in reservations.
- It is self-reinforcing.
drainLedgerJournal iterates accounts.journal, an unbounded stable List receiving one entry per balance mutation (Accounts.mo:45, the single write funnel) and cleared only by this drain. Each entry costs a UserEvent construction plus a SHA-256. While the heartbeat traps, trading keeps appending and List.clear is rolled back every round, so the trap point eventually migrates to :6170 and stays there even after the original trigger is gone.
- No aggregate budget, no breaker. The sync caps are sized individually but never summed, and their cadences (10 s / 30 s / 60 s / 300 s) align every 300 s. There is no consecutive-failure counter and no timer fallback.
Mitigating: _lastHeartbeatNs also rolls back, so a stale heartbeat is visible — detectable, but not recoverable in-canister. setTestTimersPaused(true) stops the trap but also stops maintenance and never drains the journal.
Suggested direction. Chunk drainLedgerJournal (~5,000 entries/beat; journalUnshipped already surfaces the backlog); dispatch every subtask the way the safe ones already are (async + ignore), which alone turns "permanent" into "one skipped tick" because the throttle stamp then lives in the parent message; add per-subtask consecutive-failure breakers; and give sweepStaleUserOrders and tickDeadman the cursor-and-cap treatment every other sweep already has via lib/Shard.mo.
— Ravenith, OhShii Labs
OhShii Labs review, 2/8: unvalidated input reaching permanent state, and cycle burn with no cross-caller ceiling
Second of eight. Theme: paths where a free or near-free caller writes unbounded data into stable state, or drives cycle spend, with no global bound. The Menese team's #2 covers the algorithmic side of bounding work; this covers the input side and the cycle side. They do not overlap except where noted.
DEPLOY_MODE = #play(main.mo:93), soinspect(main.mo:7846-7871) ends atnot IS_PRODUCTIONand admits every non-anonymous principal, registered or not. Its own comment concedes the consequence: "Play caveat, accepted for v1: unregistered spam isn't shed at this gate."1.
setUserPreferencespersists an unbounded, unvalidated blob per free identity, with no eviction and no purge pathWhere:
src/backend/mixins/UserAccount.mo:109-113; type atsrc/backend/lib/Types.mo:198-201; map atsrc/backend/main.mo:3507Why it matters.
requireAuthrejects only the anonymous principal; it is not an authorization boundary. There is no cap onrecentMarkets, none on eachText, none onlastMarket. The only ceiling is the ~2 MiB ingress limit. The frontend voluntarily slices to 3 entries (main.js:8509) — a client-side convention, not a control.This is structurally the one write endpoint reachable before any anti-Sybil control applies: the
inspectcomment at:7873names "preference sync on sign-in" as precisely why unregistered principals are admitted.userPreferencesis a plainletin apersistent actor, so implicitly stable. We grepped every occurrence: it is never cleared byperformWorldWipe/resetExchange/resetSeason(which clear ~60 maps at:13928-14048), and there is no admin purge method. Recovery would need--mode reinstall, which destroys the exchange ledger.Roughly 2,700 calls from rotating free identities reaches
WASM_MEMORY_LIMIT_BYTES(main.mo:120) — the wall your own comment at:117records having hit once already ("the order-map leak bricked updates").Suggested direction. Validate before persisting — reject
recentMarkets.size() > 8, reject entries not present inmarkets, caplastMarketlength; addMap.clear(userPreferences)toperformWorldWipe; and add aHARD_CAPonMap.size(userPreferences)that refuses new principals above it. A cheaper blanket fix for this whole class: Motoko'sinspectsupports thearg : Blobform, soreturn falsewhenarg.size()exceeds ~16 KiB is one line, needs no per-method variant enumeration (the stated reason the current gate is caller-only), and closes every oversized-payload variant at once.2. Bridge
claim(asset : Text)writes a permanent ledger row for an unvalidated asset string before the rejectionWhere:
src/bridge/main.mo:229-237,:111-117,:40,:338-341Why it matters.
ASSETS(:40) is referenced only bygetSupportedAssetsand thedevConfirmDepositsloop — never as a membership test — andgrep -n "Text.size" src/bridge/main.moreturns nothing. Returning#errfrom an update is a normal return, not a trap, so theMap.addcommits.ledgersis implicitly stable, as the file's own comment at:313notes; the Bridge has no admin wipe, andpostupgrade(:333) clears onlyclaiming/admitting. One authenticated principal, no Sybil needed.Note the recovery trap this creates: a
reinstallerases every user'sconfirmed/claimedhigh-water, but the DEX'screditedSeqis not reset, so a reinstalled Bridge restartsseqat 0 and every subsequentcreditAndRegisterno-ops (seq <= prevSeq → #ok,main.mo:5246). The DEX guard is correct; the operational consequence is that outstanding claimables become permanently unclaimable.Suggested direction. Validate
assetagainstASSETSas the first statement ofclaimanddevSimulateDeposit; moveledgerOfbelow theclaimable == 0check so a no-op claim writes nothing.Adjacent, worth stating separately: the Bridge has no posture concept at all —
grep -n "IS_PRODUCTION\|IS_DEV\|DEPLOY_MODE\|requireDevHook" src/bridge/main.moreturns zero matches.devSimulateDeposit/devConfirmDepositsare labelled "DEV ONLY (the real Bridge has NO such methods)" but carry no gate; their safety on#playis entirely outsourced to the DEX'splayDepositCap(), which returnsnullon both#devand#production. On a flip to#productionthose two methods plusclaimmint real balance.docs/pre-mainnet-checklist.mdhas no Bridge step. A posture interlock mirroringUserAccount.withdraw:189would close it in the code that ships rather than in the checklist.3.
createMarginPool(name : Text, …)stores an unbounded name; the 64-pool cap is per-principalWhere:
src/backend/main.mo:9241-9264;Pool.name : Textatsrc/backend/lib/MarginPools.mo:28-34The comment at
:9244-9246identifies pool count as a DoS amplifier and caps it atMAX_POOLS_PER_OWNER = 64— but the cap is on count, not payload, and it is per-principal, so free to multiply.nameis never length-checked. No funding, no registration, no minimum balance is required to reach it.24 identities × 64 calls with a large
namereaches the same memory wall as item 1, from a different door. Every pool row also createspoolByPrincipalandownerPoolCountentries and aMarginEngine.openaccount, none of which are ever deleted for an unfunded pool — and there is no close/delete path anywhere (grep "Map.delete(marginPools"returns nothing) despite the cap message saying "Reuse or close an existing pool."Suggested direction. Cap
nameat ~64 chars at entry; requireregisteredUsersmembership (the gateaiCompletealready uses at:12199-12202) or a minimumfundMarginPoolin the same call; and add acloseMarginPoolthat deletes from all three maps when a pool has zero debt, zero balance and no open orders.4.
aiCompletehas no prompt length bound, so per-call cycle cost is caller-controlled — with no global cap and no meaningful floorWhere:
src/backend/main.mo:12190(entry),:12227,:12280-12290,:12076-12080(limits),:11936(outcallCycles)Why it matters. Outcall cost is
base + 400·n·request_bytes + 800·n·max_response_bytes, andrequest_bytesis dominated byprompt, which has no length check on any path. The rate limiter (10/min, 100/h, 250/24h) bounds call count, not cost.On a 13-node subnet the fixed floor is
49,140,000 + 100,000 × 10,400 ≈ 1.09 Bcycles per call; a 1.5 MB prompt adds1,550,000 × 5,200 ≈ 8.06 B, giving ≈9.15 B cycles/call against a legitimate frontend prompt of ~8–15 KB (≈1.13 B) — roughly an 8× per-call amplification, and the same ratio holds under single-node pricing ifis_replicated = falseis honoured. One principal at the rate limit is ~2.29 T/day; ten are ~9.15 T/h.AUTO_FUEL_HEADROOM_MINis 2 T with a 10-minute cooldown (:10493-10496), so the drain outruns self-funding.docs/deploy-to-subnet.md:149already anticipates the shape of this ("launch traffic is many principals") while quoting limits (20/min · 200/h · 500/24h) that no longer match the code's 10/100/250.Suggested direction.
if (Text.size(prompt) > AI_MAX_PROMPT_BYTES) { return #err(...) }with ~32 KiB — four times the real frontend prompt. Also add("maxOutputTokens", #number(#int(4096)))to the GeminigenerationConfig: the Anthropic branch bounds output withmax_tokens: 4096(:12259) but the Gemini branch sets onlytemperature, so a caller can force a response that exceedsmax_response_bytesand is rejected after the full charge is paid.5. There is no global cross-caller rate cap and no cycle-reserve floor on any ingress method
Where: the gate at
main.mo:7846-7871;src/backend/lib/RateLimit.mois used at exactly one site,main.mo:12097We searched for each defence layer rather than assuming:
Cycles.balance()appears at:6163, 6316, 6337, 6429, 6480, 6509, 6821, 7276, 7718, 10515, 11938— every one is an outbound-funding affordability check. None gates an inbound call.grep -n "globalWindow\|globalCount\|MIN_CYCLES\|GLOBAL_RATE" src/backend/main.mo→ zero hits. The only global valve,recomputeShedFloor(:6115-6122), keys off staged-order depth, not call rate, and only affects registered callers.The cheapest faucet we found is
_internet_identity_sign_in_start(.mops/identity-attributes@0.4.1/src/lib.mo:47-49, included atmain.mo:8119) —public shared funcwith no caller binding at all, andRandom.blobinmo:coreisrawRand(.mops/core@2.5.0/src/Random.mo:29-31), a management-canister call on every invocation. Per call the canister pays ≈6.5–7 M cycles; the caller pays nothing. At 100 calls/s that is ~56–60 T/day; at 1,000 calls/s ~560–600 T/day, i.e. ~21 h and ~2 h respectively to the freezing threshold on 50 T of headroom. BecausetickArchiveFuel/tickBridgeFuel/tickArbFuelstop feeding atfreezeLimit + 5 T, the archive and bridge starve first. Permain.mo:6250-6258a frozen archive is a stable state that already caused a live 700k-event backlog on 2026-07-11.Suggested direction. A
MIN_CYCLES_RESERVEfloor at the top of every ingress update; one global(windowStartNs, count)tuple checked in every update — the only Sybil-proof primitive and two comparisons; and a caller binding plusregisteredUserson_internet_identity_sign_in_start.6.
aiActionExecutedwrites the map theaiCompleteregistration gate exists to protectWhere:
main.mo:12313-12317; contrast:12199-12202; map at:12142aiComplete's own comment states the registration gate exists because otherwise there is "unbounded growth of the never-evicted aiCallLog/aiUsage maps".aiActionExecuted, 120 lines below, writesaiUsagewith onlyrequireAuth. TheText.size(method) > 64check bounds nothing — the argument is discarded, while the caller principal (the actual map key) is unbounded in cardinality.adminClearAiBanclears onlyaiBanUntilandaiRefusalLog, so there is no operator remedy short of an upgrade that drops the map. Row size is fixed, so this is slow growth rather than the 2 MiB class, but it is a clean bypass of the stated invariant.Suggested direction. Apply the same
registeredUsersgate, and add lazy eviction with a hard cap on both maps.7. A trap in any synchronous heartbeat subtask stops all maintenance permanently
Where:
main.mo:6140-6189; notably:6170(drainLedgerJournal),:6184(sweepStaleUserOrders),:6174(tickTier),:6186(tickDeadman); journal atsrc/backend/lib/Accounts.mo:25,45Same failure class as Menese #2 item 1, different trigger — both need fixing. Theirs is
processDeferredExpiry's uncapped O(K²) matcher; this is the heartbeat body itself.We verified with the repo's pinned
moc 1.9.0thatignore f()wheref : () -> async Tschedules a separate message (output ordering shows the caller's continuation running before the callee's body). So the heartbeat mixes two dispatch styles with very different blast radii:ignore tickAmm()/tickLiquidations()/tickShipEvents()/tickPriceRefresh()are trap-isolated, whilereapClosedOrders()/drainLedgerJournal()/tickTier(now)/tickHeatmaps()/tickLeaderboardShard()/settleInsuranceArrears()/sweepStaleUserOrders(now)/tickCandleFill()/tickDeadman()run inside the heartbeat message with no isolation.Four properties turn one trap into a permanent outage:
_lastTtlSweepNs := nowis assigned in the same message, so a later trap undoes it and the next heartbeat re-enters identical work — every round, forever.tickLiquidations,finaliseExpiredPending(which drives GEPTOR releases),tickShipEvents,tickPriceRefresh,tickAutoFuelall stop. Staged orders never release, so user funds stay locked in reservations.drainLedgerJournaliteratesaccounts.journal, an unbounded stableListreceiving one entry per balance mutation (Accounts.mo:45, the single write funnel) and cleared only by this drain. Each entry costs aUserEventconstruction plus a SHA-256. While the heartbeat traps, trading keeps appending andList.clearis rolled back every round, so the trap point eventually migrates to:6170and stays there even after the original trigger is gone.Mitigating:
_lastHeartbeatNsalso rolls back, so a stale heartbeat is visible — detectable, but not recoverable in-canister.setTestTimersPaused(true)stops the trap but also stops maintenance and never drains the journal.Suggested direction. Chunk
drainLedgerJournal(~5,000 entries/beat;journalUnshippedalready surfaces the backlog); dispatch every subtask the way the safe ones already are (async+ignore), which alone turns "permanent" into "one skipped tick" because the throttle stamp then lives in the parent message; add per-subtask consecutive-failure breakers; and givesweepStaleUserOrdersandtickDeadmanthe cursor-and-cap treatment every other sweep already has vialib/Shard.mo.— Ravenith, OhShii Labs