Shield Pre-Auth Cache
1. What the cache is
Section titled “1. What the cache is”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 defaultEnforcemode a cache miss does NOT reject — it falls through to the body.inspect_messageruns 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 theTrustedOnlylockdown (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.
The key security property
Section titled “The key security property”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:
| Step | Name | Detail |
|---|---|---|
| T | trusted-caller bypass | governance / guardian / self / the transition-phase deploying principals |
| F | cycle reserve floor | refuses expensive public calls below a cycle reserve |
| P | pre-auth cache lookup | SYNC, no await; fresh positive hit skips ONLY B |
| B | global cross-caller rate limit | Sybil-proof single tuple, scoped to ONE endpoint |
| B2 | canister-wide aggregate budget | Sybil-proof single tuple spanning EVERY endpoint; bounds rotation across methods. Not skippable by P |
| E | per-caller rate limit | friction |
| C | Defcon trigger observation | anti-flap holds (see “Defcon: the high-load state” below) |
| A | verification dispatch | short-lived result cache; if the verification service is unreachable the call is refused as “service temporarily unavailable”, not as “unverified” |
| D | PoW check | only under Defcon AND cfg.pow_required_in_defcon (see “Defcon: the high-load state” below) |
Step P guarantees:
- Step P is synchronous: it makes no inter-canister call.
- 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.
- 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: the high-load state
Section titled “Defcon: the high-load state”“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
triggermodule’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.
| canister | method | shared budget the proof replaces | lane |
|---|---|---|---|
| SONS | create_additional_lock | the shared budget for callers without local standing | own per-caller limit and canister-wide ceiling |
| SONS | refresh_my_verification | same | same |
| ONS | follow | the shared follow cap | same |
| ONS | set_accepts_followers | the shared lookup budget | same |
| backend | register_sponsor_with_icrc2_payment | the shared registration band | own 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_challengereturns 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 blobargument; a client that does not send it is decoded asnull.
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)”| # | Canister | Step-P cache? | Cached contexts | Source |
|---|---|---|---|---|
| 1 | launcher backend (ohshii_launcher_backend) | Yes | Verified, Contributor(campaign_id) | ONS + dao_storage pulls and ONS trusted push |
| 2 | dao_storage (storage) | Source-only | — | contributor source queries |
| 3 | ohshii_governance (ONS) | Yes | LocalVerified | local verified-union scan |
| 4 | sons_governance (SONS) | Yes | SonsLockOwner, DaoCreator | local SONS state |
| 5 | locker backend (ohshii_locker_backend) | Yes | Verified, AnyLockOwner, status-specific lock-owner contexts, StoredSubaccountOwner(token_id) | lock_storage pulls and ONS trusted push |
| 6 | lock_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.
4.1 Organic body record_preauth
Section titled “4.1 Organic body record_preauth”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).
4.3 ONS trusted push (one-way)
Section titled “4.3 ONS trusted push (one-way)”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”5.1 Campaign-state gating
Section titled “5.1 Campaign-state gating”Completedis terminal and cannot downgrade (guarded on both dao_storage and launcher backend with theOHSHII_COMPLETED_TERMINALmarker).Finalizingis 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.
5.2 Two-phase SONS guardian
Section titled “5.2 Two-phase SONS guardian”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.
6. Recovery surface
Section titled “6. Recovery surface”| Method | Where | What it does |
|---|---|---|
refresh_preauth_cache(context) | cache canisters | deduped refresh from source of truth |
guardian_refresh_preauth_cache(context) | cache canisters | unconditional refresh; guardian gate is SYNC heap lookup |
shield_set_preauth_mode(PreAuthMode) | every Shield canister incl. SONS | sets the per-canister Step-P mode |
admin_reset_rate_limit_counter(endpoint_id [, pow]) | 5 core canisters | clears 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 canisters | flushes 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 only | 5-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.
Centralized kill switch
Section titled “Centralized kill switch”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:
| State | Recovers how |
|---|---|
| Endpoint in the high-load state | automatically, on the next admitted call after the anti-flap hold; no command can force it |
| Rate windows still full after traffic stopped | automatically as the window drains; admin_reset_rate_limit_counter hands the window back sooner |
| Cache empty after an upgrade | automatically; refresh_preauth_cache / guardian_refresh_preauth_cache converge it in one call |
Canister in TrustedOnly | only 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.
Heap-cached ONS guardian
Section titled “Heap-cached ONS guardian”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.
7. Observability
Section titled “7. Observability”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:
| Field | Type | Per-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_stats | Vec<PublicPreauthCacheStats> | per context: entries_count, positive_hits, fresh_misses, ages, cache_state. positive_hits / fresh_misses are reserved and currently report 0. |
refresh_stats | Vec<PublicPreauthRefreshStats> | per context: successes, failures, saturated, deduped, pending |
rate_limits | Vec<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_counters | PublicPushCounters | populated 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_events | Vec<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
PublicShieldStatusis an aggregate count, safe to expose publicly. The per-callerget_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 noprincipalarg, exposes no per-callerPrincipal(the only principal it carries is the reporting canister’s owncanister_id), and enumerates no cache members. (The separate source-refresh feeds — verified-principals / contributors / lock-owners pages — do returnVec<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.
8. Storage and upgrade model
Section titled “8. Storage and upgrade model”- 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_messageis 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 callsshield::check.shield::checkis the authoritative gate — under consensus, for every caller. It runs in the method body, reached by every caller that gets there: ingress that passedinspect_messageand every ICC (trusted or not). Its first step (Step T) isis_admin_or_self(caller): if the caller is in that canister’s trusted set it returnsAllowand 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 bypassesinspect_messagetoo, yet still hitsshield::checkin 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_messageand burning cycles); and whole access-controlled canisters carry zero Shield wraps (dao_storage, and the locker’sproxy/storage/strongbox), relying on caller guards +inspect_messageinstead.pool_managerwraps one endpoint,get_passcode_price_status(below).
The complete inventory
Section titled “The complete inventory”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 backend | 23 | prepare_token_creation, prepare_ico_creation, prepare_imported_dao_creation, prepare_index_canister_creation, set_user_sponsor | claim_tokens, request_refund | create_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_manager | 1 | — | — | get_passcode_price_status |
| locker backend | 8 | — | — | 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_storageandlock_storagerun 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_manageris 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 ininspect_messagebefore 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 inpool_manager’s own stable memory. Pages read the free queryget_passcode_price_snapshotinstead. So no ingress call reachespool_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.
11.1 Burst vs slow, and the three axes
Section titled “11.1 Burst vs slow, and the three axes”“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:
- a DAO-voted treasury proposal (a passed proposal that converts treasury ICP to cycles), and
- 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.
11.3 Honest limits
Section titled “11.3 Honest limits”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.