Skip to content

Shield Pre-Auth Cache


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 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 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 → B2 → E → C → A → D

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

Step P is inserted after the cycle floor (F) and before the global cap (B); the canister-wide budget (B2) sits immediately after B. The full pipeline, in fixed order, is:

T · trusted-caller bypass

F · cycle reserve floor

P · pre-auth cache lookup

B · global cross-caller rate limit

B2 · canister-wide aggregate budget

E · per-caller rate limit

C · Defcon trigger observation

A · verification dispatch

D · PoW check

StepNameDetail
Ttrusted-caller bypassgovernance / guardian / self / the transition-phase deploying principals
Fcycle reserve floorrefuses expensive public calls below a cycle reserve
Ppre-auth cache lookupSYNC, no await; fresh positive hit skips ONLY B
Bglobal cross-caller rate limitSybil-proof single tuple, scoped to ONE endpoint
B2canister-wide aggregate budgetSybil-proof single tuple spanning EVERY endpoint; bounds rotation across methods. Not skippable by P
Eper-caller rate limitfriction
CDefcon trigger observationanti-flap holds (see “Defcon: the high-load state” below)
Averification dispatchshort-lived result cache; if the verification service is unreachable the call is refused as “service temporarily unavailable”, not as “unverified”
DPoW checkonly under Defcon AND cfg.pow_required_in_defcon (see “Defcon: the high-load state” below)

Step P guarantees:

  1. Step P is synchronous: it makes no inter-canister call.
  2. A fresh positive hit may skip only B — never the canister-wide budget (B2), per-caller caps (E), Defcon observation (C), verification/body auth (A), PoW (D), the cycle floor (F), or any endpoint-specific business guard. B2 in particular stays in force, because it is the layer that bounds a canister’s total admitted burn.
  3. Miss, stale, unknown, empty, disabled, and refresh-failed states all fall through to B, exactly as if the cache did not exist.

Why B2 exists, and why it cannot refuse a legitimate burst

Section titled “Why B2 exists, and why it cannot refuse a legitimate burst”

Every other rate limit in the Shield is endpoint-scoped. That is the right shape for stopping one method being hammered, and it is blind to the attack that spreads the same volume across many methods: a caller who rotates endpoints collects the sum of every endpoint’s budget, and nothing in the pipeline was measuring that sum.

B2 is a single counter per canister, covering every Shield-protected endpoint together. Its ceiling is set from one observation:

Legitimate traffic is dominated by one activity at a time — a vote burst, a refund crowd, a claim wave. An attack is dominated by their sum.

So the ceiling sits above the busiest single endpoint’s own limit, with headroom for concurrent secondary activity. Any one endpoint can therefore still run at its full configured rate and never encounter this layer; only rotation across endpoints does. A unit test asserts this as a relation between the two limits, so retuning either one cannot invert it.

Two placement rules carry the rest of the safety:

  • After T and F. A trusted caller — governance or the guardian recovering the canister — arrives during the incident that filled the budget, so the trusted bypass runs first and B2 never sees those calls.
  • After B. The aggregate counts what each endpoint has already admitted, rather than competing with the per-endpoint decision.

Each canister also has its own ceiling. The launcher backend’s is larger than the others’, because four of its endpoints serve the same launch crowd at once (buying, and the three paths that return a buyer’s own funds), and each of them is sized to admit a launch crowd rather than to refuse it. Its ceiling sits above those four at their full configured rate together, so a launch is not refused by this layer either; rotation across endpoints still is.

Each canister keeps its own independent counter. Nothing is coordinated and no canister consults another, so a campaign’s own governance canister remains fully independent of the ecosystem governance canister — a property of the DAO model, not an implementation detail to be traded away for a fleet-wide budget.

As with every other threshold in this document, the specific value lives in the configuration rather than here.

The Step P guarantees above are covered by host-runnable unit tests in ohshii-shield-core (skip-only-B, still-reaches-verification-A, still-reaches-PoW-D-under-Defcon, miss/stale fall-through-to-B).

“Defcon” is the Shield’s high-load state — observed in Step C (trigger) and consumed in Step D (PoW).

  • 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 command to force-clear it, because 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.) A submitted proof that does not verify (wrong hash, already used, from an expired challenge window, or solved for another caller or endpoint) is refused before consensus, at no cycle cost to the canister, with the same “complete the proof-of-work challenge” message; the method body verifies every admitted proof again.

Proof demanded before consensus when Defcon is certain (sponsor registration). The crate exposes a read-only predictor, trigger::certainly_defcon_through, that answers “yes” only when every call observed up to a short margin ahead must still find the endpoint in Defcon. The backend’s inspect_message uses it for sponsor registration: a call without a proof that the body would only answer with a proof demand is refused with the same text before consensus. A stale Defcon state alone is never enough, so a call the body would admit is never refused here.

The proof-of-work lane past a shared budget

Section titled “The proof-of-work lane past a shared budget”

Some budgets are shared by every caller of a kind. For the methods below, a caller who presents a valid Shield proof of work is checked against a separate lane instead of the shared budget; without a proof nothing changes.

canistermethodshared budget the proof replaceslane
SONScreate_additional_lockthe shared budget for callers without local standingown per-caller limit and canister-wide ceiling
SONSrefresh_my_verificationsamesame
ONSfollowthe shared follow capsame
ONSset_accepts_followersthe shared lookup budgetsame
backendregister_sponsor_with_icrc2_paymentthe shared registration bandown id register_sponsor_pow_lane, own per-caller and overall limits
  • Same proof, harder challenge. The lane reuses the ordinary Shield proof (same Argon2id parameters, 5-minute window, replay ledger), verified in the body by the crate against a lane config that is always in Defcon. get_shield_pow_challenge returns a higher difficulty for a lane id than for any other id; the canister checks that difficulty itself after the crate has accepted the proof.
  • Refused before consensus at no cost: a malformed, too-easy, forged or replayed proof, and a lane whose own band is spent.
  • A proof is bound to the canister that issued its challenge. This holds for every Shield proof, in a lane or not: a proof solved for one canister is refused by every other canister. Each campaign governance canister (SONS) runs its own Shield — its own budgets, Defcon state and proof replay ledger — independently of the platform canisters and of the other campaigns.
  • What the user sees. When the shared budget is spent, the refusal is the normal Shield proof-of-work demand. The page solves the proof automatically (a few seconds on a desktop browser, longer on a phone) and retries once, showing the usual overlay with a label (“Lock creation”, “Verification refresh”, “Delegate follow”, “Delegate settings”, “Sponsor registration”).
  • Old clients still work. The proof is a trailing opt blob argument; a client that does not send it is decoded as null.

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.


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-only—contributor 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-only—lock-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.

SonsLockOwner admits any local lock owner, whatever the lock’s status.


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: a caller who legitimately passed the body is in the cache for their own next call, even before any refresh runs. A failed record never changes the outcome of the call. 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 (a global and a per-caller hourly limit) are the cycle-drain guard; the source queries themselves are trusted-only.

A single refresh re-pulls and replaces the entire eligible set for the context in one shot, up to a fixed cap of 5000 principals. So the budgets bound how often the full snapshot is retaken, not how many principals can converge. Convergence is additionally served, outside this budget, by the per-principal ONS push (§4.3) and the organic body record (§4.1).

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 has a global cooldown and rejects calls after the 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 keeps its 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 canistersflushes the positive cache entries of a context; negative entries are kept (storage canisters: 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 (defined in 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 fan-out is a fixed list of five core canisters — launcher backend, dao_storage, ONS itself (applied locally), locker backend, lock_storage — each called with a bounded timeout. A partial failure does not stop the others: the caller receives one outcome per target. Any other target is refused with OHSHII_KILL_SWITCH_INVALID_TARGET before the fan-out, on both the direct path and the proposal path.

Campaign DAOs (SONS) are deliberately excluded: they are created dynamically and each is governed by its own campaign, so a coordinated fan-out to them would widen the effect of a compromised ONS. Each SONS canister has its own shield_set_preauth_mode.

The coordinator is not Shield-wrapped (a guardian emergency must never be rate-limited); its ingress pre-filter admits only the authorized callers. The kill-switch endpoints on the launcher and locker backends are Shield-wrapped.

Returning to normal after a saturation event

Section titled “Returning to normal after a saturation event”

Almost everything recovers on its own once attack traffic stops: the high-load state falls back from observed load, the rate-limit windows drain, and the pre-auth cache repopulates through the organic record, the ONS push and the scheduled refresh. The recovery methods above only shorten the wait:

StateRecovers how
Endpoint in the high-load stateautomatically, on the next admitted call after the anti-flap hold; no command can force it
Rate windows still full after traffic stoppedautomatically as the window drains; admin_reset_rate_limit_counter hands the window back sooner
Cache empty after an upgradeautomatically; refresh_preauth_cache / guardian_refresh_preauth_cache converge it in one call
Canister in TrustedOnlyonly by a deliberate mode change back to Enforce — per canister with shield_set_preauth_mode, across the core with coordinate_shield_kill_switch, or by a DAO SetShieldPreauthMode proposal

get_shield_rate_limit_status() (§7) shows the result. A cache entry never grants access by itself, so none of these steps weakens any other layer.

The 4 non-ONS canisters (launcher backend, dao_storage, locker backend, lock_storage) do not own the guardian. Each keeps a heap copy of the ONS guardian (CACHED_ONS_GUARDIAN, fail-closed), updated by an ONS notification whenever the guardian changes, plus a bounded lazy refresh on a refused call. Their guardian checks are therefore a cheap local lookup, never an inter-canister call that an unauthorized caller could trigger.

Because the copy is fail-closed, a cold or just-upgraded copy may refuse the legitimate guardian once; it refreshes within a short cooldown and admits the next call.


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—populated on Shield canisters. Always false on dao_storage / lock_storage, which run no Shield pipeline (their state is Unknown) — read state first.
preauth_mode——
cache_statsVec<PublicPreauthCacheStats>per context: entries_count, positive_hits, fresh_misses, ages, cache_state. positive_hits / fresh_misses are reserved and currently report 0.
refresh_statsVec<PublicPreauthRefreshStats>per context: successes, failures, saturated, deduped, pending
rate_limitsVec<PublicEndpointRateStatus>per endpoint: global_window_count / global_window_max are live. rate_limited_count, preauth_bypass_count, preauth_reject_count are reserved and currently report 0.
push_countersPublicPushCounterspopulated on the launcher backend and the locker backend (receive side) and on ONS (send side). All zero on SONS and the two storage canisters, which take no part in the push — a not-applicable zero, not a measurement.
push_targets——
recent_eventsVec<PublicShieldEventSummary>bounded FIFO ring (SHIELD_EVENT_RING_CAP)

The recent_events ring records structured marker events alongside the Err returns (never replacing the Err string — see §9).

Privacy. 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, and it evolves append-only, so an upgrade keeps it readable.

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_MARKERS → shield_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, unauthorized callers on guarded 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. Throughout this document deploying principals means the bootstrap identities that hold is_admin_guard during the transition phase; they are removed at the DAO handover, after which the set is governance, the guardian and the canister itself. ONS = self + guardian + the deploying principals; SONS = self + effective guardian + the lifecycle-scoped admissions (ONS and the launcher backend, until the campaign’s LGE seals); launcher backend = the ONS canister + the deploying principals (guardian added at the body for the two kill-switch endpoints); locker backend = self + the deploying principals.

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 “the platform’s 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 guarded, non-public methods are wrapped (the kill-switch endpoints — to stop an unauthorized ingress flood from slipping past inspect_message and burning cycles); and whole access-controlled canisters carry zero Shield wraps (dao_storage, and the locker’s proxy / storage / strongbox), relying on caller guards + inspect_message instead. pool_manager wraps one endpoint, get_passcode_price_status (below).

43 Shield-wrapped methods across 4 canisters, plus 1 on pool_manager. 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.

The identity-gated column reads VerificationLevel::AnyVerified: the endpoint requires that the caller counts as a verified identity, and ohshii_governance decides which providers satisfy that. The level resolves to one is_verified inter-canister call, so a provider added or retired in governance changes what these endpoints admit without any change to the Shield crate and without redeploying a consumer. The refusal message names no provider for the same reason — it points at the launcher’s verification page, which always reflects the current set. DecideIdOrWorldId is the retained older spelling of the same gate; it dispatches identically and no shipped config uses it.

Canister (repo)#Identity-gated (AnyVerified)Contribution-gated (ContributionOrVerified)Friction-only (None)
launcher backend23prepare_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, update_standalone_token, get_standalone_token_update_status, admin_reset_rate_limit_counter, admin_flush_preauth_cache
ohshii_governance (ONS)2——create_proposal, vote
sons_governance (SONS, per campaign)10——create_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
pool_manager1——get_passcode_price_status
locker backend8——create_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.

The locker’s reclaim_lp_lock / reclaim_cancelled_lock have one further ordering detail: they answer before shield::check when the lock they name is already being processed by another flow. That refusal is transient and not attributable to the caller — they are told to retry — so charging it a rate-limit slot (and, under Defcon, a freshly computed PoW solution) could rate-limit a lock’s rightful owner out of reclaiming their own funds. The early answer is a single in-memory lookup that returns sooner than a Shield-processed message would, so it does not widen the cycle-drain surface, and it is advisory only: every call that proceeds still runs the full pipeline, and the authoritative decision is taken after it.

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 kill-switch endpoints only as documented no-ops (OHSHII_*_NO_SHIELD_PIPELINE) for API symmetry across the kill-switch fan-out.
  • proxy / storage / strongbox (locker) are access-controlled with caller guards + inspect_message, not the Shield pipeline. pool_manager is too, except for one Shield-wrapped endpoint, get_passcode_price_status. It treats the canister’s authorized callers as trusted (itself, the backend, and its authorized set, which includes the governance canister), refuses every other ingress caller in inspect_message before consensus, and keeps the Shield in the body for an untrusted inter-canister caller, with a proof of work under Defcon and the proof-of-work replay ledger in pool_manager’s own stable memory. Pages read the free query get_passcode_price_snapshot instead. So no ingress call reaches pool_manager’s Shield.

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) 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 set (governance / guardian / self / the transition-phase deploying principals — 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. 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. A bounded bleed in progress is visible in each canister’s public cycle history (System Status), which is what a refuel decision reads. 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.