Skip to content

Shield Pre-Auth Cache

Status: AS-BUILT.

Last updated: 2026-06-26.


The pre-auth cache is a per-canister in-memory (heap-only) set of principals currently known to satisfy a trusted context for a Shield-protected endpoint. It exists to keep legitimate, eligible callers flowing during a global-cap saturation event (the canonical Sybil cycle-drain scenario), in which the global cross-caller cap (Step B) would otherwise reject everyone indiscriminately — including the verified user or campaign contributor whose eligibility was never evaluated, because Step B runs before verification (Step A).

The cache is consumed at two points:

  • inspect_message (pre-consensus, single replica) — a fresh cache hit lets a known-eligible caller be admitted cheaply, and the anonymous principal is rejected here. In the default Enforce mode a cache miss does NOT reject — it falls through to the body. inspect_message runs on one replica and a boundary node can forward the message anyway, so it is a cycle-saving cap-skip pre-filter, never an authorization gate; the pre-auth contexts are membership hints for skipping the global cap, not an allowlist. The only mode in which a non-cached public caller is rejected pre-consensus is the operator-set TrustedOnly lockdown (the kill switch — §6), which is orthogonal to the automatic Defcon state. Fails open on a stale/empty/cold cache so a freshly upgraded canister never bricks itself.
  • Step P in the Shield body (under consensus) — a fresh positive hit lets a caller skip only the global cap (B). Every other Shield layer still runs.

A Sybil attacker’s strength is the quantity of cheap fresh principals — but the cache does not counter it by rejecting Sybils. Under the automatic Defcon state (in Enforce mode) a non-cached caller is not rejected at inspect_message; it falls through to the body. (The only pre-consensus rejection of a non-cached public caller is the operator-set TrustedOnly lockdown, which is orthogonal to the automatic Defcon state — see the inspect_message note above.) What the cache neutralizes is the censorship of honest callers that the shared cap would otherwise cause: under saturation, Step B rejects everyone — including the verified user or contributor whose eligibility was never evaluated, because B runs before verification (A). The cache lets those known-eligible callers skip B, so the honest population keeps flowing. The Sybils — never cached — still hit the shared global cap (B), which bounds their aggregate accepted burn no matter how many fresh principals they mint, plus the per-caller cap (E) and, under Defcon, the memory-hard PoW (Step D). The cache thus inverts the problem: instead of the shared cap censoring the honest along with the Sybils, the honest are exempted and the cap + PoW are left to bound the flood. An attacker who does hold a few genuinely eligible principals (verification is expensive and source-rate-limited; a contribution row costs real ICP) can skip B on those few, but is still bounded by E on each — so B is the backstop behind the honest fast-path, not the front line.

Step P is an optimization, never an authorization layer. A cache hit only means “this caller is already known to satisfy a trusted context for cap-skipping.” The endpoint body remains responsible for enforcing the real access-control predicate under consensus. Body authorization is authoritative; the cache cannot grant access by itself.

The cold-start tradeoff for first-time callers

Section titled “The cold-start tradeoff for first-time callers”

The cache skips the global cap for known-eligible callers; it never blocks anyone. A legitimate caller who is not yet in any cached context — a brand-new verified creator, or a contributor whose row was created during the attack and not yet refreshed — is therefore not pre-emptively rejected. In Enforce mode they fall through inspect_message to the body and are handled under consensus by the global cap (B) plus verification (A). The only thing they lack is the cap-skip: during an active global-cap saturation they may be RateLimited at B and have to retry, and they are recorded into the cache on their first successful body call (and, for verification, via the ONS push the moment they verify — §4.3). This friction is heaviest for the identity-gated prepare_* creation endpoints (a first-time creator has no prior context to be cached under) and negligible for claim/refund, whose callers prove eligibility through an existing contribution-of-record that the Contributor/Verified contexts already track. Creation can wait out a bounded attack window — this is a deliberate availability/protection tradeoff, not a lockout.

Scope. The Shield is an in-canister layer that begins after a boundary node admits the message. It defends the cheap-fresh-principal cycle-drain vector that reaches the canister, not an upstream network-layer (volumetric) DDoS — that is the IC boundary-node layer’s concern. For the full cycle-drain threat model (burst vs slow), the auto-top-up / treasury boundary, and the honest limits this layer does not close, see §11.


2. As-built pipeline — T → F → P → B → E → C → A → D

Section titled “2. As-built pipeline — T → F → P → B → E → C → A → D”

Step P is inserted after the cycle floor (F) and before the global cap (B). The full pipeline, in immutable order, is:

T · trusted-caller bypass

F · cycle reserve floor

P · pre-auth cache lookup

B · global cross-caller rate limit

E · per-caller rate limit

C · Defcon trigger observation

A · verification dispatch

D · PoW check

StepNameDetail
Ttrusted-caller bypassadmin / governance / guardian / self
Fcycle reserve floorMIN_CYCLES_RESERVE = 50 B cycles
Ppre-auth cache lookupSYNC, no await; fresh positive hit skips ONLY B
Bglobal cross-caller rate limitSybil-proof single tuple
Eper-caller rate limitfriction
CDefcon trigger observationanti-flap holds (see “Defcon: the high-load state” below)
Averification dispatch5-min cache; Pattern E on ICC failure
DPoW checkonly under Defcon AND cfg.pow_required_in_defcon (see “Defcon: the high-load state” below)

Step P invariants (enforced every turn of the wave):

  1. Step P is synchronous and must never await.
  2. A fresh positive hit may skip only B — never per-caller caps (E), Defcon observation (C), verification/body auth (A), PoW (D), the cycle floor (F), or any endpoint-specific business guard.
  3. Miss, stale, unknown, empty, disabled, and refresh-failed states all fall through to B, preserving the pre-Phase-3 Shield behavior.

These three invariants are not just prose: they are pinned by host-runnable unit tests in ohshii-shield-core (check.rs — skip-only-B, still-reaches-verification-A, still-reaches-PoW-D-under-Defcon, miss/stale fall-through-to-B). The cache state space (context × fresh/stale/miss/empty/disabled × mode × Defcon × campaign state) is broad, so any upgrade that silently violates one of these invariants is meant to fail cargo test rather than ship.

“Defcon” is the Shield’s high-load state — observed in Step C (trigger) and consumed in Step D (PoW). This is the canonical description: the Step D row above and the Post-Saturation Recovery runbook both point here.

  • Per-endpoint, and effectively binary. Each protected endpoint has its own state (the trigger module’s per-endpoint state machine), not one global flag. Despite the name there is no 1–5 scale: an endpoint is either in the normal state or the single elevated high-load state.
  • It rises from admitted load, never from rejections. Escalation is driven by the rate of admitted requests in a rolling window — so a flood of rejected calls cannot push it up, and an attacker cannot pin it high by spamming calls that bounce. It must be earned by real accepted throughput.
  • It falls on its own and cannot be force-cleared. An endpoint leaves the high-load state once its admitted rate drops back to/under the low watermark and a minimum anti-flap hold has elapsed; the flip back to normal is applied on the next admitted call. There is intentionally no operator command to force-clear it — forcing it would defeat the anti-flap protection. The state and its timers are in-memory and reset on upgrade (so a deploy briefly re-opens the escalation window; harmless — it re-derives from live load).
  • What it triggers: the PoW (Step D). While an endpoint is in the high-load state and its config carries pow_required_in_defcon, a non-trusted caller must also complete the Argon2id proof-of-work challenge; in the normal state Step D is skipped. (What PoW is for, and why its parameters are deliberately small, is in the crate’s “What the PoW layer is” wire-contract note.)

The escalation / de-escalation thresholds (high/low watermark, hold time) are deliberately not published — they are exactly the line an attacker would tune to sit just under and avoid the PoW. The operator-facing “is it back to normal?” checklist lives in the Post-Saturation Recovery runbook.

(The pre-Phase-3 pipeline was T → F → B → E → C → A → D; the design-era F → B → E → C → A → D string is historical Wave-A notation and does not describe current code.)


3. As-built context inventory (6 canisters)

Section titled “3. As-built context inventory (6 canisters)”
#CanisterStep-P cache?Cached contextsSource
1launcher backend (ohshii_launcher_backend)YesVerified, Contributor(campaign_id)ONS + dao_storage pulls and ONS trusted push
2dao_storage (storage)Source-onlycontributor source queries
3ohshii_governance (ONS)YesLocalVerifiedlocal verified-union scan
4sons_governance (SONS)YesSonsLockOwner, DaoCreatorlocal SONS state
5locker backend (ohshii_locker_backend)YesVerified, AnyLockOwner, status-specific lock-owner contexts, StoredSubaccountOwner(token_id)lock_storage pulls and ONS trusted push
6lock_storage (storage)Source-onlylock-owner source queries

The 2 storage canisters (2, 6) hold no Step-P membership cache — they are the source-of-truth pulls and a cache-invalidation surface. The 4 cache-bearing canisters (1, 3, 4, 5) carry a PreauthFamilyCounters aggregate (heap-only) used for observability (§7).

Multi-context endpoints use OR semantics: a fresh hit in any configured context earns the Step-P global-cap skip for that endpoint. This is separate from body authorization — e.g. locker liveness intentionally admits Verified OR a lock-owner context for cap-skipping, while the endpoint body still enforces the lock-specific action rules.

The SONS liveness context was renamed SonsTokenHolderSonsLockOwner during the wave to reflect permissive any-status semantics (any local lock owner, any status). The design-era “Type 4 SONS_TOKEN_HOLDER (VP > 0)” naming is superseded.


4. Population: organic body, refresh, push, pull

Section titled “4. Population: organic body, refresh, push, pull”

The cache converges through four mechanisms, no single one of which is the sole liveness path.

After an endpoint body confirms eligibility, the caller is recorded into the cache on a best-effort basis. This is the Tier-1 organic tier: a caller who legitimately passed the body is in the cache for their own next call, even before any refresh runs. The organic record is best-effort — the let _ = ... discard must not affect control flow (invariant). Organic outcomes are counted (organic_record_attempts/successes/skipped_fresh/hard_cap_failures/negative_suppressions).

4.2 Refresh (refresh_preauth_cache / guardian_refresh_preauth_cache)

Section titled “4.2 Refresh (refresh_preauth_cache / guardian_refresh_preauth_cache)”

A refresh replaces the cache set from the source of truth: an async ICC for Verified/Contributor, a synchronous local scan for LocalVerified / SonsLockOwner / DaoCreator / locker owner contexts. Refresh is the fail-safe for missed pushes. Caller-side refresh budgets (REFRESH_GLOBAL_CAP = 60/hour, REFRESH_CALLER_CAP = 6/hour) are the cycle-drain guard (source queries are intentionally not source-rate-limited; they are trusted-only and the budgets sit on the caller side — a deliberate design decision, verified by tests).

A single refresh re-pulls and replaces the entire eligible set for the context in one shot (paginated at 500/page, up to PREAUTH_MAX_PAGES = 10 / PREAUTH_MAX_REFRESH_PRINCIPALS = 5000). So these budgets bound how often the full snapshot is retaken, not how many principals can converge — the first successful refresh in a window re-admits the whole population at once, and convergence is additionally served (unthrottled by this budget) by the per-principal ONS push (§4.3) and the organic body record (§4.1). The real scaling ceiling is therefore the 5000-principal truncation — which downgrades a refresh to PartialTruncated, and, because the page loop starts at offset 0, leaves any principal ranked beyond 5000 in source order to converge via push/organic only — not the 60/hour cadence.

guardian_refresh_preauth_cache forces an unconditional refresh (ignores the dedup short-circuit); its guardian gate is a synchronous heap lookup (see §6).

ONS verification writes fan out with fire-and-forget trusted pushes to the launcher backend and locker backend via backend_preauth_verification_changed. Clear paths preserve verification-union semantics by recomputing is_any_method_verified before sending. Push is one-way only (ONS → launcher backend + locker backend; no reverse push) and is an optimization, not the only convergence path — refresh and guardian refresh remain the fail-safe.

4.4 SONS two-phase guardian pull (pull_pre_lge_ons_guardian_from_ons)

Section titled “4.4 SONS two-phase guardian pull (pull_pre_lge_ons_guardian_from_ons)”

SONS recovers the cached ONS guardian via this public method. Because it is public and triggers an ICC, it retains a 60-second global cooldown and must reject post-seal (after the local SONS LGE seal, the local guardian is authoritative). See §5.


5. Campaign-state and two-phase-guardian semantics

Section titled “5. Campaign-state and two-phase-guardian semantics”
  • Completed is terminal and cannot downgrade (guarded on both dao_storage and launcher backend with the OHSHII_COMPLETED_TERMINAL marker).
  • Finalizing is the provisional finalization state and has no user-facing actions.
  • Refund is allowed only in Failed.
  • Vesting, claim, and governance proposal creation are allowed only in Completed.
  • Automatic classification: target-reached takes precedence over expired; only expired unsold campaigns become Failed.

SONS has two guardian concepts:

  • pre_lge_ons_guardian_principal — the cached ONS guardian, effective before the local SONS LGE seal.
  • guardian_principal — the post-LGE local guardian, effective after the seal.

The authority switch happens at SONS seal success, not merely at storage Completed. The public pull recovery retains its 60s cooldown and rejects post-seal with OHSHII_R3C_PULL_REJECTED_POST_SEAL.


MethodWhereWhat it does
refresh_preauth_cache(context)cache canistersdeduped refresh from source of truth
guardian_refresh_preauth_cache(context)cache canistersunconditional refresh; guardian gate is SYNC heap lookup
shield_set_preauth_mode(PreAuthMode)every Shield canister incl. SONSsets the per-canister Step-P mode
admin_reset_rate_limit_counter(endpoint_id [, pow])5 core canistersclears the global + per-caller rate counters for an endpoint (storage canisters: no-op OHSHII_RESET_NO_SHIELD_PIPELINE)
admin_flush_preauth_cache(context [, pow])5 core canisterstombstone-preserving cache flush; NEVER clears PREAUTH_NEGATIVE_TOMBSTONES, NEVER reuses remove_cache_entry (storage: no-op OHSHII_FLUSH_NO_SHIELD_PIPELINE, Ok(0))
coordinate_shield_kill_switch(mode, justification)ONS only5-target static fan-out; SONS deliberately excluded

PreAuthMode (frozen crate ohshii-shield-core/src/gateway.rs):

  • Disabled — Step P is a no-op and cannot skip the global cap.
  • Enforce — Step P may use fresh positive hits to skip only B; all later layers run.
  • TrustedOnly — only trusted callers are admitted; public callers are rejected before rate limits, verification, or PoW can consume cycles.

coordinate_shield_kill_switch flips PreAuthMode across the trusted core in one call. ONS (ohshii_governance) is the coordinator; everyone else is a passive target. Entry points: the governance-set guardian (direct, for incident response), or the DAO path via ProposalTarget::SetShieldPreauthMode (executes the same fan-out). The 5-target static fan-out (kill_switch_core_targets()): launcher backend, dao_storage, ONS-self (applied locally), locker backend, lock_storage — over futures::future::join_all of Call::bounded_wait("shield_set_preauth_mode") with a 10s timeout, no spawn, partial failure does not short-circuit (the operator receives a full Vec<Outcome>). SONS is deliberately excluded: it is dynamic + untrusted, and a coordinated destructive fan-out to dynamically-discovered untrusted canisters would widen the ONS-compromise blast radius the two-phase model contains. validate_kill_switch_targets() rejects any non-core/SONS target with OHSHII_KILL_SWITCH_INVALID_TARGET before fan-out, on both the admin-direct and the proposal path; targets are NEVER derived from list_unsealed_sons_governance_canisters. A SONS instance is disabled individually via the per-SONS kill-switch procedure (list_all_sons_governance_canisters + per-SONS shield_set_preauth_mode(Disabled)).

The coordinator is not Shield-wrapped (a guardian emergency must never be rate-limited); its cheap ingress defense is a guardian-inclusive inspect_message branch. The real-work admin endpoints on launcher + locker backends are wrapped by ADMIN_KILL_SWITCH_SHIELD (byte-identical launcher ↔ locker). ohshii-shield-core was NOT modified for the kill switch.

The 4 non-ONS canisters (launcher backend, dao_storage, locker backend, lock_storage) do not own the guardian, so their guardian gate was historically an ICC to ONS — an ICC-amplified reject path (Bucket-3). The cache layer heap-caches the ONS guardian on those 4 canisters (CACHED_ONS_GUARDIAN, fail-closed) with an ONS fanout (notify_non_ons_canisters_guardian_changed, fire-and-forget spawn from 3 write sites) plus a deny-side bounded single-flight lazy refresh (30s cooldown, RAII Drop). The 8 Bucket-3 sites (guardian_refresh_preauth_cache + shield_set_preauth_mode on each of the 4) collapse to Bucket-2 (flat-cheap sync reject). The cycle-drain ICC-amplification vector is CLOSED and the guardian gate is DAO-transition-ready.

One eventual-consistency caveat (a deliberate availability tradeoff, not a gap): because the cache is fail-closed, a cold or just-upgraded cache (None) may reject the legitimate guardian once, then self-heals within the 30-second cooldown via the deny-side single-flight refresh and admits on the next call — availability is restored without ever re-opening the ICC-amplification vector.


Every Shield-bearing canister exposes a free public query get_shield_rate_limit_status() -> PublicShieldStatus (defined in the frozen crate ohshii-shield-core/src/preauth_api.rs). PublicShieldStatus carries aggregate-only counters with zero per-principal data:

FieldTypePer-element counters / notes
timestamp_ns
canister_id
state
any_defcon_active
preauth_mode
cache_statsVec<PublicPreauthCacheStats>per context: entries_count, positive_hits, fresh_misses, fail_open_count, ages, cache_state
refresh_statsVec<PublicPreauthRefreshStats>per context: successes, failures, saturated, deduped, pending
rate_limitsVec<PublicEndpointRateStatus>per endpoint: global_window_count/max, rate_limited_count, preauth_bypass_count, preauth_reject_count
push_countersPublicPushCountersreceived, accepted, rejected_unauthorized, cache_inserted/removed, events_emitted, send-side, …
push_targets
recent_eventsVec<PublicShieldEventSummary>bounded FIFO ring (SHIELD_EVENT_RING_CAP)

The observability layer wired the previously-stubbed push_counters and recent_events ring on the cache-bearing canisters, and added structured marker events emitted alongside existing Err returns (never replacing the Err string — see §8).

Transparency vs exploitable. Every field in PublicShieldStatus is an aggregate count, safe to expose publicly. The per-caller get_preauth_status(principal) query is separate, already rejects anonymous (OHSHII_PREAUTH_STATUS_ANONYMOUS_REJECTED), and is the only per-caller surface. The aggregate metrics query takes no principal arg, exposes no per-caller Principal (the only principal it carries is the reporting canister’s own canister_id), and enumerates no cache members. (The separate source-refresh feeds — verified-principals / contributors / lock-owners pages — do return Vec<Principal>, but those are the trusted, rate-limited refresh inputs, not this public status query.)

Reading the push counters — receive-side vs send-side

Section titled “Reading the push counters — receive-side vs send-side”

PublicPushCounters carries two groups of fields. The launcher and locker backends are push receivers, so on them only the receive-side counters are populated:

  • received, accepted, rejected_unauthorized, cache_inserted, cache_removed, events_emitted.

The send-side fields — sent_total, skipped_target_unavailable, probe_attempts, probe_successes, recovery_events — describe outbound pushes and are tracked on the sender canisters (the ONS verification fanout and the pool manager), not on a receiver. On a receiver they read 0 by design — that is expected, not an error.


  • The Step-P membership cache is heap-only and intentionally resets on upgrade. An empty cache after upgrade falls through to the original Shield cap path and schedules a refresh; it never grants access.
  • The aggregate counters (PreauthFamilyCounters, push counters, the event ring) are heap-only and reset on upgrade.
  • The stable state is mode/lifecycle/cooldown/config only. New stable fields are Option<T> and appended last; new persisted enum variants are appended last (ProposalTarget::SetShieldPreauthMode, GovernanceEvent::ShieldPreauthModeChangeResult, GovernanceEvent::GuardianFanoutDispatched all appended last).

9. Marker Err contract (consumed by the frontend classifier)

Section titled “9. Marker Err contract (consumed by the frontend classifier)”

The marker Err prefixes are a wire contract consumed by the frontend classifier (src/frontend/src/utils/canisterErrors.js and shieldPow.js). The FE↔Rust link is verified by tests: canisterErrors.js classifies on these stable Err prefixes — OHSHII_SHIELD_PREAUTH_REJECTED (in RATE_LIMIT_MARKERSshield_preauth_rejected) and the terminal markers OHSHII_COMPLETED_TERMINAL, OHSHII_R3C_PULL_REJECTED_POST_SEAL, OHSHII_FINALIZE_NO_GOVERNANCE (in TERMINAL_MARKERS).


10. Protected surface — which canisters, which methods, and what “protected” means

Section titled “10. Protected surface — which canisters, which methods, and what “protected” means”

What “Shield-protected” means — two mechanisms, not one

Section titled “What “Shield-protected” means — two mechanisms, not one”

A method is Shield-protected when its body, as its first work, calls shield::check(&CONFIG, caller, …) and returns the decision’s user error. The surface is easy to misread, so separate the two mechanisms involved:

  • inspect_message is an ingress-only pre-filter — NOT the protection. It runs on a single replica, before consensus, and only for ingress (user-submitted update calls). The IC platform never invokes it for inter-canister calls (ICC), queries, or management calls — so every canister-to-canister call bypasses it, including a hypothetical third-party canister. Its job is to cheaply reject obviously-disallowed ingress (anonymous callers on authenticated methods, non-admin callers on admin-only methods, cache-cold non-trusted callers on Shield methods) to save consensus cycles. It is not a security boundary and it never calls shield::check.
  • shield::check is the authoritative gate — under consensus, for every caller. It runs in the method body, reached by every caller that gets there: ingress that passed inspect_message and every ICC (trusted or not). Its first step (Step T) is is_admin_or_self(caller): if the caller is in that canister’s trusted set it returns Allow and skips the whole pipeline; otherwise the full pipeline runs (cycle floor → pre-auth cache → global + per-caller rate limits → Defcon/PoW → the verification ICC when the config demands it).

The trusted set (who bypasses via Step T) is canister-specific: ONS = self + admin + guardian; SONS = self + lifecycle-aware admin + effective guardian; launcher backend = admin + the ONS canister (guardian added at the body for the two kill-switch endpoints); locker backend = self + the launcher backend + admin.

So, refining the common shorthand “protected methods are the user-callable ones, and canisters bypass”:

  • “Canisters bypass inspect_message is true universally — because ICC never triggers it (a platform fact), not because the canister is trusted. A non-trusted third-party canister bypasses inspect_message too, yet still hits shield::check in the body under consensus and runs the full pipeline.
  • Only the trusted set bypasses shield::check (Step T). That is the real “our own canisters / governance / guardian / self skip the firewall” path.
  • Shield-protected ≠ every user-callable method. The wrapped set is a curated subset of the cycle-expensive financial / governance endpoints. Two consequences: a few admin-only methods are wrapped (the kill-switch endpoints — to stop a non-admin ingress flood from slipping past inspect_message and burning cycles); and whole access-controlled canisters carry zero Shield wraps (pool_manager, dao_storage, and the locker’s proxy / storage / strongbox), relying on caller guards + inspect_message instead.

The complete inventory (verified against source, both repos)

Section titled “The complete inventory (verified against source, both repos)”

41 Shield-wrapped methods across 4 canisters (SONS gained claim_cancelled_lock + reactivate_frozen_lock with the 2026-07 per-lock custody isolation). Most carry VerificationLevel::None — there the Shield is pure cycle-drain friction (rate limits + PoW under Defcon), because eligibility is already paid upstream (an ICRC-2 transfer from a real-balance account, or a contribution-of-record). Identity is gated only where there is no prior economic commitment.

Canister (repo)#Identity-gated (DecideIdOrWorldId)Contribution-gated (ContributionOrVerified)Friction-only (None)
launcher backend21prepare_token_creation, prepare_ico_creation, prepare_imported_dao_creation, prepare_index_canister_creation, set_user_sponsorclaim_tokens, request_refundcreate_ico_with_payment, create_ico_with_icrc2_payment, create_token_with_icrc2_payment, create_imported_dao_with_icrc2_payment, create_index_canister, register_menese_user, relaunch_ico_with_icrc2_payment, register_sponsor_with_icrc2_payment, purchase_tokens_icrc2, purchase_whale_pass_with_icrc2, execute_cycles_topup, execute_discount_cycles_purchase, admin_reset_rate_limit_counter, admin_flush_preauth_cache
ohshii_governance (ONS)2create_proposal, vote
sons_governance (SONS, per campaign)10create_proposal, vote, activate_vesting, create_lp_lock, reclaim_lp_lock, claim_cancelled_lock, reactivate_frozen_lock, follow, set_accepts_followers, mark_lge_completed_via_dao_storage
locker backend8create_lock_v2, create_lp_lock, reclaim_lp_lock, reclaim_cancelled_lock, reclaim_stored_subaccount_tokens, reactivate_frozen_lock, admin_reset_rate_limit_counter, admin_flush_preauth_cache

The two admin_reset_rate_limit_counter / admin_flush_preauth_cache endpoints share the admin_kill_switch endpoint_id and invert the order (trusted check first, then shield::check for non-trusted callers). Every wrapped config’s endpoint_id is mirrored in that canister’s get_shield_pow_challenge VALID_ENDPOINTS allow-list.

Not Shield-wrapped, by design:

  • dao_storage and lock_storage run no Shield pipeline — they are the pre-auth cache’s source of truth (contributor / lock-owner / verified-user queries) and expose the two admin endpoints only as documented no-ops (OHSHII_*_NO_SHIELD_PIPELINE) for API symmetry across the kill-switch fan-out.
  • pool_manager (launcher) and proxy / storage / strongbox (locker) are access-controlled with caller guards + inspect_message, not the Shield pipeline.

This inventory is verified directly against shield::check call sites and the *_SHIELD configs in each canister’s shield.rs; it supersedes any older count.


11. Cycle-drain threat model, the treasury boundary, and honest limits

Section titled “11. Cycle-drain threat model, the treasury boundary, and honest limits”

The pipeline above is the mechanism; this section is the threat model it serves — and the limits it does not close.

“DDoS”, “Sybil flood”, and “cycle-drain” are three different axes, treated differently:

  • Volumetric DDoS is a volume problem and sits a layer beneath the Shield — the IC’s boundary nodes absorb it before a message reaches the canister (see the Scope note in §1).
  • A burst cycle-drain / Sybil flood is the canonical case the load-gated layers answer: the admitted-rate trigger (Step C) escalates the endpoint to its high-load state, after which every admitted non-trusted call must carry a fresh Argon2id proof-of-work (Step D). That turns “free spam” into “pay CPU + memory per call” — exactly what a cheap-identity flood can’t sustain at scale.
  • A slow cycle-drain — a drain kept low enough not to trip the high-load state — is the harder case, and it is bounded by the always-on layers rather than the load-gated ones: the shared cross-caller cap (Step B) puts a hard ceiling on the aggregate accepted burn no matter how many fresh identities are minted, and the cycle-reserve floor (Step F — MIN_CYCLES_RESERVE, 50 B cycles) refuses expensive public calls once the balance reaches the floor.

11.2 Why a slow drain can’t silently reach the treasury (the auto-top-up boundary)

Section titled “11.2 Why a slow drain can’t silently reach the treasury (the auto-top-up boundary)”

The usual way a slow cycle-drain turns expensive is when a canister is on automatic cycle top-up: the drain is invisibly funded from a treasury, so a sub-detection bleed quietly empties the treasury instead of stopping at the canister.

OhShii canisters have no automatic cycle pump. There is no timer, heartbeat, balance-threshold trigger, or background task that refuels a canister’s cycles on its own. Cycles enter a canister by exactly two human-gated routes:

  1. a DAO-voted treasury proposal (a passed proposal that converts treasury ICP to cycles), and
  2. a self-funded community contribution (a caller who pays their own ICP to top up a canister).

Both require a human action — a vote that passed, or an explicit funded call. (A governance canister does auto-execute a top-up proposal once it is Approved, but the authority behind it is the prior vote, never the timer.)

The consequence is the worst case a slow drain can reach. With no pump, a cycle bleed lands on the canister’s operating cycle buffer, and there is no path by which it silently pulls from the treasury to keep itself fed. Because Step B caps the aggregate burn regardless of how many identities the attacker spins up, and the fast lane lets verified / known-eligible members skip only that shared cap, the unknown-caller lane can be throttled hard without choking members. So “slow-drain the treasury forever” becomes “nudge the canister toward its cycle floor at a capped rate, where it sheds expensive public calls and waits for a deliberate, on-chain refuel.” Operating without a pump, on its own, only trades a silent treasury drain for a canister that freezes when it runs dry — the Shield is what makes running without a pump survivable instead of a freeze: the bleed is rate-bounded, member-blind, and visible rather than silent.

At the floor, the fast lane does not keep ordinary voting alive. Only the trusted operator set (admin / governance / guardian / self — Step T) bypasses the cycle floor, because Step T runs before Step F. A verified-but-ordinary caller with a pre-auth cache hit still hits the floor (Step P skips only the global cap B, never F). So at the floor the canister refuses expensive public calls including votes; trusted and governance operations still function, which is what keeps the deliberate refuel decision reachable. The treasury itself is governed separately — see Governance & Capture Resistance for who can move it and the day-zero-autonomy / controller-seal model.

Two things this layer bounds but does not eliminate — stated plainly, without overselling either way:

  • The Shield caps and backstops a slow burn; it does not yet alert on one early. The shared cross-caller cap (Step B) puts a hard ceiling on how fast the unknown-caller lane can burn cycles — no number of fresh identities raises it — and the cycle-reserve floor (Step F) is a hard stop: below it, expensive public calls are refused, so a public-call bleed cannot run the canister to exhaustion. The always-on layers give containment (a bounded rate) and a backstop (the floor), not an early alarm: noticing a bounded, in-progress bleed before it reaches the floor relies today on cycle telemetry and operator attention rather than an automatic burn-rate signal. Adding that signal — burn rate, not just request rate — to the load observer the Shield already runs is a planned observability improvement that would let a refuel decision be taken sooner. It is not a gap that moves funds: the rate is already capped, the floor already holds, and with no pump the burn never reaches the treasury.
  • A method that is costly in its own logic is the dApp’s bug to fix; the Shield bounds its blast radius. No rate limiter, ours included, makes an expensive method cheap — that is an implementation matter for the specific endpoint. What the Shield guarantees is that even a costly method cannot be called faster than the cap allows (Step B + Step E + PoW under load) and cannot be amplified into a treasury drain (no pump). The worst case stays the same bounded buffer burn that ends at the floor.