Core Workflows
This document describes the main end-to-end flows with Mermaid diagrams: LGE creation, token creation, voting, proposal creation, proposal execution (including the difference between backend WASM upgrade and frontend/asset upgrade), the proposal fee → refund lifecycle across every outcome, self-snapshot for governance canisters (with the backend as orchestrator), LGE finalization (success and failure paths), and campaign failure and refunds.
1. Create LGE
Section titled “1. Create LGE”The creator sets the LGE’s fundraising target — any multiple of 500 ICP, from 500 ICP up to 5000 ICP (target_icp_for_liquidity = k × 500, integer k in 1..=10, in steps of 500; 500 ICP is the default). The bonding curve keeps the same shape and sells the same tokens at every target — 700M on the curve, 200M into the pool, 100M DAO reserve, unchanged — and only the ICP prices scale by k: ~90% of the chosen raise seeds the ICPSwap pool (450 × k ICP, 450 at the 500-ICP default) and the pool opens at 450k / 200M = 225k e8s/token (0.00000225 × k ICP/token, 0.00000225 at the default; the end-of-curve price 0.00000133 × k stays ~41% below it at every k). The 500-ICP figures used elsewhere on this page are the k = 1 example, not a fixed model — and are distinct from the one-time creation fee described next.
Regional participation eligibility. During creation the creator configures which countries are excluded from participating in the LGE (ISO 3166-1 alpha-2 codes). The creation UI pre-populates the selection from a platform-wide suggested-default list (a single shared list, itself changed only by an adopted ONS proposal). The suggested default has two tiers: baseline entries (set at platform level, included in every launch — rendered checked and non-removable, and enforced by the backend with a fail-closed pre-payment check plus a union on every later replacement) and suggested entries (pre-checked but freely removable). Beyond the baseline, the creator can fully customize the list — add or remove any country — before submitting. The resolved list is snapshotted onto the campaign at creation (list version 1) and stays in effect unless later replaced by an adopted governance proposal targeting that campaign (see GOVERNANCE.md); there is no separate freeze-at-start for this parameter. The list is mirrored onto the campaign’s own sons_governance canister, so the DAO’s own governance layer holds an independently verifiable copy.
The user pays 5 ICP via ICRC-2; the backend creates an empty governance canister, ledger, and index (with governance as controller), installs the governance WASM, and registers the campaign in storage and pool manager. The index deployment, the governance WASM install and the pool-manager registration run in parallel (they only depend on the ledger and governance ids), and the ICPSwap pool phase is detached: the create call returns right after the campaign is stored and synced to governance, while the pool completes in a background job.
The campaign is stored as Frozen while the pool is being created, and flips to Active only when the pool is confirmed ready. Because the pool phase is detached (it runs after the create call returns, for up to a few minutes), an Active campaign would be purchasable before its pool exists — and if contributions reached the sale target before the pool was created, finalization would fail for lack of a pool. Storing the campaign Frozen blocks contributions (the purchase path gates on Active), so no funds can move until the detached job creates the pool and reveals the canister ids + pool id. On a pool-creation failure the campaign simply stays Frozen (canisters preserved) and is recoverable by the ONS guardian.
sequenceDiagram participant User participant Frontend participant ICPLedger participant Backend participant Storage participant CyclesLedger participant Gov as sons_governance participant Ledger as Ledger Canister participant Index as Index Canister participant PoolMgr as pool_manager
User->>Frontend: Create LGE (params, payment) Frontend->>Backend: prepare_ico_creation() Note over Backend: validates params incl. optional post-LGE guardian<br/>(anonymous + OhShii infra canisters rejected);<br/>advisory concurrency check (global 3 / per-creator 1) Backend-->>Frontend: payment_info (amount, expiry, memo) Frontend->>User: Show approve amount User->>ICPLedger: icrc2_approve(backend, amount) Frontend->>Backend: create_ico_with_icrc2_payment(args) Note over Backend: reserve creation slot (RAII; rejected BEFORE any<br/>fee debit — approval stays valid for a retry) Backend->>ICPLedger: icrc2_transfer_from (collect) Backend->>Backend: create_ico_campaign() Note over Backend: allocate campaign_id + subaccount, cycles floor<br/>scaled by in-flight creations Backend->>CyclesLedger: create_empty_governance_canister Backend->>CyclesLedger: create ledger (Gov as controller) par parallel phase Backend->>CyclesLedger: create index (Gov as controller) and Backend->>Gov: install governance WASM and Backend->>PoolMgr: register campaign subaccount end Backend->>Storage: store campaign FROZEN (pool_status="pending", ids hidden) Backend->>Storage: store_created_token, sponsor attribution Backend->>Gov: sync campaign data Note over Backend: persist detached pool job (stable), schedule timer Backend-->>Frontend: Ok(campaign_id, ledger, index, pool_id=None) Frontend-->>User: LGE created (pool completing in background) Note over Backend,PoolMgr: detached job: fund transfer → ICPSwap pool create →<br/>final update (ids revealed, pool_status="ready", state Active) →<br/>link pool to governance. On failure: campaign stays Frozen<br/>+ job kept for guardian retry. Job survives upgrades<br/>(re-armed in post_upgrade) + a housekeeping watchdog<br/>re-arms a stuck job.Progress is observable in two ways: the memo-keyed log stream (get_ico_creation_logs, which now also authorizes the payer via the progress record so it stays readable through the detached tail) and the typed per-step progress query get_ico_creation_progress(payment_memo) (steps PaymentVerified → SlotAllocated → GovernanceCreated → LedgerCreated → IndexCreated / GovernanceInstalled / SubaccountRegistered (parallel) → CampaignStored → GovernanceSynced → PoolCreating → PoolCreated → Completed, each InProgress/Done/Failed). The typed query authenticates against the payer recorded in the progress entry, so it keeps answering after completion — the frontend resume banner uses it to restore progress after a page reload.
Anti-front-running (ledger id stays hidden until the pool exists). The token ledger id must not appear on any public surface until our ICPSwap pool is created — otherwise someone could create the pool first with a wrong initial price. So the campaign record hides the ledger/index/governance ids (stored None), the token is registered in the public created-tokens registry (get_all_created_tokens) and synced to the governance canister only in the detached job’s finalize step, after the pool exists. On a pool-creation failure the ids stay hidden everywhere; the guardian retry reveals and registers them only once the pool is successfully created.
Pool-creation failure and guardian retry. If the detached job exhausts its bounded retries (a definite ICPSwap failure, or a transient failure that never resolves), the campaign stays Frozen, all canisters are preserved, and the pool job is kept (marked frozen). The ONS guardian (or an admin) can re-drive it with guardian_retry_pool_creation(campaign_id) (Shield/guardian-gated, same pre-auth model as guardian_retry_finalization): it resets the job and re-runs pool creation; on success the campaign flips Frozen → Active and opens to contributions. admin_clear_pool_job(campaign_id) is an admin give-up for a corrupt/stuck job. A stuck job whose timer chain died silently is also re-armed by the housekeeping watchdog and by post_upgrade.
Concurrency: at most 3 LGE creations platform-wide and 1 per creator may be in flight at once. The slot is reserved before the fee debit (and the scaled cycles floor is checked there too), so a capacity/cycles rejection never costs the user anything; the error message says the approval remains valid and to retry in a few minutes.
Post-LGE guardian: the creator can optionally designate a guardian for the new DAO (defaults to the creator). The anonymous principal and every OhShii infrastructure canister are rejected at prepare_ico_creation (and re-checked at campaign creation and at SONS install). The guardian can only trigger emergency/maintenance actions on the DAO — it can never access funds.
Reference: src/backend/src/lib.rs — prepare_ico_creation, create_ico_with_icrc2_payment, create_ico_campaign, create_empty_governance_canister, install_governance_code, run_pool_creation_job, finalize_campaign_after_pool (Frozen→Active), freeze_campaign_after_pool_failure, guardian_retry_pool_creation, admin_clear_pool_job, sweep_stuck_pool_jobs, get_ico_creation_progress, validate_post_lge_guardian.
1.5 Participate in an LGE (purchase tokens)
Section titled “1.5 Participate in an LGE (purchase tokens)”LGE participation follows the Voter Benefits Protocol (5 tiers: Guest / Human / Fish / Shark / Whale). See GOVERNANCE.md § Voter Benefits Protocol for the full tier table and axis definitions.
The backend is a payment gateway: business logic — bonding curve,
per-user purchase limit, sale-target gate, and idempotency — lives
inside dao_storage. The backend validates, fetches eligibility, and routes the
ICP through a per-contribution escrow before it reaches the campaign pool.
Regional participation eligibility (self-declaration). Each LGE carries a creator-configured list of countries excluded from participation (see § 1 Create LGE). Before purchasing, the participant confirms via a checkbox that they are not located in / not a resident of any country on the campaign’s current list — the confirmation text is generated from the live list (fetched immediately before rendering the checkbox and re-checked right before the transaction is submitted, never from a cached page-load value, because an adopted governance proposal can replace the list mid-campaign). This confirmation is a frontend gate: it enables the purchase action but is not recorded per-participation on-chain (the backend does not receive the checkbox signal, so materializing it as a canister field would imply an on-chain rigor the client-side check does not have). List changes are forward-only — a later replacement applies to participants from that point on and never retroactively affects earlier participations. A client-side hint resolved entirely from browser-local signals (the device timezone and locale, matched against an offline public-domain table bundled with the app — the flow is fully on-chain: no server and no third-party service is ever called) additionally informs visitors when participation from their detected region isn’t available for the launch; the hint is advisory only — the signed self-declaration is the binding record and the actual gate.
Cycles pause (DAO liveness gate). If a live LGE’s SONS governance canister
falls below its critical cycle line (max(1.2× its freeze reserve, 150B) —
monitored by the backend’s hourly fleet cycles sweep), the LGE temporarily
stops accepting contributions: the purchase preflight rejects with a stable
LGE_PAUSED_SONS_CYCLES_CRITICAL error before any fund movement (no ICP
leaves the buyer’s wallet for that attempt; an already-applied purchase retry
still returns its receipt). The pause protects the DAO’s own operational
liveness — its timer stops working below ~100B cycles — and clears as soon as
the canister is topped back up (anyone can top it up via
execute_cycles_topup): a rejected purchase triggers a fresh cycles probe at
most once per 5 minutes, so contributions resume within minutes of a top-up
rather than waiting for the next hourly sweep. The participation box shows the
paused state proactively, and the System Status page lists the affected DAO
with its cycle levels.
Per-contribution escrow (custody model). ICP moves Buyer → a per-contribution
backend escrow subaccount (deterministic, derived from the buyer + campaign +
amount + nonce) → forwarded net to the campaign’s pool_manager subaccount on
apply-OK, or refunded from that same escrow on apply-reject. The escrow is a
single-purchase, watertight compartment, so a refund can never touch another
in-flight purchase’s funds. Recovery is fully on-chain (idempotent sweep
methods + a durable per-contribution lifecycle); the [ICP_RECEIVED] /
[ICP_TRANSFER_RAW_OK] logs are observability only, never a recovery input —
there is no off-chain reconciler.
1.5.1 Sequence
Section titled “1.5.1 Sequence”sequenceDiagram participant U as User participant F as Frontend participant B as Backend (ohshii_launcher_backend) participant G as ohshii_governance participant L as ohshii_locker (lock_storage) participant S as dao_storage participant Ledger as ICP ledger participant OL as OHSHII ledger
U->>F: enter ICP amount, click "Participate" F->>F: read or generate client_nonce<br/>(localStorage, keyed on user+campaign+amount) opt Guest tier, first contribution F->>OL: icrc2_approve(backend, guest_fee_e8s ≈ 30,000 OHSHII + 0.01 ledger fee) end F->>B: purchase_tokens_icrc2(campaign_id, amount, sponsor?, client_nonce) B->>B: validate inputs + nonce shape B->>G: get_lge_eligibility(caller) G->>L: calculate_quadratic_voting_power(caller, OHSHII) L-->>G: VP G-->>B: { tier, effective_tier, max_purchase_limit_e8s, is_verified, is_active_voter, ... } B->>B: derive_contribution_id + escrow subaccount (length-prefixed, injective) B->>B: acquire per-contribution_id lock (RAII Drop) B->>S: lookup_contribution_status(contribution_id) alt Applied S-->>B: Applied(ContributionView { tokens_received, amount_icp_e8s, block_index }) B-->>F: Ok(TokenPurchaseResponse { ... block_index from storage }) else Rejected (durable tombstone) S-->>B: Rejected B-->>F: Err("already rejected and refunded — start a new purchase") — NO ICP moved else NotFound / Pending B->>B: resolve sponsor split + NET (before funding)<br/>(gated on is_verified from eligibility: unverified buyer ⇒ 0% sponsor, share stays with OhShii, no referral connection persisted); reject pre-payment if net ≤ ledger fee B->>S: record_contribution_pending(...) → fixed created_at_time B->>S: preflight_purchase(...) (fast-fail UX; tolerated on ICC error) opt guest tier, first contribution B->>S: try_mark_guest_fee_paid(campaign,user) (durable at-most-once marker + pinned ts) B->>OL: transfer_ohshii_guest_fee — icrc2_transfer_from(user → per-campaign guest-fee sub, net of the 0.01 OHSHII ledger fee) end B->>Ledger: icrc2_transfer_from(user → escrow(contribution_id), amount, FIXED created_at_time) Ledger-->>B: block_index (Duplicate on a deduped retry → escrow already funded) B->>B: store_critical_log [ICP_RECEIVED] (observability only) B->>S: add_contribution_atomic(..., net_forwarded_e8s, escrow_subaccount) Note over S: single CAMPAIGNS.borrow_mut: dedup → sale target<br/>→ minimum (collapses to the residual cost in the final-fill regime)<br/>→ sale-target ICP cap (amount ≤ remaining-supply cost + 0.001 ICP, else reject)<br/>→ tokens_received = curve(GROSS — the full ICP sent; exact fill ⇒ full remainder)<br/>→ per-user TOKEN limit → push row + advance tokens_sold<br/>+ persist net_forwarded/escrow + insert CONTRIBUTION_ID_INDEX alt Ok / AlreadyApplied S-->>B: Ok { tokens_received, total_tokens_sold, limited_by_sale_target } B->>Ledger: forward NET from escrow → pool_manager/campaign_subaccount<br/>(from_subaccount = Some(escrow); 3 legs: platform, isolated referral, net) B->>S: mark_contribution_forwarded(contribution_id) B-->>F: Ok(TokenPurchaseResponse { tokens_received, amount_paid }) else gate reject S-->>B: PurchaseLimitExceeded | SaleTargetReached | BelowMinimumPurchase | ExceedsRemainingSupply | CampaignNotActive B->>S: record_contribution_rejected(contribution_id) (durable tombstone) B->>Ledger: refund amount − fee from escrow → buyer opt first-time Guest (the OHSHII guest fee was charged this attempt) B->>S: try_mark_guest_fee_refunded(campaign,user) (NotOwed if still a participant; idempotent) B->>OL: refund_guest_fee_on_failure — icrc1_transfer (OHSHII guest fee back to user) end B-->>F: Err(message) end end F-->>U: purchase confirmed (or refunded)Sponsor attribution is verification-gated (anti-Sybil). The sponsor split is resolved only when the buyer is verified (
is_verified, already fetched atget_lge_eligibility). For an unverified buyer the sponsor share is 0% and stays with the OhShii treasury, and no referral connection is persisted on-canister — so unverified identities cannot farm referral storage or rewards. The check is at purchase time and is not retroactive. See SPONSOR_SYSTEM.md → Referral registration & verification gating.
Token amount is computed by storage, not by the backend. The backend passes the curve INPUT to
add_contribution_atomic; storage runsbonding_curve::calculate_tokens_from_icp_amountagainst the liveCampaign.tokens_sold/sale_supplyinside the sameborrow_mut. For every live campaign — both legacy (curve_input_net = None) and new escrow (curve_input_net = Some(false)) — the curve always prices on the gross ICP a buyer sends: the fullXICP is curve progress. The escrow still forwards the net ~90% to the campaign subaccount; the per-leg ICP ledger fees are absorbed by the 5% platform band, so the pool draws exactly its target —450 × kICP (450 at the 500-ICP default; the creator picks the raise500 × k,kin1..=10) — and the ICPSwap listing is450k / 200M = 225k e8s/token(225 at the default). Thecurve_input_netflag governs the curve input, the contributed-total accumulation, the finalize drain, and the refund basis together — no campaign is ever on a mixed basis. The net basis (curve_input_net = Some(true)) is retired (it broke the listing ratio) and no live campaign uses it.
Sale-target ICP cap + final fill (2026-07). A purchase may never pay more ICP than the curve cost of the entire remaining supply. Historically the tokens were clamped to the remainder while the full amount was kept (the “last buyer overpay” defect: 15 ICP charged for 1 token at the target); now any amount above
remaining_cost + 0.001 ICPis rejected — pre-payment bypreflight_purchase(ExceedsRemainingSupply, no ICP moves) and authoritatively byadd_contribution_atomic(tombstone + escrow refund) — with the exact cost still purchasable in the message (exact_cost_e8s). To keep 100% sold-out reachable, once the remainder costs less than the stage minimum the minimum collapses to the fill floor —remaining_cost − 0.001 ICP, saturating — (the final-fill regime): any amount inside the band[remaining_cost − 0.001, remaining_cost + 0.001]ICP is granted the whole remainder, so neither rounding nor a client-side quote that floors a few e8s below the canister’s exact cost can strand an unbuyable dust tail or deadlock the completion onBelowMinimumPurchase. The window (remaining cost, effective min/max, final-fill flag) is exposed read-only bydao_storage.get_purchase_window— it returnsnullfor a non-Active or sold-out campaign — and is computed by the same helper the two gates use; the frontend completion flow and the harness quote consume it directly, so quote and enforcement cannot drift. A final-fill whose amount exceeded the exact cost (by ≤ tolerance) setsTokenPurchaseResponse.limited_by_sale_target = true; the flag is persisted on the contribution row, so a deduped retry (AlreadyApplied) replays the same receipt. Money-trail bound:total_contributed_e8smatches the full curve cost within the tolerance plus per-row inversion slack (≤ ~0.001 ICP per campaign in practice).
1.5.2 What runs where
Section titled “1.5.2 What runs where”| Concern | Owner | How |
|---|---|---|
| Cycle floor + per-user rate limit + global rate limit + LGE toggle + relaunch block | Backend purchase_tokens_icrc2 | Synchronous pre-checks before any ICC |
Tier resolution + is_active_voter flag | ohshii_governance.get_lge_eligibility | 1 ICC; backend fail-safes to Human limit + Guest-fee-required on any error |
| Idempotency probe (was this contribution_id already applied?) | dao_storage.lookup_contribution_status (query) | 1 ICC query BEFORE the transfer. On Some, short-circuit with the cached receipt — no second ICRC-2 transfer. ICC errors propagate to the user as Err (paying on a glitched lookup is unsafe). |
| Token quote shown to the buyer | dao_storage.quote_purchase (query) | The canister that CHARGES also answers the quote, over the same bonding_curve module and the same live tokens_sold, so a quote and the purchase that follows it agree exactly rather than within a band. |
| Fast-fail UX (limit / target / minimum) | dao_storage.preflight_purchase (query) | 1 ICC query BEFORE the transfer. Same gates as the atomic apply. ICC errors are tolerated (the apply will re-enforce the gates atomically). |
| ICP transfer | Backend → ICP ledger | icrc2_transfer_from(caller → escrow(contribution_id), amount, FIXED created_at_time); the only money-moving await |
| Forensic ICP log | Backend store_critical_log | Single [ICP_RECEIVED] event with block_index, amount_e8s, contribution_id. Stable storage, survives upgrade. Observability only — on-chain escrow state (not the log) is the recovery source; there is no off-chain reconciler. |
| Atomic apply: dedup + limit + target + minimum + bonding curve + persistence + index | dao_storage.add_contribution_atomic (update) | All inside ONE CAMPAIGNS.with(borrow_mut). No .await between dedup check and CONTRIBUTION_ID_INDEX insert. Returns AlreadyApplied { ..., block_index } (storage-authoritative values) on a duplicate. |
1.5.3 The client_nonce contract
Section titled “1.5.3 The client_nonce contract”The frontend generates a UUIDv4 (crypto.randomUUID()) and persists it
in localStorage under a key derived from (user_principal, campaign_id, amount_icp_e8s). The same intent always rebuilds the same key, which
returns the same nonce, which produces the same contribution_id in the
backend.
Properties:
- F5 / tab reload mid-purchase: localStorage survives — the retry
rebuilds the same id and short-circuits at
lookup_contribution_status. - Real network error / timeout: caller retries with the same nonce → same id → short-circuits if storage already applied; pays once if not.
- User changes amount: localStorage key changes → fresh nonce → new intent (correct behavior).
- Two tabs, same intent: same key → both tabs read the same nonce →
whichever fires second hits
lookup_contribution_statusand short-circuits. Edge case: a true same-intent race is blocked at the backend by the per-contribution_idRAII lock, and any duplicate buyer→escrow transfer carries the fixedcreated_at_timepersisted on the pending marker, so the ICP ledger dedups it (returnsDuplicate) instead of debiting twice.
After a successful response, the frontend clears the localStorage entry so the next intent gets a fresh nonce.
1.5.4 Failure paths
Section titled “1.5.4 Failure paths”- Lookup ICC fails (transient network glitch on the query): backend
returns
Errto the user without paying. Frontend surfaces “retry later”; the next attempt with the same nonce succeeds when storage is reachable. - Preflight ICC fails: backend continues to the transfer. Storage’s atomic apply enforces the gates and rejects with a typed variant if the user is not eligible; on a reject the escrow is refunded.
- Apply rejects (policy gate): storage writes a durable rejection
tombstone and the backend refunds
amount − feefrom that contribution’s escrow to the buyer, inline. A same-nonce retry short-circuits on the tombstone — no re-pay, no fee bleed. - Trap/upgrade between escrow funding and apply: the ICP sits in the
deterministic per-contribution escrow. The fixed
created_at_timeon the pending marker means a retry’s transfer is deduped (no double debit), and the escrow balance is the authoritative recovery source —sweep_escrow_to_lge(forward) andsweep_escrow_refund(refund) are idempotent, on-chain, callable by the authorized canister (governance) and via self-call. No off-chain reconciler. - Trap between apply (counted) and forward: the contribution is counted but
its net still sits in the escrow;
sweep_escrow_to_lgerecovers it, and finalize reconciles any such stragglers into the campaign subaccount before draining. - dao_storage upgrade during the apply: ICC fails, backend returns
Err. User retries with the same nonce; the lifecycle status probe short-circuits if the original apply landed pre-upgrade, otherwise the atomic apply runs cleanly.
See LOGGING_SYSTEM.md for the [ICP_RECEIVED] / [ICP_TRANSFER_RAW_OK] event
schema. These latches are observability only — on-chain escrow state, not the
logs, is the recovery source.
1.5.5 Guest ICP-only participation (automatic OHSHII fee swap)
Section titled “1.5.5 Guest ICP-only participation (automatic OHSHII fee swap)”A Guest-tier participant pays a one-time OHSHII guest fee on their first
contribution (see GOVERNANCE.md § Voter Benefits Protocol).
Previously a Guest holding only ICP could not participate — they had to source
OHSHII manually first. The frontend now removes that dead end: it can auto-swap
ICP for exactly the missing OHSHII before the purchase, so a Guest can complete an
LGE contribution end-to-end with ICP alone. This is a frontend capability
(purchaseTokensWithAutoGuestFee in utils/canister.js) layered on top of the
unchanged backend purchase path — the backend still receives a normal
purchase_tokens_icrc2 call with the OHSHII guest fee already in the wallet.
The flow, when the caller opts in (autoSwapEnabled):
- Eligibility, fail-closed. Fetch
get_lge_eligibilityonce. If eligibility is unavailable the whole flow aborts (it never assumes Guest or non-Guest). - Shortfall check. Only Guests with a positive
guest_fee_e8sare affected. If the wallet already holds enough OHSHII (fee + ledger-fee headroom), skip straight to the purchase. Otherwise compute the exact OHSHII shortfall. - Quote + swap. Preview the ICP needed for the shortfall on the OHSHII/ICP
ICPSwap pool (
getIcpForOhshiiPreview, which already accounts for the pool’s auto-withdraw fee and adds an input safety margin), then swap (swapIcpForOhshii, a specialization of the generalizedswapIcpForTokeninutils/ohshiiSwap.js) with a minimum-out floor sized so the received OHSHII covers the fee. Guarantee: if the swap throws, no LGE purchase is made (the error is re-taggedstage: 'guest_fee_swap'and propagated — the flow never proceeds to spend ICP on the contribution). - Post-swap verification. Re-read the OHSHII balance; if it has not yet settled
to the required amount the flow aborts with
GUEST_FEE_NOT_SETTLED— no purchase, no fee charged — and the user retries in a few seconds. - Purchase. Only after the OHSHII is confirmed in the wallet does the flow
delegate to the normal
purchaseTokensIcrc2path (which re-fetches eligibility and performs the Guest-fee approve + ICP approve + Shield-wrapped purchase from §1.5.1).
Recovery. The swap uses ICPSwap’s deposit → swap → withdraw sequence, whose
output withdraw is queued asynchronously by the pool (an ok from the pool
means the transfer was enqueued, not that it has settled — arrival is confirmed by a
ledger balance change). If a swap partially executes — the ICP deposit lands but the
swap or the auto-withdraw does not — the funds sit in the OHSHII/ICP pool as the
user’s in-pool unused balance (or in their pool deposit subaccount), never lost.
recoverStuckPoolFunds(identityOrAgent, poolCanisterId, userPrincipalText) in
utils/ohshiiSwap.js sweeps both (deposit-subaccount rescue, then unused-balance
withdraw) back to the user’s main account.
2. Create Token
Section titled “2. Create Token”The user pays 5 ICP via ICRC-2; the backend verifies the payment and deploys the token ledger and index canisters.
sequenceDiagram participant User participant Frontend participant ICPLedger participant Backend
User->>Frontend: Create token (metadata, payment) Frontend->>Backend: prepare_token_creation() Backend-->>Frontend: payment_info (amount, expiry, subaccount) User->>ICPLedger: icrc2_approve(backend, amount) Frontend->>Backend: create_token_with_icrc2_payment(args) Backend->>ICPLedger: icrc2_transfer_from (collect) Backend->>Backend: allocate funds, deploy ledger + index Backend-->>Frontend: Ok(ledger_id, index_id) Frontend-->>User: Token createdReference: src/backend/src/lib.rs — prepare_token_creation, create_token_with_icrc2_payment.
3. Vote
Section titled “3. Vote”The user selects a DAO (OHSHII → ohshii_governance, or a SONS token → that campaign’s sons_governance), then votes on a proposal. The governance canister’s timer handles expiry and auto-execution.
flowchart LR User[User] --> Select[Select DAO] Select --> ONS[OHSHII] Select --> SONS[SONS token] ONS --> PM[ohshii_governance] SONS --> IG[sons_governance] PM --> Vote[vote] IG --> Vote Vote --> VP["Voting power quadratic token-based"] Timer["Timer expiry and auto-execute"] --> PM Timer --> IG- ONS: Voting power from OHSHII governance voting power (quadratic function of locked OHSHII managed by ohshii_governance/locker). Lock points are not used for voting. The ohshii_governance timer runs expiry and execution.
- SONS: Voting power from the LGE campaign token (quadratic voting based on that DAO’s locks — both LGE vesting locks and user-created manual
create_additional_locklocks — held in that sons_governance); each sons_governance has its own timer. - Cap behavior (both ONS and SONS): cap applies to the aggregated VP of all eligible locks owned by the same principal (not only per single lock). Example with cap
36,000: if a user has36,000VP from one lock and4,000VP from another, displayed/votable VP remains36,000. - Vote delegation (ONS only): verified ONS voters can delegate their vote to another verified principal. When the delegate casts a vote, the same ohshii_governance timer that handles expiry also drains a delegation queue and applies each follower’s vote with their own snapshot voting power. Delegation never crosses into SONS proposals. See LIQUID_DEMOCRACY.md for the follow/unfollow lifecycle, the accept-followers toggle, the dismiss-follower flow, the cascade architecture, and the security model.
Reference: OnsVotingPage.jsx (DAO selector, vote calls, Following tab + Voting Power delegation surfaces), ohshii_governance and sons_governance (vote, timer, execute).
4. Create Proposal
Section titled “4. Create Proposal”The user chooses the DAO, then the proposal category and payload (e.g. ExecuteAdminMethod with target canister and method). For ONS, categories include UpgradeCanister, UpgradeGovernanceCanister, ExecuteAdminMethod, CriticalGovernanceOperation, etc. For SONS, categories include ExecuteAdminMethod, UpgradeCanister, UpgradeGovernanceCanister, CriticalGovernanceOperation, UpgradeAssetCanister, etc. Creation may require a proposal fee (and for upgrades, WASM upload).
flowchart TB User[User] --> DAO[Select DAO: OHSHII or SONS] DAO --> Cat[Choose category and method] Cat --> Pay[Payload: target, args, optional WASM] Pay --> Fee[Proposal fee / upload] Fee --> Create[create_proposal] Create --> PM[ohshii_governance] Create --> IG[sons_governance] PM --> StoredONS[Stored ONS proposal] IG --> StoredSONS[Stored SONS proposal]Reference: CreateProposalForm.jsx, GOVERNANCE.md for full category and method lists.
5. Execute Proposal: Backend WASM Upgrade vs Frontend (Asset) Upgrade
Section titled “5. Execute Proposal: Backend WASM Upgrade vs Frontend (Asset) Upgrade”Execution of approved proposals differs by target type.
5.1 Backend (Rust) canister upgrade
Section titled “5.1 Backend (Rust) canister upgrade”Voting tier: every canister upgrade is Critical — the standard backend upgrade (
UpgradeCanister) and the governance self-upgrade (UpgradeGovernanceCanister) both run the critical pipeline (fixed 7-day window, critical quorum/approval bar, guardian veto with override round), the same as the batch upgrades in §5.3/§5.4. See GOVERNANCE.md.
The executor (ohshii_governance or sons_governance) calls the IC management canister to install the new WASM on the target. For WASM under the message size limit, install_code is used; for larger WASM, chunks are uploaded and install_chunked_code is used.
sequenceDiagram participant Gov as ohshii_governance or sons_governance participant Mgmt as Management Canister participant Target as Target Rust canister
Gov->>Gov: Revalidate WASM hash load session alt WASM under 2 MiB Gov->>Mgmt: install_code mode Upgrade canister_id wasm_module Mgmt->>Target: Upgrade else WASM 2 MiB or more chunked Gov->>Target: upload_chunk repeated Gov->>Mgmt: install_chunked_code canister_id chunk_store_id hashes Mgmt->>Target: Upgrade end Mgmt-->>Gov: OkSize ceiling. A governance canister accepts an upload of at most 20 chunks, and a chunk of at most 1 000 000 bytes, so the largest module either canister will take is 20 000 000 bytes (~19.1 MiB). The ceiling is the product of those two numbers rather than a separate constant, so it cannot drift from them. It is the same on both governance canisters and for every proposal shape — single-target and batch alike — and the frontend refuses an oversized file before any chunk is sent, so the limit is reported at selection time rather than part-way through an upload. Modules are uploaded gzipped (which is also the form installed), so the ceiling applies to the compressed artefact: the shipped governance module is a small fraction of it.
Export scan. Before a module can be installed it is scanned on-chain for method exports whose name is reserved for development builds. The scan runs twice: once when the proposal is created, and again immediately before execution, because a passing proposal may sit for its full voting window before it is executed. Both governance canisters run the identical scan, it covers modules embedded inside the uploaded module as well as the module itself, and the scan is not optional on any install path — a module it refuses cannot be installed by any proposal type. A module the scanner cannot read is never reported as clean.
Reference: ohshii_governance/execution.rs — execute_upgrade_standard, execute_upgrade_chunked; sons_governance equivalent execution paths.
5.2 Frontend (asset) canister upgrade
Section titled “5.2 Frontend (asset) canister upgrade”Voting tier: the frontend (asset) upgrade (
UpgradeAssetCanister) is also Critical — fixed 7-day window, critical quorum/approval bar, guardian-vetoable. See GOVERNANCE.md.
The target is an IC asset canister. The flow uses the asset canister’s batch API: the proposer uploads a batch and proposes it; at execution time the governance canister validates and commits the batch. No install_code is involved.
sequenceDiagram participant Proposer participant Asset as Asset canister participant Gov as ohshii_governance or sons_governance
Note over Proposer,Asset: Before execution: create_batch, upload chunks, propose_commit_batch, compute_evidence Gov->>Asset: validate_commit_proposed_batch(evidence) Asset-->>Gov: ValidationResult (valid / invalid) alt valid Gov->>Asset: commit_proposed_batch(evidence) Asset->>Asset: Apply batch (new assets / frontend content) Asset-->>Gov: Ok else invalid Gov-->>Gov: Execution failed endReference: ohshii_governance/execution.rs — execute_asset_upgrade (validate_commit_proposed_batch then commit_proposed_batch); CreateProposalForm.jsx — upgrade_frontend / Upgrade Asset flow.
Proposed-batch lifecycle and automatic cleanup. An asset canister allows at most one proposed batch at a time, and a proposed batch never expires upstream — while one exists, create_batch (and therefore any new staging) fails. A successful execution consumes the batch; every other terminal outcome (rejected, expired, veto-upheld, execution-failed) would otherwise leave it blocking all future frontend upgrades. On both ONS and SONS, cleanup is automatic: each non-executed terminal transition schedules a delete_batch on the target, retried by the governance timer with exponential backoff; batches staged but never attached to a proposal are discovered and removed by an orphan probe (triggered by the abandoned-upload-session sweep and by the next prepare_asset_upload); a batch referenced by a still-live proposal is never deleted, and creating a proposal against a batch already scheduled for cleanup is refused (re-stage a fresh batch). Pending work is public via the get_pending_asset_cleanups query; persistent failure surfaces as an asset_batch_cleanup_gave_up event, with guardian_delete_asset_batch as the guardian/admin fallback. (SONS holds no asset permission by default but controls its campaign frontends, so the drain grants itself Commit on the target the first time it needs it.)
5.3 Ledger-suite batch upgrade (UpgradeCanisterBatch)
Section titled “5.3 Ledger-suite batch upgrade (UpgradeCanisterBatch)”One proposal upgrades a token’s whole ledger suite — index, ledger, and archive canisters — in the canonical safe order. Both DAOs support it: ONS for suites it controls (e.g. the OHSHII ledger suite), SONS for the campaign token’s suite. The proposal category is UpgradeCanisterBatch (Critical, guardian-vetoable — see GOVERNANCE.md).
Why one ordered batch. The index pulls blocks from the ledger and the ledger pushes blocks to its archives, so the safe upgrade order is Index → Ledger → Archive(s): the consumer must understand the producer’s new format before the producer emits it. The order is hard-validated at creation and the executor runs the voted step list exactly as written. The Ledger step is mandatory (it anchors the suite); the Index step is optional (recommended when the suite maintains one — an old index pulling from a newer ledger may stop syncing until it is upgraded too); archives are listed explicitly by the proposer and cross-checked against the ledger’s archives() interface when it answers.
Create → upload → vote → execute:
- Create —
create_proposalwith aCanisterUpgradeBatchtarget validates the suite shape (role order, 32-byte hashes, sizes, chunking, no self-target, suite probes) and collects one standard proposal fee for the whole batch (pre-staged paid sessions are accepted per step; their payment is reused, never double-charged). For every step without a pre-staged session the canister allocates an upload session; the proposal starts inPendingUpload. - Upload — the frontend reads the proposal back to learn the per-step session ids, then uploads each WASM in chunks. The batch upload window is 15 minutes (longer than the single-target window: up to five 20 MiB modules ≈ 100 chunk calls). On each session’s final chunk the assembled hash is checked against that step’s declared hash. When every step is staged, the proposal flips
PendingUpload → Open. - Vote — the standard CRITICAL pipeline: fixed 7-day window, critical quorum/approval bar, guardian veto with override round.
- Execute — pre-flight first: the controller gate is all-or-nothing — if governance does not control even one target, nothing is installed. Then the steps run sequentially, in the voted order, stop-on-first-error. Per step: idempotent skip if the live module hash already equals the step hash, staged-hash revalidation (TOCTOU),
install_code/install_chunked_code, post-install hash verify, then the step result is persisted before the next step begins. Per-step progress lives in the proposal’sbatch_step_results(Pending/Started/Skipped/Executed/Failed) and is rendered step-by-step on the proposal card.
No-arg upgrades. A step without an explicit upgrade argument sends the canonical empty Candid tuple, never raw empty bytes — stock ledger and index canisters decode it as “no change”, while raw empty bytes would trap their post_upgrade. Index steps may alternatively set a typed sync-interval convenience field instead of hand-encoding an argument.
Partial failure and recovery. There is no automatic rollback — downgrading a ledger to an earlier release is not supported upstream, so a rollback lever would be a second incident, not a fix. A failed step leaves the suite mid-upgrade (a state the ordered procedure explicitly tolerates): the proposal ends ExecutionFailed with the per-step record, and recovery is fix-forward — submit a fresh batch proposal for the same suite; steps whose live module hash already matches auto-skip, so only the uncommitted steps re-run.
Release discipline (operator checklist). Pin one ledger-suite release per batch; never skip releases (sequential-version enforcement is built into the official ledger WASMs and an install of a skipped-ahead version fails); keep the whole suite on one version before moving to the next release.
sequenceDiagram participant Proposer participant Gov as ohshii_governance or sons_governance participant Mgmt as Management Canister
Proposer->>Gov: create_proposal CanisterUpgradeBatch (ordered steps) Gov->>Gov: Validate order, hashes, suite probes; one fee; N sessions Note over Proposer,Gov: PendingUpload — 15 min window Proposer->>Gov: upload chunks per step session Gov->>Gov: All steps staged → Open Note over Gov: 7-day critical vote (guardian-vetoable) Gov->>Gov: Pre-flight: controller check ALL targets (all-or-nothing) loop each step in voted order Gov->>Mgmt: canister_info (skip if hash already matches) Gov->>Mgmt: install_code / install_chunked_code Gov->>Mgmt: verify installed module hash Gov->>Gov: persist batch_step_results[k] end alt a step fails Gov->>Gov: ExecutionFailed + per-step record (remaining steps Pending) Note over Proposer,Gov: Recovery: fresh batch — committed steps auto-skip endReference: ohshii_governance/execution.rs / sons_governance/execution.rs — execute_canister_upgrade_batch; GOVERNANCE.md for the ONS and SONS category blocks.
5.4 Dapp batch upgrade (UpgradeDappBatch)
Section titled “5.4 Dapp batch upgrade (UpgradeDappBatch)”One proposal upgrades an arbitrary ordered set of canisters — 1–10 steps, mixing code installs and asset (frontend) commits — in a single vote. Both DAOs support it: ONS for any OhShii canister it controls (the launcher fleet, the locker fleet, the quests canisters, or any custom principal) plus its own governance canister as a final self-step; SONS for the campaign frontend, custom targets, and its own governance canister. The proposal category is UpgradeDappBatch (Critical, guardian-vetoable — see GOVERNANCE.md). This is the general dapp-release lane; the ledger-suite path (§5.3) stays specialized for a token’s money rails.
Why proposer order, executed verbatim. Unlike a ledger suite, a dapp release has no protocol-fixed order: the safe sequence is release-specific (a storage-before-backend release and its reverse are both legitimate). So the chain validates step shape, not release semantics, and the executor runs the voted step list exactly as written — the DAO votes on the literal order it sees, with no executor reordering. The UI nudges a code → asset → self order (asset commits are not idempotent, so committing them last minimizes the chance a code-step failure strands a consumed asset commit; the self-step is forced last anyway), but the order is the proposer’s and reviewers’ responsibility.
Create → stage → vote → execute:
- Create —
create_proposalwith aDappUpgradeBatchtarget validates the shape: 1–10 steps, pairwise-distinct targets, self-step-last (and only if its kind is code), per-payload shape, category rules (ONS: Core = a known OhShii canister; SONS: Core = the governance canister itself only — every other target is Custom), per-step provenance (git_repo_url+git_commit_hash+build_instructions, all required non-empty), and for each asset step avalidate_commit_proposed_batchprobe on the target. It collects one proposal fee for the whole batch — pre-staged paid sessions are reused and never double-charged; each asset step additionally rides its own already-paidprepare_asset_uploadstaging on its target. For every code step without a pre-staged session the canister allocates an upload session; the proposal starts inPendingUpload. - Stage — the frontend reads the proposal back to learn the per-code-step session ids and uploads each WASM in chunks (the 30-minute upload window). Asset steps consume no upload session — their bytes already live on the target asset canister from the
prepare_asset_uploadstaging done before creation. When every code step is staged, the proposal flipsPendingUpload → Open(a pure-asset batch landsOpenimmediately). - Vote — the standard CRITICAL pipeline: fixed 7-day window, critical quorum/approval bar, guardian veto with override round.
- Execute — pre-flight first: the controller gate is all-or-nothing over EVERY target — asset targets included — if governance does not control even one target, nothing is installed and nothing is committed. Pre-flight also re-validates each code step’s staged hash, re-runs each asset step’s
validate_commit_proposed_batch(TOCTOU — the staged batch may have changed during the vote), and checks the per-target cycles floor. Then the steps run sequentially, in the voted order, stop-on-first-error, dispatched by kind: a code step revalidates the staged hash, runsinstall_code/install_chunked_code, and verifies the installed module hash; an asset step runscommit_proposed_batch. Per-step progress lives in the proposal’sbatch_step_results(Pending/Started/Skipped/Executed/Failed) and is rendered step-by-step on the proposal card.
Two proofs — verify each step by its kind. A dapp batch carries two different artifact kinds, each with its own proof:
- Code steps are verified by MODULE HASH: reproducibly build from the cited repo + commit (per the step’s
build_instructions), compare the result against the step’s declaredwasm_module_hash, and after execution confirm withdfx canister info <target> --network ic. - Asset steps are verified by EVIDENCE: rebuild the frontend from the cited commit, run the asset canister’s compute-evidence flow, and compare the evidence byte-for-byte against the step’s declared evidence. Two ways to compute it, and you do not need
dfxfor this:cargo run --release --manifest-path tools/compute-evidence/Cargo.toml -- <asset-canister-id> ./dist— in-repo, no dfx, read-only as the anonymous caller, and it calls the sameic_asset::compute_evidencethe other route does.dfx deploy <asset-canister> --network ic --compute-evidence— equivalent, and the only route if you already have dfx and prefer it. (icp-cli has no compute-evidence command as of 1.3.0, so this is the one place dfx is still the CLI answer.) ⚠ Evidence is a delta against the target canister’s current state, so point either tool at the REAL canister the proposal targets — computing it against any other canister yields a different hash by construction.
Two different proofs — the proposal card states which one applies to which step. Custom-category steps (a target that is not a known OhShii core canister) render with a warning treatment: verify the principal and the provenance with extra care, since no known-canister name backs them.
Asset recovery caveat (no rollback). There is no automatic rollback. A failed batch ends ExecutionFailed with the per-step batch_step_results record; recovery is a fresh batch proposal. On recovery the kinds differ:
- Code steps auto-skip — a step whose live module hash already equals its declared hash is marked
Skipped, so only the uncommitted code steps re-run. - Asset steps never auto-skip — a committed asset batch id is CONSUMED: a recovery proposal that re-carries it is rejected at creation (the asset re-validate ICC fails, loudly, pre-vote) and would be re-rejected at execution pre-flight. A recovery batch must therefore either OMIT the already-committed asset steps, or re-stage a fresh asset batch (identical content ⇒ identical evidence, but a new batch id) when the commit genuinely did not land. The partial-failure banner states this on the proposal card.
Self-step. A step targeting the governance canister itself is allowed only as the final step. When the executor reaches it, it write-aheads Started, sets the pending self-step record, and installs on self — on success the install never returns and the step is completed by the new wasm’s post-upgrade hook (the proposal is marked Executed only after the self step’s batch_step_results entry is finalized); a failed self-install delivers an error, clears the pending record, and fails the batch normally (fix-forward applies, with the live-hash skip protecting against a double install on recovery).
sequenceDiagram participant Proposer participant Gov as ohshii_governance or sons_governance participant Mgmt as Management Canister participant Asset as Asset canister (per asset step)
Proposer->>Gov: create_proposal DappUpgradeBatch (ordered mixed-kind steps) Gov->>Gov: Validate shape, category, provenance; per-asset-step validate ICC; one fee; sessions per code step Note over Proposer,Gov: PendingUpload — 30 min window (asset steps pre-staged) Proposer->>Gov: upload chunks per code-step session Gov->>Gov: All code steps staged → Open Note over Gov: 7-day critical vote (guardian-vetoable) Gov->>Gov: Pre-flight: controller check ALL targets incl. asset (all-or-nothing) + asset re-validate loop each step in voted order alt code step Gov->>Mgmt: live module hash (skip if already matches) Gov->>Mgmt: install_code / install_chunked_code Gov->>Mgmt: verify installed module hash else asset step Gov->>Asset: commit_proposed_batch(batch_id, evidence) end Gov->>Gov: persist batch_step_results[k] end alt a step fails Gov->>Gov: ExecutionFailed + per-step record (remaining steps Pending) Note over Proposer,Gov: Recovery: fresh batch — committed code steps auto-skip; committed asset steps do NOT (omit or re-stage) endReference: ohshii_governance/execution.rs / sons_governance/execution.rs — execute_dapp_upgrade_batch; GOVERNANCE.md for the ONS and SONS category blocks.
5.5 Proposal fee → deposit settlement lifecycle (every outcome)
Section titled “5.5 Proposal fee → deposit settlement lifecycle (every outcome)”Creating a proposal pulls a creation fee (ICRC-2 transfer_from) into a
dedicated per-proposal ESCROW SUBACCOUNT of the governance canister, derived
injectively from the proposal id (SHA-256("ohshii-proposal-fee:v1:" ‖ proposal_id_be8); the proposal id is allocated BEFORE the fee pull so the
escrow and the payment memo carry the real id). The deposit is snapshotted on
the proposal as fee_paid + fee_subaccount + fee_policy_v2 = true,
together with the rejection_cost and the voting parameters active at
creation. Session-paid proposals (upgrade/asset flows whose fee was pulled
at prepare_paid_upload time, before a proposal id exists) keep main-account
custody (fee_subaccount = None) under the same outcome policy. A 300-second
resolution timer drives every proposal to a terminal state and settles the
deposit according to the outcome — so the amounts are dynamic per DAO (they
follow whatever proposal_creation_fee / rejection_cost the DAO had voted at
creation).
Deposit settlement per outcome (net of the ledger transfer fee):
| Outcome | Settlement | Notes |
|---|---|---|
| Executed, or Approved for a signal-only proposal (ONS Motion; SONS Motion/UpdateCampaign) | Deposit → treasury (escrow residual − ledger fee) | The fee of a successful governance action belongs to the DAO. ONS forwards to the OhShii backend treasury; SONS settles escrow → its own main account (the SONS treasury). |
| Approved, executable (execution still pending) | Nothing yet | Transient — settling early would make a later ExecutionFailed refund unpayable (the escrow would already be empty). The deposit stays in escrow until Executed / ExecutionFailed. |
| ExecutionFailed | Full refund (fee_paid − ledger_fee) from the escrow | A trapped execution is not the proposer’s fault. |
Rejected / Expired (an Open proposal that missed quorum) | Partial refund (fee_paid − rejection_cost − ledger_fee) from the escrow; the withheld residue settles to the treasury | Rejection-cost destination: OhShii backend treasury on ONS; retained in the governance canister (main account) on SONS. |
Abandoned paid upload (a PendingUpload proposal whose upload never completes and expires) | Forfeit (no refund) | A refund here would be abusable; the fee is forfeited and a FeeForfeited audit event is emitted. The upload session expires on its TTL. |
Vetoed / VetoOverrideExpired (non-terminal) | resolves to the final Approved/Rejected outcome | A guardian veto opens an override round; the timer finalizes the proposal — Vetoed is never a terminal state of its own. |
Legacy proposals (created before the escrow upgrade — fee_policy_v2
absent) settle under the pre-escrow policy byte-identically:
Approved/Executed/ExecutionFailed → full refund and Rejected/Expired → partial
refund, all paid from the main account.
Liveness & recovery.
- The resolution timer is always armed; each tick runs expire → execute approved → reap stuck-
Executing→ process settlements, so a deposit is settled in the same tick the proposal reaches its terminal state. - A proposal that traps mid-execution and is stuck in
Executingpast a generous TTL (and is not awaiting a live self-upgrade) is reaped toExecutionFailedand refunded in full. claim_proposal_refund(proposal_id)— a public, proposer-only fallback on both canisters: if the automatic settlement fails repeatedly (ledger outage, trapped tick), the proposer drains their own owed refund out-of-band. It pays exactly what the timer would pay, from the same escrow, with the same pinnedcreated_at_time(a re-claim of an already-committed refund is an exact ledgerDuplicate— never a second payment) and the same one-shotrefund_processedflag. Anonymous and non-proposer callers are rejected by cheap synchronous checks before any inter-canister call; a per-proposer 60 s cooldown and the global refund guard bound the honest path. An executed proposal’s deposit is not claimable (it belongs to the treasury). The full deposit lifecycle is queryable viaget_proposal_fee_status(proposal_id)and surfaced on the proposal card in the UI (“Deposit refund pending” + Claim button for the proposer).- The guardian can call
force_process_refundsto drain any owed settlements/execution out-of-band without waiting for the timer — a maintenance lever that is idempotent and cannot change a vote, an outcome, or a DAO-voted parameter. - When a treasury swap or add-liquidity proposal fails and leaves tokens stranded in the ICPSwap pool (unused balance, or a transferred-but-not-deposited deposit subaccount), the guardian can call
guardian_claim_pool_fundsto sweep them — both tokens, both reclaim scenarios, live fees — back to the executing canister’s own treasury. The destination is fixed in code (the guardian cannot name a recipient), it is idempotent, and it fails closed while a treasury op is mid-flight on that pool. On ONS the guardian selects which canister holds the funds (Backendfor a failed swap →pool_managerfor failed liquidity); on SONS the campaign governance canister recovers its own position. Admins and ONS proposals can also trigger the underlyingsweep_pool_unused_balancedirectly. See the guardian capability table in GOVERNANCE.md.
Safety properties.
- Watertight custody — the escrow derivation is injective (fixed-width proposal id under a frozen domain separator), so distinct proposals can never share an escrow; every settlement leg is bounded to that one compartment (
from_subaccount = fee_subaccount) and can never draw on the treasury float or another proposal’s deposit. - No double payment — every leg (refund, treasury forward, rejection-cost residue) uses a deterministic
created_at_timepersisted on its first attempt (refund_created_at_time/treasury_created_at_time), and every amount-driven leg also pins the ledger fee on its first attempt (refund_fee_pinned/treasury_fee_pinned— the ledger dedup key includes the fee arg, so a live fee re-read after a ledger fee change would miss the dedup and double-pay a landed-but-lost transfer), so any retry is an exact ledgerDuplicatemapped to success; the per-proposalrefund_processedflag is the one-shot guard, and the escrow-residual treasury sweep is balance-driven (an already-swept escrow reads ~0 → idempotent no-op). The timer scan andclaim_proposal_refundare serialized by a shared RAII refund guard (pure Drop-released singleton on BOTH canisters — no stale-takeover window). A stale pin (a >24h ledger outage →TooOld, or a ledger fee change mid-settlement →BadFee) fails closed and self-recovers on the escrow-custody legs only: the refund leg re-pins after verifying the escrow still holds the full deposit (a landed refund leaves it short), and the balance-driven treasury sweep re-pins unconditionally (a landed original drained the escrow, so the retry no-ops). Main-custody legs (legacy / session-paid) never auto-re-pin — with no compartment to verify against, a stale pin there stays wedged (funds safe in the main account) and needs an operator-verified intervention. - No charge for a failed creation — all validation (and, for upload proposals, the global + per-proposer session-slot capacity checks) runs before the fee, so a proposal that fails to create never collects a fee. (A creation that fails after the fee-await burns a proposal id — a harmless gap in the sequence.)
- Auditability —
FeeCollected,RefundProcessed,RefundFailed,FeeForwardedToTreasury,FeeForwardFailed,FeeForfeitedandRejectionCostForwarded(/Failed)events are written to the stable event log, so a proposal’s full money lifecycle is reconstructable.
flowchart TB Create["create_proposal: allocate id → fee into per-proposal escrow"] --> Live["Open / PendingUpload"] Live --> Timer{"300s resolution timer"} Timer -->|quorum + pass| Approved Timer -->|quorum + fail| Rejected Timer -->|no quorum from Open| Expired Timer -->|upload abandoned, from PendingUpload| Forfeit["Expired (abandoned upload)"] Approved -->|executable| Exec{execute} Approved -->|signal-only Motion| Treas Exec -->|ok| Executed Exec -->|trap / fail| Failed["ExecutionFailed"] Stuck["stuck Executing > TTL"] -->|reaper| Failed Executed --> Treas["Deposit → treasury (escrow − ledger_fee)"] Failed --> FullR["Full refund: fee − ledger_fee (from escrow)"] Rejected --> PartR["Partial: fee − rejection_cost − ledger_fee (from escrow); residue → treasury"] Expired --> PartR Forfeit --> NoR["Forfeit + FeeForfeited"] FullR -.->|auto fails?| Claim["claim_proposal_refund (proposer fallback)"] PartR -.->|auto fails?| ClaimReference: ohshii_governance/timer.rs (process_refunds, stale-Executing reaper) / sons_governance/lib.rs (process_refunds); GOVERNANCE.md for guardian capabilities and the rejection-cost destination.
6. Self-Snapshot: Backend as Orchestrator
Section titled “6. Self-Snapshot: Backend as Orchestrator”When the snapshot target is the governance canister itself (ohshii_governance or sons_governance), the canister cannot perform stop → snapshot → start on itself: a canister that calls stop_canister(self) would never complete the call (deadlock). So both governance canisters delegate the stop/snapshot/start workflow to the ohshii_launcher_backend, which acts as orchestrator.
6.1 Technical constraint
Section titled “6.1 Technical constraint”- Only a controller of a canister can call
stop_canisterandstart_canisteron it. - If the target is the caller itself, the call to
stop_canister(self)does not return; the canister stops before responding. - Therefore the workflow must be run by another canister that is a controller of the target. The backend is that orchestrator.
6.2 OHSHII Governance (ONS) self-snapshot / self-restore
Section titled “6.2 OHSHII Governance (ONS) self-snapshot / self-restore”The ohshii_governance is already deployed with the backend as one of its controllers. When the snapshot/restore target is the ohshii_governance itself:
- ohshii_governance does not call stop/snapshot/start itself.
- It sends an async request (
notify) to the backend:backend_orchestrate_snapshot_workfloworbackend_orchestrate_restore_workflowwithtarget = selfandcleanup_backend_controller_after_start = false. - The backend (already a controller) runs: stop → snapshot or load_snapshot → start.
- No controller change is needed; the backend remains a controller.
sequenceDiagram participant PM as ohshii_governance participant Backend as ohshii_launcher_backend participant Mgmt as Management Canister
PM->>Backend: notify backend_orchestrate_snapshot_workflow(self, replace_id, None, false) Backend->>Mgmt: stop_canister(PM) Mgmt->>PM: Stopped Backend->>Mgmt: take_canister_snapshot(PM) Backend->>Mgmt: start_canister(PM) Mgmt->>PM: Running Note over Backend: cleanup_backend_controller = false, no changeReference: ohshii_governance/canister_mgmt.rs — admin_create_snapshot_with_workflow, admin_restore_snapshot_with_workflow (self-target branch).
6.3 LGE Governance (SONS) self-snapshot / self-restore
Section titled “6.3 LGE Governance (SONS) self-snapshot / self-restore”The sons_governance canister is not created with the backend as a controller. So for self-target snapshot or restore:
- Governance adds the OhShii backend as a temporary controller (so the backend can stop/start the governance canister).
- Governance calls the backend via notify:
backend_orchestrate_snapshot_workfloworbackend_orchestrate_restore_workflowwithtarget = selfandcleanup_backend_controller_after_start = true. - The backend runs: stop → snapshot or load_snapshot → start.
- After start, the backend removes itself from the governance canister’s controllers (so the backend does not remain a controller).
This temporary-controller pattern is required so that (a) the backend can perform stop/start on the governance canister, and (b) the backend does not retain control after the workflow. The Create Proposal form shows the target canister when creating a snapshot proposal; when the target is the current governance canister (SONS), this backend temporary-controller flow is used and can be explained in the UI.
sequenceDiagram participant IG as sons_governance participant Mgmt as Management Canister participant Backend as ohshii_launcher_backend
IG->>Mgmt: update_settings: add Backend as controller Mgmt-->>IG: Ok IG->>Backend: notify backend_orchestrate_snapshot_workflow(self, replace_id, None, true) Backend->>Mgmt: stop_canister(IG) Mgmt->>IG: Stopped Backend->>Mgmt: take_canister_snapshot(IG) Backend->>Mgmt: start_canister(IG) Mgmt->>IG: Running Backend->>Mgmt: update_settings: remove Backend from IG controllers Mgmt-->>Backend: OkReference: sons_governance/src/lib.rs — execute_create_snapshot / execute_restore_snapshot (self-target: ensure_controller_present(self_id, backend) then notify with cleanup_backend_controller_after_start: true); backend/src/lib.rs — backend_orchestrate_snapshot_workflow, backend_orchestrate_restore_workflow, backend_remove_self_controller_from_target when the cleanup flag is true.
7. LGE Finalization (vesting, pool, controllers)
Section titled “7. LGE Finalization (vesting, pool, controllers)”LGE finalization is handled by the backend via finalize_ico_logic(campaign_id), which may be triggered internally (for example from housekeeping or liquidity checks) or, on a bootstrap/test path, via the finalize_ico update method. The flow below focuses on the successful path (sold-out or retry from Finalizing/Frozen). If the campaign did not reach the token target, the backend marks it Failed and does not create a pool; see § 8. Campaign failure and refunds.
sequenceDiagram participant Backend participant Storage participant PoolMgr participant Gov participant Mgmt
Backend->>Backend: finalize_ico_logic Backend->>Storage: get_campaign Backend-->>Backend: validate state and success conditions Backend->>Storage: set state Finalizing (STATUS-ONLY write — does not clobber tokens_sold/contributions) Backend->>Backend: reconcile unforwarded escrows → sweep stragglers into campaign subaccount alt Pool already exists Backend-->>Backend: compute liquidity ICP — min(fixed target ~90% of raise, balance − buffer)<br/>(the balance-driven branch needs curve_input_net=Some(true), which nothing live carries) Backend->>PoolMgr: transfer_from_subaccount Backend->>PoolMgr: add liquidity to existing pool else No pool yet Backend->>PoolMgr: store_campaign_subaccount Backend-->>Backend: compute liquidity ICP — min(fixed target ~90% of raise, balance − buffer)<br/>(the balance-driven branch needs curve_input_net=Some(true), which nothing live carries) Backend->>PoolMgr: transfer_from_subaccount Backend->>PoolMgr: create pool and add liquidity Backend->>Storage: update_campaign with pool_id end Note over Backend: On pool errors campaign set to Frozen; on a failed campaign the Failed flip is also STATUS-ONLY Backend->>Backend: execute_post_finalization_steps Note over Backend,Gov: seal-probe (probe_sons_finalized) — if SONS already finalized, skip the pre-seal steps (vesting/1.5/2/2.5/A/B) and jump to STEP C verify; the whole flow is idempotent + convergent on a Frozen retry alt Delayed unlock enabled Backend->>Gov: STEP 1 batch_create_vesting_locks (idempotent — "already created" converges) Gov->>Gov: store INACTIVE vesting locks, each with its own custody subaccount (custody_funded = false) · queue one funding op per lock Backend->>Storage: set governance_vesting_enabled else No delayed vesting Note over Backend,Gov: skip vesting lock creation end Note over Gov: SONS timer (async, after finalize) funds each lock's custody subaccount (main → subaccount) and later pays unlocks FROM that subaccount — the governance main account stays treasury-only Backend->>Gov: STEP 1.5 set_pool_canister_id (BEFORE STEP 2 — re-point SONS to the live pool; idempotent) Backend->>PoolMgr: STEP 2 transfer_position_to_governance (idempotent — no-op if already on governance) Backend->>Gov: STEP 2 set_position_info Backend->>Backend: STEP 2.5 settle deferred referral (drain held 5% BEFORE the seal; fail-closed on a transient sponsor lookup) Backend->>Mgmt: STEP 2.7 release ledger + index to the DAO — remove backend + ohshii_governance from IC controllers, SONS governance guaranteed in the same atomic write (spawned ledger archives swept best-effort) Backend->>Gov: STEP A set_campaign_info (final sync) Backend->>Gov: STEP B finalize_governance (autonomy SEAL — removes OhShii admins + IC controllers) Backend->>Mgmt: STEP C canister_status + update_settings — VERIFY controller removal (NotAuthorized = already autonomous when proven; transient = retry, never false-seal) Backend->>Storage: update_campaign state Completed Backend->>Gov: mark_lge_completed_via_dao_storage(opt blob = null) Note over Backend,Gov: DAO autonomous, users claim tokensVesting-lock custody at STEP 1 (per-lock subaccount isolation)
Section titled “Vesting-lock custody at STEP 1 (per-lock subaccount isolation)”The vesting tokens are already on the governance canister at STEP 1 — they were minted onto its account when the token ledger was created, not transferred at finalize. batch_create_vesting_locks therefore moves no tokens itself; it records the locks. What changed with per-lock custody isolation is where those tokens live afterward:
- Each vesting lock is created INACTIVE with its own cryptographically distinct custody subaccount of the governance canister (derived from
(campaign_id, lock_id)) andcustody_funded = false. The per-contributor unlock-fee reserve coversNunlock transfers plus one funding transfer. - The SONS timer (running independently of finalize, so a slow or retried finalize never blocks it) drains a funding-op queue: for each lock it moves
net + N·feefrom the governance main account into that lock’s subaccount, idempotently (balance-gated, pinned dedup so a retry never double-funds), then flipscustody_funded = true. - Unlock payouts are then paid from the lock’s own subaccount to the beneficiary; the governance main account holds only the DAO treasury (the ~100M/41.59M treasury allocation, proposal fees, referral pot) — never lock-backing tokens. A
TreasuryWithdraw/TreasurySwapproposal can no longer reach a lock’s funds.
This is invisible to contributors: they still activate_vesting and receive tranches on the same schedule. Legacy campaigns created before this change (and any lock with no subaccount) keep the pre-isolation main-account behaviour unchanged. See GOVERNANCE.md → “Per-lock custody isolation” for the full model, the cancelled-lock reclaim path, and the treasury-integrity guard.
Changing a SONS lock’s recipient (creator self-service)
Section titled “Changing a SONS lock’s recipient (creator self-service)”The creator of a SONS lock (the recorded creator field — for LGE vesting locks that is the contributor themselves, since creator = recipient at batch creation) can redirect who receives future unlocks, without a proposal:
change_lock_recipient(lock_id, new_recipient, opt pow)— creator-only (fail-closed: legacy locks with no recorded creator cannot use it), Shield-protected (rate limits + PoW under load). Allowed while the lock isActive,Frozen, orInactive(on an Inactive vesting lock the beneficiary change also moves the activation right — intended), at most once per 24h per lock. LP (P-family) locks are excluded. The lock records only the change count and the last-change timestamp. An unlock transfer already in flight still pays the previous recipient — the change redirects FUTURE unlocks.set_lock_recipient_permanent(lock_id, opt pow)— the creator flips a one-way latch after which the creator can never change the recipient again. Also settable at creation (CreateLockArgs.recipient_permanent; the launcher and locker creation forms expose it as a checkbox). Idempotent if already set.- The creator can never modify duration, amount, or schedule — only the recipient. Custody is unaffected: the lock’s subaccount is derived from the lock_id and never moves.
- Governance override: the DAO retains
TransferLockBeneficiary(CRITICAL-tier SONS proposal) for exceptional cases (e.g. lost-key recovery) — it is not bound by the latch or the cooldown, and it stamps the same count/timestamp metadata. A governance change re-arms the creator’s 24h cooldown. - Observability: success emits a
LockStateChangedstable event (recipient_changed/recipient_permanent_set); business-rule failures emitErrorOccurred— both surface on the per-DAO observability panel.
Notes: existing per-campaign SONS canisters need the fleet upgrade before these methods exist there (the UI shows “upgrade pending” until then); a relaunch recreates the V-family vesting locks and resets this metadata. The OhShii Locker product has the parallel creator flow on its own backend — see OHSSHII_LOCKER_INTEGRATION.md § “Changing a lock’s recipient”.
Recovery from Frozen (retry vs relaunch)
Section titled “Recovery from Frozen (retry vs relaunch)”If finalization fails partway — a transient inter-canister error, or the pool-link / position / autonomy-seal steps not all completing — the backend rolls the campaign back to Frozen, a recoverable, retryable state (the flip is STATUS-ONLY) and records an on-chain wedge marker (get_wedge_record: first-wedge time, failed-retry counter, last failure reason). finalize_ico_logic is idempotent and convergent on retry: each step recognizes its own already-done end-state (pool id already set, position already on governance, vesting locks already created, governance already finalized), and a re-run after the autonomy seal skips the pre-seal admin steps and only verifies the controller removal. So re-running finalization on a Frozen campaign drives it the rest of the way to Completed without manual surgery.
Recovery is automatic first: housekeeping re-drives a wedged sold-out Frozen campaign (and any campaign stranded in Finalizing — that state is never a parking spot) once per hour, so transient third-party failures converge to Completed unattended. A deliberate guardian/governance freeze clears the wedge marker and stands the automatic machinery down. If the wedge persists for 14 days from the first failure with ≥ 24 recorded failed automatic retries, the wedge failsafe resolves the campaign to Failed through the repatriate-then-fail chokepoint (resolve_wedged_lge_to_failed): any drain-pinned raise sitting on pool_manager MAIN is first returned on-chain to the campaign subaccount (repatriate_campaign_liquidity, at-most-once, get_repatriation_record), the restored balance is verified, and only then does the campaign flip — so refunds can always pay (“Failed implies refundable”). A campaign whose liquidity position actually exists is never failed — it converges to Completed. From 48 hours after the first wedge, guardian_resolve_wedged_lge(campaign_id) (ONS guardian or an approved ONS proposal) runs the same chokepoint early; a raw Failed status write is refused while a drained, non-repatriated pin exists.
Two explicit recovery paths also surface on the campaign page, chosen by pool state:
- Pool still live → Retry Finalization. The ONS guardian (a governance-set principal with a limited recovery scope) re-drives finalization via
guardian_retry_finalization(campaign_id)(the “Retry Finalization” control). It refuses terminalCompleted/Failedcampaigns — it recovers a pre-completionFrozenor strandedFinalizingone — and completes the campaign’s intended seal + autonomy rather than altering it. - Pool unreachable → Relaunch. If the liquidity pool itself is gone, the recovery is a relaunch (fresh ledger/index/pool, preserving contributions, governance and vesting), not a finalize retry. See the dedicated relaunch section below.
Finalize LP-drain pin (idempotent, retry-safe)
Section titled “Finalize LP-drain pin (idempotent, retry-safe)”The single money leg that moves the raise into the pool is the transfer campaign subaccount → pool_manager main account (transfer_from_subaccount), which the pool_manager then deposits and mints. This leg used to recompute its draw from the live subaccount balance on every attempt — so if the transfer landed but the reply was lost (Frozen rollback), the retry would read the now-drained subaccount and either wedge (InsufficientFunds forever) or, worse, fund a pool with dust while the real raise sat commingled on the pool_manager main account. The finalize drain pin closes that hole (FINALIZE_DRAIN_PINS, backend MemoryId 21; keyed by campaign_id):
- Pin before the transfer. On the first finalize pass the backend resolves the draw once and writes a durable pin — the drawn amount, a fixed
created_at_time, and a snapshot of the campaign-subaccount balance — before it moves any ICP. An existing pin always wins: a retry reuses the pinned amount and never recomputes it. - Deterministic, dedup-safe transfer. The leg sends
pin.amount + 20,000e8s (the 20k covers the pool_manager’s two 10k fee hops) withcreated_at_time = pin.created_at_time. The pool_manager maps a ledgerTxDuplicatetoOk, so a retry re-sends the byte-identical transfer and the ICP ledger dedups it instead of debiting twice. drainedshort-circuit. Once the transfer is confirmed (or a retry proves it landed), the pin is markeddrained = trueand every subsequent pass skips the transfer entirely and proceeds straight to add-liquidity with the pinned amount.- DEFINITE vs AMBIGUOUS failure handling. A failed transfer is classified: an AMBIGUOUS outcome (call lost /
SysUnknown— may have landed) keeps the pin and returns an error, so the next retry re-sends the identical (deduped) tx — it is never recomputed. A DEFINITEInsufficientFundsis proof the transfer did not land, so the pin is re-built fresh (never certified as landed). A DEFINITETxTooOld(the pinned tx aged past the ledger’s 24h dedup window) triggers a verify-before-repay check on the balance delta versus the pin-time snapshot: a drop of ≈ the pinned debit ⇒ landed (markdrained, proceed in the same pass); an unchanged balance ⇒ not landed (re-pin fresh); anything ambiguous, or an unreadable balance ⇒ fail closed (operator must inspect the ledger). Every error rolls the campaign back toFrozenand is retriable. - Dust floor. A resolved draw below 1 ICP (
FINALIZE_MIN_LIQUIDITY_E8S = 100,000,000e8s) is refused before the pin is written — and again before funding — unlesscustom_icp_liquidity_e8sis explicitly set (FINALIZE_DUST_FLOOR). A refused dust draw therefore never leaves a sticky pin, and a dust amount is never minted into a pool. - Pin-time affordability. The resolved draw plus overhead must fit inside the live subaccount balance at pin time, otherwise the pin is refused with
FINALIZE_DRAW_UNAFFORDABLE(the usual trigger is an unclampedcustom_icp_liquidity_e8soverride) — a loud, recoverable failure rather than a silent “landed” mis-certification. - Frozen retry matrix. A
Frozencampaign is force-succeeded (driven towardCompleted) only with evidence: it reached its sale target, or a drain pin already exists (finalize has already started moving LP funds — converging toCompletedis the only fund-safe direction). A repatriated pin (repatriated=true— the drained raise was returned to the campaign subaccount by the wedge chokepoint) counts as no pin here, and the drain step refuses to reuse it: trusting itsdrained=truewould skip the drain and feed pool creation with other campaigns’ funds sitting on thepool_managermain account. AFrozencampaign that expired without reaching target and has no pin falls through to the failure branch and converges toFailedso refunds unlock. AFrozencampaign that neither reached target nor expired (no pin) is refused (OHSHII_FINALIZE_FROZEN_NOT_READY) — creation-failure placeholders and mid-curve freezes must never be one-click drained toward a pool by an accidental admin/guardian retry. - Concurrency. A second finalization pass while one is mid-flight returns
FINALIZE_ALREADY_IN_PROGRESS— a distinct prefix so an operator (or the retry-liquidity proposal) can tell “in progress” apart from a completed pass and from a real failure. - Escape hatch —
admin_clear_finalize_drain_pin(campaign_id). Removes a corrupt or stale pin. Operator caveat: before clearing, verify the actual on-chain fund location (look for the pinned transfer —pin.amount + 20kat the pinnedcreated_at_time, from the campaign subaccount — on the ICP ledger). A relaunch of a wedged campaign requires clearing the pin after the funds have been re-seated on the campaign subaccount, otherwise the reused pin would re-drive the stale draw.
Completed is committed only after the on-chain liquidity position is verified non-zero; Frozen is the universal retry parking state.
Relaunch (recovery when the ICPSwap pool is lost)
Section titled “Relaunch (recovery when the ICPSwap pool is lost)”What it is for. A live LGE’s ICPSwap pool can disappear out from under it — ICPSwap can remove or uninstall the pool, or the pool’s (ICP, token, 3000) key can be reclaimed by a different canister — while the campaign still holds contributor funds. Relaunch rebuilds such a campaign: it mints a fresh ledger + index for the same token, reuses the existing SONS governance canister (the ledger is relinked, not the governance recreated), creates a new ICPSwap pool, and reopens the campaign to Active. Existing contributions and their ICP are preserved untouched in the campaign’s escrow subaccount; a mid-curve relaunch recreates the pool empty so liquidity is not locked at an arbitrary price.
Who can trigger it, and the cost. Any authenticated user may fund a relaunch of an eligible campaign — it is a recovery that benefits the campaign, and the payer covers only the real recovery cost via ICRC-2: the cycles for the two new canisters (ledger + index) plus a fresh, live-priced ICPSwap passcode reserve (no platform margin is charged on a relaunch). OHSHII governance (ONS) and platform operators are platform-funded (no fee). Existing contribution funds are never used to pay for the relaunch.
Eligibility — the pool loss is verified on-chain. A campaign is relaunch-eligible only when all of these hold:
-
it is in the
Frozenstate — aCompletedorFailedLGE is terminal and can never be relaunched (this terminal-state guard is enforced unconditionally, including for a platform-operator force call); -
it is a SONS (per-campaign governance) LGE;
-
it is not already relaunching;
-
it still records a pool id and a ledger id;
-
check_pool_is_lostconfirms the loss against the ICPSwap factory — the backend queries the factory’sgetPoolfor the campaign’s token pair and classifies:- the key now resolves to a different pool canister → lost (key reclaimed),
- the pool is still in the registry but its
metadata()no longer responds → lost (canister uninstalled), - the factory reports the pool as absent → lost (removed from factory),
- the pool’s
metadata()responds (alive) → not eligible, - the factory is unreachable / the probe times out → fail-closed, not eligible (relaunch is blocked until the loss can be verified).
The factory probe runs replicated with generous timeouts and retries so a transient timeout does not mis-classify a live pool as lost, nor a dead pool as alive.
A platform-operator force path exists to override only the pool-loss probe (for a pool that is broken in a way the probe cannot detect) — it still cannot relaunch a Completed or Failed campaign, and it does not bypass the fund-safety of the flow.
Path consistency. Relaunch rebuilds the ledger, index and pool through the same helpers the initial LGE creation uses (same ledger/index deployment, same initial-balance split keyed on the bonding-curve version, same pool-creation call into the pool manager). The new ledger id is not exposed on any public surface until the new pool key is claimed (the same anti-front-running protection as initial creation).
Controllers during LGE creation and after finalization
Section titled “Controllers during LGE creation and after finalization”During LGE creation (see create_ico_campaign, create_ledger_canister_via_cycles_ledger, create_index_canister_internal, create_governance_canister_internal in src/backend/src/lib.rs):
- Governance canister (
sons_governance) controllers at creation time:ohshii_launcher_backend(this backend canister),- blackhole monitor (cycle monitoring),
- CycleOps balance checker,
ohshii_governance,- NNS Root.
- Ledger and index controllers for LGE tokens:
- Same full governance set as above (backend, blackhole monitor, CycleOps, ohshii_governance, NNS Root),
- Plus the governance canister itself when its ID is passed in.
- Archive controllers for the ledger (baked into
archive_optionsat ledger init — they apply to every archive the ledger spawns later):- New deploys: primary controller is the campaign’s
sons_governancecanister, with NNS Root and CycleOps inmore_controller_ids— the platform (backend /ohshii_governance) never appears, so archives are born with their end-state DAO-owned controller set. - Legacy deploys (created before this change): primary controller was the backend, with
ohshii_governanceand the governance canister inmore_controller_ids; finalization sweeps any such archive (see STEP 2.7 below), and the DAO can also remove leftover platform controllers itself via a SONSRemoveControllerproposal.
- New deploys: primary controller is the campaign’s
These controllers are needed so OhShii can:
- Monitor cycles (blackhole + CycleOps),
- Upgrade WASM and perform emergency maintenance during the campaign’s pre-completion life (backend, ohshii_governance, NNS Root),
- Let the per-LGE governance canister upgrade/manage its own ledger/index once fully live.
After LGE finalization (see execute_post_finalization_steps in the backend and finalize_governance in sons_governance):
finalize_governance(called from the backend) ensures that:- The governance canister is a controller of itself (for self-upgrades),
- The governance canister is a controller of the ledger, index, and any archive canisters; otherwise finalization is rejected,
- OhShii backend and
ohshii_governanceare removed from the governance config.admins andfinalized = trueis set.
execute_post_finalization_stepsalso releases the token canisters to the DAO (STEP 2.7, before the autonomy seal):- For the campaign’s ledger and index it reads the current IC controllers, removes the backend and
ohshii_governance, and applies the new list in one atomicupdate_settingsthat is guaranteed to contain the campaign’ssons_governancecanister (added if missing) — the DAO holds control the instant the platform loses it, and every other pre-existing controller (NNS Root, CycleOps, the blackhole monitor on legacy deploys) is preserved. A transient failure rolls the campaign back toFrozenand the retry converges idempotently (a “not a controller” rejection means the release already happened). - Any ledger archive already spawned is swept the same way, best-effort: archives hold transaction history (never fund custody), new deploys’ archives are born DAO-controlled, and a leftover on a legacy archive is loudly logged and removable by the DAO via a SONS
RemoveControllerproposal.
- For the campaign’s ledger and index it reads the current IC controllers, removes the backend and
execute_post_finalization_stepsfinally seals governance autonomy:- Reads the current IC-level controllers of the governance canister,
- Removes the backend and
ohshii_governancefrom that controllers list, keeping at least one controller (governance, NNS Root, and monitoring controllers such as blackhole/CycleOps), - Calls
update_settingson the management canister so that backend and ohshii_governance are no longer IC controllers for the per-LGE governance canister.
The result is that, after finalization:
- The per-LGE governance canister is controlled by its own DAO (token holders) plus safety/monitoring controllers (NNS Root and cycle monitors),
- The campaign’s token ledger, index, and archives are controlled by the campaign’s own
sons_governancecanister plus the same safety/monitoring controllers — the OhShii backend andohshii_governanceare removed, - OhShii backend and the global OHSHII governance (
ohshii_governance) no longer have direct admin or controller powers over the SONS governance canister or the token canisters. Platform-side maintenance tooling that upgrades a token ledger (for example the guardian logo update) therefore only works before completion; after completion those changes go through the DAO’s own governance proposals (e.g.UpdateTokenMetadata,AddController/RemoveController).
8. Campaign failure and refunds
Section titled “8. Campaign failure and refunds”A campaign becomes Failed when:
- It expires without selling out (housekeeping
check_expired_icosorcheck_and_update_campaign_statussets state to Failed whenexpiry_timestamphas passed), or - Finalization is run on a campaign that did not reach the token target (e.g.
finalize_icois invoked — by the backend’s own flow or the authorized caller — on a non–sold-out campaign; the backend marks it Failed), or - It sits Frozen past its scheduled
expiry_timestampby more than a fixed 30-day grace without having reached the token target: the housekeeping Frozen failsafe pass resolves it to Failed automatically, so contributor refunds never depend on a manual status change. The grace window leaves room to resolve the halt through the normal recovery flows first. - It is a sold-out campaign whose finalization wedged (recorded wedge marker) and the wedge persisted for 14 days from the first failure with ≥ 24 recorded failed automatic hourly retries: the wedge failsafe resolves it to Failed through the repatriate-then-fail chokepoint — any drain-pinned raise on
pool_managerMAIN is first returned to the campaign subaccount and verified, so the refunds that open can always pay. A deliberately frozen campaign (guardian/governance moderation — no wedge marker) is exempt and resolves only through an explicit decision; a campaign whose liquidity position exists is never failed (it converges toCompleted).
When a campaign is Failed, contributors can request a refund within 7 days of the campaign’s failure. Campaigns fail at expiry on the normal paths, so this is the historical “7 days from expiry” window. When a campaign reaches Failed after its expiry — a resolved halt re-classified later, the 30-day frozen failsafe, or a governance decision — the failure moment is recorded on-chain and both the claim window and the post-window residual sweep anchor at max(recorded failure, expiry) + 7 days, so a late failure always opens a full claim window and residuals are never swept before it closes. Refunds are user-initiated via the backend method request_refund(campaign_id). The backend marks the user’s contributions as refunded in dao_storage (atomically), then calls the pool_manager to transfer ICP from the campaign subaccount to the user. A bulk refund_campaign path — invoked by the authorized caller for emergency use — follows the same per-user path (atomic mark + subaccount transfer), so a later request_refund for an already-refunded user cannot double-pay.
Refund basis. The refund amount is computed by the backend and the pool_manager transfers it exactly (it no longer re-applies a 90% factor). The campaign subaccount holds the net that was forwarded into it, so the refund can never over-draw.
Every live campaign refunds on the same basis: 90% of contributed (gross) ICP for a contributor, 70/90 of that for the creator. That covers both the pre-escrow legacy campaign (dao-0000000011, curve_input_net = None) and every campaign created since (curve_input_net = Some(false)) — the escrow changed where the money sits, not what the refund is computed from.
On a deferred-referral campaign the buyer additionally reclaims the held 5% band as a second leg, so their total is ≈95% of gross; that part is tier-independent and unchanged.
sequenceDiagram participant User participant Frontend participant Backend participant Storage as dao_storage participant PoolMgr as pool_manager participant ICPLedger participant OHSHIILedger as OHSHII ledger
Note over Backend,Storage: Campaign becomes Failed (expiry or finalize on non-sold-out) Backend->>Storage: update_campaign state = Failed Note over User: Within 7 days of expiry
User->>Frontend: Request refund (campaign_id) Frontend->>Backend: request_refund(campaign_id) Backend->>Storage: get_campaign Backend->>Backend: validate: state Failed, deadline, has contributions Backend->>Storage: mark_contributions_refunded_atomic(user, campaign_id) Storage-->>Backend: Ok Backend->>Backend: compute refund — 90%-of-gross;<br/>creator 70/90 of that (every live campaign) Backend->>Backend: split_refund_legs_e8s → (campaign_leg, referral_leg)<br/>(held funds only, each net of ledger fee) Backend->>PoolMgr: process_single_refund(campaign_id, user, campaign_leg_e8s) Note over PoolMgr: transfers from campaign subaccount (no re-applied 90%) PoolMgr->>PoolMgr: transfer_from_subaccount(campaign subaccount, user, campaign_leg) PoolMgr->>ICPLedger: transfer (ICP to user) PoolMgr-->>Backend: Ok alt referral_leg > 0 (deferred campaign with ESCROW rows) Backend->>PoolMgr: refund_referral_leg(campaign_id, user, referral_leg_e8s)<br/>(ESCROW rows only; pre-escrow rows folded into campaign leg) PoolMgr->>PoolMgr: transfer_from_subaccount(referral subaccount, user, referral_leg) PoolMgr->>ICPLedger: transfer (ICP to user) PoolMgr-->>Backend: Ok end opt Guest paid the one-time OHSHII guest fee (try_mark_guest_fee_refunded → Pay) Backend->>OHSHIILedger: refund_guest_fee_on_failure — icrc1_transfer (OHSHII guest fee back to user, from the per-campaign guest-fee sub) Note over Backend,OHSHIILedger: idempotent (mark-before-pay); NotOwed if the user is still a participant end Backend-->>Frontend: RefundResult::Ok Frontend-->>User: Refund successful- Refund deadline: 7 days after
campaign.expiry_timestamp; after that,request_refundreturns an error. - Rates: the backend computes the exact refund (90%-of-gross for a contributor, 70/90 of that for the creator — on every live campaign, legacy and escrow alike) and splits it across the HELD subaccounts only (never the treasury) via
split_refund_legs_e8s. For a deferred campaign the held 5% referral band is returned as a separaterefund_referral_legfrom the per-campaign referral subaccount (escrow rows); for pre-escrow/legacy campaigns the referral subaccount is empty and the full 95% is drawn from the campaign subaccount. Each leg is net of its own ~0.0001 ICP ledger fee (the participant bears it). The pool_manager no longer re-applies a 90% factor (that double-discount over-drew a net-funded subaccount). - Concurrency: Backend uses per-campaign and per-user locks so only one refund is in progress per user per campaign at a time.
- Bulk (authorized-caller):
refund_campaign(campaign_id)processes all pending refunds for a failed campaign in one go, routing each user through the samemark_contributions_refunded_atomic+process_single_refund(campaign-subaccount) path; it is idempotent. Storage also exposesadmin_clear_refunded_statusandadmin_mark_all_refundedfor bookkeeping. - Guest fee refund (OHSHII): if a contributor paid the one-time guest fee (charged only to Guest-tier participants on their first contribution),
request_refundand the bulkrefund_campaignalso return that OHSHII to them on a Failed campaign, alongside the ICP refund. The exact amount charged is snapshotted at contribution time, so the refund is independent of any later fee-config change, and it is idempotent (recorded per campaign+user). Eligibility is read from the recorded payment, not the user’s current tier (tiers can change over time). - 7-day residual sweep (automatic): once a campaign’s 7-day refund window has fully closed, a periodic housekeeping task (
sweep_expired_campaign_residuals) sweeps any un-refunded residual ICP — the campaign subaccount plus the per-campaign referral subaccount — into the OhShii platform treasury, i.e. the launcher backend canister’s main account, never into the campaign’s own SONS DAO. The campaign-subaccount leg transfers there directly; the referral-subaccount leg callsdrain_campaign_referralwith a zero SONS amount, and because that leg only fires when its amount is at least the ICP ledger fee, a zero amount suppresses it entirely and the whole referral residual drains to the same backend treasury. Fully on-chain, balance-driven, and at-most-once per campaign. Under the per-campaign guest-fee custody (v2) the un-refunded OHSHII guest fees reside in the per-campaign guest-fee subaccount and are forwarded to that same treasury by the OHSHII leg of the same sweep once the window closes — at-most-once, balance-driven. (Legacy MAIN-custody campaigns already hold the float in the treasury account, so they need no separate sweep.) The treasury is ONS-controlled, so releasing a swept residual takes an ONS governance withdraw proposal.
Reference: src/backend/src/lib.rs — request_refund, refund_campaign, compute_failed_refund_e8s, refund_guest_fee_on_failure, sweep_expired_campaign_residuals, finalize_ico (Failed path), check_expired_icos, check_and_update_campaign_status; src/storage/src/lib.rs — mark_contributions_refunded_atomic, try_mark_guest_fee_refunded, mark_campaign_swept; src/pool_manager/src/lib.rs — process_single_refund, transfer_from_subaccount, drain_campaign_referral.
9. Treasury liquidity operations (ONS)
Section titled “9. Treasury liquidity operations (ONS)”ONS can create ICPSwap pools, add liquidity to existing pools, and remove liquidity back to the treasury — all funded from the DAO treasury (the backend main account) and executed through CRITICAL-tier ExecuteAdminMethod proposals (7-day window, higher quorum/approval, guardian-vetoable). Positions created this way are owned by the pool_manager (like LGE positions) and tracked in its treasury-position registry (get_treasury_positions query). One full-range position is minted per approved add/create proposal.
Create / add (backend orchestrator → pool_manager executor):
- The approved proposal calls
proposal_treasury_create_custom_pool/proposal_treasury_add_liquidityon the backend. - The backend journals the operation in stable memory (op id, amounts, per-ledger funding legs with pinned
created_at_time) BEFORE moving any funds, then transfers each leg from the backend main account to the pool_manager main account (a re-sent leg deduplicates at the ledger). The ICPSwap passcode reserve is topped up shortfall-only. - The backend calls the pool_manager, which runs its own journaled saga: for a create, a deterministic factory
getPoolprobe first (if the pool was front-run into existence during the public vote, the op degrades to add-liquidity on the existing pool); passcode purchase (skip-if-exists);createPool; then deposits (exact approvals, NET amounts), a position-id snapshot, the full-range mint with the actually-credited amounts, the registry write, and a residual sweep. For an add, the target pool is first verified against the ICPSwap factory (a lookalike canister that merely echoes plausiblemetadata()is rejected). - Failure at any point leaves the op journaled as failed with funds parked on the pool_manager main account or as pool “unused balance” — both recoverable on-chain: a follow-up
proposal_retry_treasury_liquidity(op_id)proposal re-sends only the missing legs and resumes the pool_manager saga from its phase journal (idempotent; deposits are resumed by unused-balance delta oracles, and a completed op returns success without moving funds).
Remove to treasury (pool_manager):
- The approved proposal calls
proposal_remove_liquidity_to_treasury(pool, position, amount?)— valid targets are treasury-registry positions, or campaign-mapped positions only when the campaign is terminal (Completed/Failed; a Frozen campaign’s LGE position can never be drained mid-recovery). decreaseLiquidity(fatal on error, amounts recorded) →claim(pending fees, recorded) →withdrawof the exact recorded amounts (never a balance-driven sweep — the pool’s unused balance is shared across positions) → forward legs of the recorded amounts (fee-netted, pinnedcreated_at_time) from the pool_manager main account to the backend treasury.- A partially-forwarded removal is re-drained idempotently by
proposal_retry_treasury_forward(op_id).
The proposer sets create/add amounts at proposal time; the pool price can move during the 7-day vote, so the mint consumes the amounts at the pool’s ratio and sweeps the residual back to the pool_manager main account (recorded on the op; retrievable via the existing critical proposal_pool_manager_withdraw). The legacy self-funded, governance-owned custom-pool path (proposal_create_custom_pool) is deprecated: new proposals for it are rejected at creation; proposal_remove_custom_pool_liquidity / proposal_transfer_custom_pool_position remain for any legacy governance-owned position.
SONS equivalents are self-funded from the SONS treasury (its main account) and self-owned: CreatePoolAndAddLiquidity (now critical), the new AddLiquidityToPool (critical, factory-verified, new position per proposal, custody-earmark netting on the campaign token) and SweepPoolUnusedBalance (standard-tier recovery of stranded deposits), with per-DAO position tracking via get_pool_positions. RemoveLiquidity already returns funds to the SONS treasury and, like TransferLiquidityPosition / CollectFees, can optionally target a specific registry position.
Reference: src/backend/src/lib.rs — proposal_treasury_create_custom_pool, proposal_treasury_add_liquidity, proposal_retry_treasury_liquidity, get_treasury_liquidity_ops; src/pool_manager/src/lib.rs — treasury_create_pool_and_add_liquidity, treasury_add_liquidity, proposal_remove_liquidity_to_treasury, proposal_retry_treasury_forward, get_treasury_positions; src/sons_governance/src/lib.rs — execute_add_liquidity_to_pool, execute_sweep_pool_unused_balance, get_pool_positions.
10. CustomCall (arbitrary on-chain-encoded call)
Section titled “10. CustomCall (arbitrary on-chain-encoded call)”CustomCall dispatches ONE arbitrary Candid call to a named method on a canister the DAO controls. It exists on both ONS and SONS, is always Critical (7-day window, critical quorum/approval, guardian veto with override round), and — crucially — the governance canister, not the frontend, encodes the argument. See the category blocks in GOVERNANCE.md.
Voting tier: always Critical, guardian-vetoable. A raw call to an arbitrary method is at least as dangerous as a code upgrade, so it never runs the standard tier.
Why on-chain encoding. A naive “custom call” feature would let the frontend build the executable bytes and merely show the human a rendered call — the classic “vote on X, execute Y” gap, where the displayed text and the encoded blob can diverge. Here the proposer supplies only human-readable Candid text (candid_text_args); the canister fetches the target’s own published interface and encodes that text against the real method signature. The bytes that will run are therefore derived on-chain from exactly the text the voters read.
Preview → create → vote → execute:
- Preview —
custom_call_preview(target, method_name, candid_text_args)(an update) returns the normalized render + payload sha256 + target module_hash the canister WOULD encode, without creating a proposal. The proposer confirms the render matches their intent. Preview is rate-limited (a cycle-reserve floor + a global cap — ONS 120/hour, SONS 60/hour) as a Sybil cycle-drain defence. - Create —
create_proposalwith a custom-call target re-runs the same fetch-parse-encode at creation and pins into the proposal: the target module_hash, the encoded-payload sha256, and the normalized render. Reserved targets are refused here (aaaaa-aa, the governance canister itself, the OHSHII / ICP / cycles ledgers; a SONS DAO also reserves its own campaign ledger + index). Creation fails closed if the target does not publish a parseablecandid:service, ifmethod_nameis absent from it, or if the text does not type-check against the signature — the proposal is never created against an un-encodable call. - Vote — the standard CRITICAL pipeline.
- Execute — the executor re-verifies the pinned module_hash against the target’s live code (fail-closed if it changed at all since the vote — a changed target is a different program than the one approved), confirms governance is still a controller, then dispatches the pinned blob with a bounded wait (never unbounded).
sequenceDiagram participant Proposer participant Gov as ohshii_governance or sons_governance participant Mgmt as Management Canister participant Target as Target canister
Proposer->>Gov: custom_call_preview(target, method, candid_text) Gov->>Mgmt: canister_metadata(target, "candid:service") Gov->>Gov: parse service, encode text vs real signature Gov-->>Proposer: render + sha256 + module_hash (no proposal) Proposer->>Gov: create_proposal (custom call) — re-encode + pin module_hash/sha256/render Note over Gov: 7-day critical vote (guardian-vetoable) Gov->>Mgmt: re-verify target module_hash + controller (fail-closed on drift) Gov->>Target: dispatch pinned blob (bounded wait) Target-->>Gov: reply / reject10.1 Outcome policy — “Executed unless provably no-op”
Section titled “10.1 Outcome policy — “Executed unless provably no-op””A custom call reaches an external canister, so its result is classified conservatively: the proposal is marked Executed unless the call provably had NO effect. The guiding risk is that flipping an already-run call to ExecutionFailed would invite a re-run and a possible irreversible double execution.
| Outcome on the target | Proposal status | Event record |
|---|---|---|
Any reply — including a business Err/err arm | Executed | The call ran on the target. success = true for an Ok / plain / other arm; success = false for an Err/err arm (the method executed and returned its own error — that is a decision, not a failure to run). |
Ambiguous — a non-clean reject: a timeout, a SysUnknown, or a callee trap (a trap is a CanisterError, not a clean reject) | Executed, flagged | custom_call_pin.outcome_unknown = true and a CustomCallOutcomeUnknown event. The operator MUST verify the effect on the target canister — the call may or may not have taken effect, and the canister cannot prove which. |
Clean reject — a system-level reject that provably had no effect and is safe to retry (SysFatal / SysTransient / DestinationInvalid, e.g. a frozen / out-of-cycles / nonexistent target) | ExecutionFailed | Only here — the call demonstrably never ran, so failing it (and allowing a fresh proposal) is safe. |
Operator note (ambiguous outcomes). An outcome_unknown = true flag / CustomCallOutcomeUnknown event is a call to manually verify the effect on the target, never a signal to blindly re-submit the same proposal — a re-run of a call that already took effect can double-apply an irreversible action. Confirm the target’s state first; only re-propose if you have positively established the original call did nothing.
Reference: ohshii_governance/execution.rs / sons_governance/execution.rs — custom-call preview, encode, module-hash pin/re-verify, and dispatch paths; GOVERNANCE.md for the category blocks.
11. HttpsOutcall (DAO-composed external request)
Section titled “11. HttpsOutcall (DAO-composed external request)”HttpsOutcall sends a typed, DAO-composed HTTPS request to an external server. It exists on both ONS and SONS, is always Critical (guardian-vetoable), and is a canister HTTPS outcall — it is not a bridge and not an oracle. See the category blocks in GOVERNANCE.md.
Voting tier: always Critical, guardian-vetoable.
The request the DAO votes on carries: url; method (Get / Post / Head); request headers; an optional body; a mandatory max_response_bytes (1..=2 MiB); a max_cycles cap; is_replicated; and a fixed, wasm-exported transform chosen by id — StripHeaders or StripHeadersAndBody. A proposal can never supply its own transform: it only selects one of the built-in transforms, so the code that normalizes the response for consensus is always canister code, not proposer-injected logic.
Create → vote → execute:
- Create —
create_proposalwith an HTTPS-outcall target validates the request shape (a well-formedurl, amax_response_bytesin1..=2 MiB, a known transform id). - Vote — the standard CRITICAL pipeline.
- Execute — the executor computes the real outcall cost from the request shape and
max_response_bytesand aborts if cost >max_cycles(the DAO’s ceiling is enforced, never silently exceeded), then performs the outcall with the chosen wasm transform.
11.1 Outcome — delivered is Executed, even on a non-2xx
Section titled “11.1 Outcome — delivered is Executed, even on a non-2xx”Like CustomCall, the policy is conservative because a non-idempotent request (e.g. a POST) that reached the server may already have had a side effect.
| Result | Proposal status | Event |
|---|---|---|
| 2xx response | Executed | error = None |
| Non-2xx (e.g. 500) | still Executed | error = Some("HTTP status N") — the request WAS delivered; retrying could double-execute a side effect, so the proposal is not failed |
| Transport-level failure (the outcall never completed) | ExecutionFailed | status 0, error set |
Operator note. A non-2xx is recorded on the proposal, not hidden as a failure: the DAO can see the exact status the server returned while the proposal still reads Executed, because the request left the subnet. Only a transport-level failure — where nothing was delivered — fails the proposal.
Reference: ohshii_governance/execution.rs / sons_governance/execution.rs — HTTPS-outcall cost gate, fixed transform dispatch, and status classification; GOVERNANCE.md for the category blocks.
See also
Section titled “See also”- ARCHITECTURE.md — Canister map and governance scope table.
- WORKFLOWS.md — Create LGE, token, vote, create/execute proposal, backend vs frontend upgrade, self-snapshot flows.
- OHSSHII_LOCKER_INTEGRATION.md — OhShii Locker token list and lock management via ONS proposals, SONS vesting vs Locker.
- GOVERNANCE.md — ONS vs SONS, proposal categories, snapshot/restore.
- LIQUID_DEMOCRACY.md — Vote delegation on ONS: follow / unfollow flows, accept-followers toggle, dismiss-follower, snapshot cascade model, four-layer rate limits, cross-canister batched VP fetch contract, event log reference, FAQ.
- LOGGING_SYSTEM.md — Three-tier logging architecture, stable memory log details, frontend monitoring.
- WORLD_ID_VERIFICATION.md — World ID voter verification: flow, privacy model, three-state config (not_configured / optional / required), threshold-ECDSA RP signatures, rate limits, governance controls and operator checklist.
- FRONTEND.md — Frontend architecture: React + JSX + JSDoc
@ts-checktype-checking, the canister-types.js + utils/canister.js bridges, Candid wire conventions, auth providers, and the TypeScript version policy. - Deploy/VOTER_VERIFICATION_GUIDE.md — Verifying WASM hashes for DAO proposals.