Shield Pre-Auth Cache
Status: AS-BUILT.
Last updated: 2026-06-26.
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 the operator-setTrustedOnlylockdown (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 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:
| Step | Name | Detail |
|---|---|---|
| T | trusted-caller bypass | admin / governance / guardian / self |
| F | cycle reserve floor | MIN_CYCLES_RESERVE = 50 B cycles |
| P | pre-auth cache lookup | SYNC, no await; fresh positive hit skips ONLY B |
| B | global cross-caller rate limit | Sybil-proof single tuple |
| E | per-caller rate limit | friction |
| C | Defcon trigger observation | anti-flap holds (see “Defcon: the high-load state” below) |
| A | verification dispatch | 5-min cache; Pattern E on ICC failure |
| D | PoW check | only under Defcon AND cfg.pow_required_in_defcon (see “Defcon: the high-load state” below) |
Step P invariants (enforced every turn of the wave):
- Step P is synchronous and must never
await. - 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.
- 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: 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). 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
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 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)”| # | 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.
The SONS liveness context was renamed
SonsTokenHolder→SonsLockOwnerduring the wave to reflect permissive any-status semantics (any local lock owner, any status). The design-era “Type 4SONS_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.
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. 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).
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 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”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 retains its 60s cooldown and rejects post-seal
with OHSHII_R3C_PULL_REJECTED_POST_SEAL.
6. Admin / recovery surface
Section titled “6. Admin / 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 | tombstone-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 only | 5-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.
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 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.
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, 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.
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 | — | — |
preauth_mode | — | — |
cache_stats | Vec<PublicPreauthCacheStats> | per context: entries_count, positive_hits, fresh_misses, fail_open_count, ages, cache_state |
refresh_stats | Vec<PublicPreauthRefreshStats> | per context: successes, failures, saturated, deduped, pending |
rate_limits | Vec<PublicEndpointRateStatus> | per endpoint: global_window_count/max, rate_limited_count, preauth_bypass_count, preauth_reject_count |
push_counters | PublicPushCounters | received, accepted, rejected_unauthorized, cache_inserted/removed, events_emitted, send-side, … |
push_targets | — | — |
recent_events | Vec<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
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. New stable fields are
Option<T>and appended last; new persisted enum variants are appended last (ProposalTarget::SetShieldPreauthMode,GovernanceEvent::ShieldPreauthModeChangeResult,GovernanceEvent::GuardianFanoutDispatchedall 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_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, 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 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: 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 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 “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_messageand burning cycles); and whole access-controlled canisters carry zero Shield wraps (pool_manager,dao_storage, and the locker’sproxy/storage/strongbox), relying on caller guards +inspect_messageinstead.
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 backend | 21 | 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, 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 |
| 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.
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 admin endpoints only as documented no-ops (OHSHII_*_NO_SHIELD_PIPELINE) for API symmetry across the kill-switch fan-out.pool_manager(launcher) andproxy/storage/strongbox(locker) are access-controlled with caller guards +inspect_message, not the Shield pipeline.
This inventory is verified directly against
shield::checkcall sites and the*_SHIELDconfigs in each canister’sshield.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.
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 —
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:
- 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 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.
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; 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.