Skip to content

Core Workflows

This document describes the main end-to-end flows with Mermaid diagrams: LGE creation, token creation, payment custody on every paid flow, 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.


0. Where the ICP sits — every account, and how each one is derived

Section titled “0. Where the ICP sits — every account, and how each one is derived”

This section is the map the rest of the document walks over. ICP never sits “in a canister” in the abstract: it always sits in one specific account, and every account below belongs to exactly one canister and has a deterministic address anybody can recompute.

The three canisters that ever hold campaign ICP

Section titled “The three canisters that ever hold campaign ICP”
canisterwhat its main account iswhat its subaccounts hold
ohshii_launcher_backendthe platform / DAO treasury — receives the 5% platform fee and post-window residual sweepsone per-contribution escrow per purchase in flight; one per-payment account per paid creation flow
pool_manageroperating float for the liquidity legs — not a treasuryone per-campaign account (the raise) and one per-campaign referral account (the held 5%)
sons_governance (one per campaign)the campaign DAO’s own treasuryone per-proposal fee escrow; one per-lock custody account per vesting/manual lock

Every derivation is a SHA-256 over a domain separator plus length-prefixed components, so two different kinds of account can never collide even when they describe the same campaign, and no component boundary can be forged by choosing a value that contains a separator character.

accountlives onderived fromdomain separator
contribution escrowbackendbuyer principal + campaign id + amount + client nonceohshii-escrow:v1:
payment (paid creation flows)backendpayment kind + payer principal + payment memoohshii-pay:v1:
LGE finalization deposit (refundable)backendcampaign idohshii-finreserve:v1:
guest-fee pot (holds OHSHII, not ICP)backendcampaign idohshii-guestfee:v1:
campaign referral pot (the held 5%)pool_managercampaign idohshii-referral:v1:
proposal fee escrowONS / SONSproposal idohshii-proposal-fee:v1:
campaign account (the raise)pool_managerthe campaign’s numeric id, big-endian, right-aligned in 32 bytes — not a hash(none — a positional layout)

⚠ The campaign account is the one exception, and it carries one reserved value. Because it is positional rather than hashed, the id 0 would produce 32 zero bytes — and a 32-zero subaccount is, by the ledger’s own definition, the main account of that canister. That value is therefore reserved: pool_manager refuses a transfer that names it as a source, and the residual sweep skips it. Nothing else about the layout changes, because a campaign’s funds already sit at its derived address.

pool_manager’s main account is touched once in the life of a pool — at creation, never with the raise.

  • At CREATION. The ICPSwap pool is created in the detached pool phase of an LGE creation — a timer job that runs right after the creation call returns (passcode → deploy → verify), not at finalization. ICPSwap requires a passcode for the pair before it will create it, and that reserve is a dedicated leg of the creator’s own payment: it goes payment-subaccount → pool_manager main, sized from the live passcode price plus the two ledger fees the flow consumes. The job deliberately waits for that leg rather than falling through to main — fronting it would spend the platform’s float on a pool the creator already paid for. The pool exists from that moment, and stays empty.
  • At FINALIZATION the raise does not transit main at all. pool_manager funds the pool directly from the campaign account: it transfers the deposit into the pool’s pool_manager-derived deposit account with the campaign subaccount as the source, then calls the pool’s deposit. The backend only decides how much (the live campaign-account balance, dust-floored and affordability-checked before anything moves). The pool’s deposit account is shared by every flow pool_manager runs on that pool, so the leg counts only this campaign’s own ICP there, from a durable per-campaign record (icp_leg_in_flight_e8s), never the account’s balance: it sends what is still missing toward amount + fee, journals each transfer before sending it with a pinned created_at_time, and re-sends a transfer whose reply was lost as the same transfer, so the ledger applies it once. The campaign account pays the deposit plus two ledger fees (the transfer into the pool’s deposit account, and the pool’s own move from that account into its main account); the reserved all-zero subaccount is refused by name as a source (LP_LEG_RESERVED_SUBACCOUNT).

The reserved-value rule above still matters at creation, because the passcode reserve does sit in the shared main account while the job runs; the raise never does.

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 is 0.00000133 × k, scaling by the same 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. In the form, each country in the suggested-default chips, the custom list and the dropdown rows carries its flag — an SVG vendored with the app rather than fetched from anywhere, falling back to the two-letter code in a mono chip when a file does not load.

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 in chunks (uploaded into the new governance canister’s own chunk store and installed from there in one atomic step, after which the store is cleared), 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.

pool_managerIndex CanisterLedger Canistersons_governanceCyclesLedgerStorageBackendICPLedgerFrontendUserpool_managerIndex CanisterLedger Canistersons_governanceCyclesLedgerStorageBackendICPLedgerFrontendUservalidates params incl. optional post-LGE guardian(anonymous + OhShii infra canisters rejected)·advisory concurrency check (global 3 / per-creator 1)reserve creation slot (RAII· rejected BEFORE anyfee debit — approval stays valid for a retry)allocate campaign_id + subaccount, cycles floorscaled by in-flight creationspar[parallel phase]persist detached pool job (stable), schedule timerdetached job: fund transfer → ICPSwap pool create →final update (ids revealed, pool_status="ready", state Active) →link pool to governance. On failure: campaign stays Frozen+ job kept for guardian retry. Job survives upgrades(re-armed in post_upgrade) + a housekeeping watchdogre-arms a stuck job.Create LGE (params, payment)prepare_ico_creation()payment_info (amount, expiry, memo)Show approve amounticrc2_approve(backend, amount)create_ico_with_icrc2_payment(args)icrc2_transfer_from (collect)create_ico_campaign()create_empty_governance_canistercreate ledger (Gov as controller)create index (Gov as controller)upload governance WASM in chunks, then installregister campaign subaccountstore campaign FROZEN (pool_status="pending", ids hidden)store_created_token, sponsor attributionsync campaign dataOk(campaign_id, ledger, index, pool_id=None)LGE created (pool completing in background)

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 the campaign’s 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 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 a guarded 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). A campaign DAO that has no guardian when the campaign completes receives this choice (else the creator) at finalization (see §7). The guardian can only trigger emergency/maintenance actions on the DAO — it can never access funds.

Subnet selection. The creation form offers four answers to where the campaign’s canisters are created: let the network choose (the default, which sends no subnet_selection), a named subnet type sent as Filter { subnet_type } (fiduciary, shown as “Higher replication”, and european, shown as “Europe only”), a subnet picked from a list, or a typed principal. The list is the Cycles Minting Canister’s get_default_subnets decorated with the IC dashboard’s node, country, running-canister and load figures; only subnets with every node up are listed and the picker names how many were hidden. At prepare_ico_creation, before the quote and before any ICP moves, the backend resolves get_default_subnets and get_subnet_types_to_subnets and refuses a pinned subnet outside their union with SUBNET_NOT_OPEN, or an unlisted type name with SUBNET_TYPE_UNKNOWN. The two lists are cached for an hour and are not read when nothing is pinned; when neither can be read the selection is accepted as before, so an unavailable list never blocks a launch. The full rule, including how a typed principal is checked, is in Creating a Standalone Token. Whatever the subnet, the campaign’s governance canister is endowed so that it holds at least 2T cycles once its creation and installation are paid: on a subnet with fewer than 11 nodes this minimum, not the per-node scaling, sets its endowment, and the creation quote includes it.

Verified creators. prepare_ico_creation runs the identity gate at “any verified provider”, so Telegram or Google both qualify and nothing on the creation path needs the stronger of the two. The form reads the connected account’s verification state on mount and, while it reads unverified, shows a card above the form, disables submit with the reason beside it, and links to /verify with a provider pre-selected. The card is a courtesy; the backend refusal at prepare_* is the gate, and it still routes to the existing notice. Same card, same gate, on relaunch and on the imported-DAO flow. The exact disclosure text is in Creating a Standalone Token.

Reference: src/backend/src/lib.rs — prepare_ico_creation, create_ico_with_icrc2_payment, create_ico_campaign, create_empty_governance_canister, install_governance_code (through sons_install::install_embedded_sons), 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.

Purchases per wallet. A wallet makes at most 4 purchases in one LGE (MAX_PURCHASES_PER_WALLET_PER_LGE in dao_storage). A purchase is an applied contribution made through the purchase flow. The token allocation the creator takes, and pays for, when creating the LGE is not a purchase: its row is recognised by its creator_<campaign>_<creator> contribution id, not by its ICP amount, so a creator has 4 purchases besides it. An attempt that was refused or is still pending does not count, and a refund does not free a slot. The limit applies to every buyer, the creator included. preflight_purchase refuses a fifth purchase before any ICP moves (PURCHASE_SLOTS_EXHAUSTED, nothing charged), and add_contribution_atomic checks it again as the binding gate: if two purchases by the same wallet race past the preflight, the one that would be the fifth is refused after its pull and refunded from its own escrow, minus ledger fees (if that refund cannot complete at once, the ICP stays in the escrow for sweep_escrow_refund).

OHSHII ledgerICP ledgerdao_storageohshii_locker (lock_storage)ohshii_governanceBackend (ohshii_launcher_backend)FrontendUserOHSHII ledgerICP ledgerdao_storageohshii_locker (lock_storage)ohshii_governanceBackend (ohshii_launcher_backend)FrontendUseropt[Guest tier, first contribution]opt[guest tier, first contribution]single CAMPAIGNS.borrow_mut: dedup → sale target→ minimum (collapses to the residual cost in the final-fill regime)→ sale-target ICP cap (amount ≤ remaining-supply cost + 0.001 ICP, else reject)→ tokens_received = curve(GROSS — the full ICP sent· exact fill ⇒ full remainder)→ per-user TOKEN limit → push row + advance tokens_sold+ persist net_forwarded/escrow + insert CONTRIBUTION_ID_INDEXopt[first-time Guest (the OHSHII guest fee was charged this attempt)]alt[Ok / AlreadyApplied][gate reject]alt[Applied][Rejected (durable tombstone)][NotFound / Pending]enter ICP amount, click "Participate"read or generate client_nonce(localStorage, keyed on user+campaign+amount)icrc2_approve(backend, guest_fee_e8s ≈ 30,000 OHSHII + 0.01 ledger fee)purchase_tokens_icrc2(campaign_id, amount, sponsor?, client_nonce)validate inputs + nonce shapeget_lge_eligibility(caller)calculate_quadratic_voting_power(caller, OHSHII)VP{ tier, effective_tier, max_purchase_limit_e8s, is_verified, is_active_voter, ... }derive_contribution_id + escrow subaccount (length-prefixed, injective)acquire per-contribution_id lock (RAII Drop)lookup_contribution_status(contribution_id)Applied(ContributionView { tokens_received, amount_icp_e8s, block_index })Ok(TokenPurchaseResponse { ... block_index from storage })RejectedErr("already rejected and refunded — start a new purchase") — NO ICP movedresolve sponsor split + NET (before funding)(gated on is_verified from eligibility: unverified buyer ⇒ 0% sponsor, share stays with OhShii, no referral connection persisted)· reject pre-payment if net ≤ ledger feerecord_contribution_pending(...) → fixed created_at_timepreflight_purchase(...) (fast-fail UX· tolerated on ICC error)try_mark_guest_fee_paid(campaign,user) (durable at-most-once marker + pinned ts)transfer_ohshii_guest_fee — icrc2_transfer_from(user → per-campaign guest-fee sub, net of the 0.01 OHSHII ledger fee)icrc2_transfer_from(user → escrow(contribution_id), amount, FIXED created_at_time)block_index (Duplicate on a deduped retry → escrow already funded)store_critical_log [ICP_RECEIVED] (observability only)add_contribution_atomic(..., net_forwarded_e8s, escrow_subaccount)Ok { tokens_received, total_tokens_sold, limited_by_sale_target }forward NET from escrow → pool_manager/campaign_subaccount(from_subaccount = Some(escrow)· 3 legs: platform, isolated referral, net)mark_contribution_forwarded(contribution_id)Ok(TokenPurchaseResponse { tokens_received, amount_paid })PurchaseLimitExceeded | SaleTargetReached | BelowMinimumPurchase | ExceedsRemainingSupply | CampaignNotActiverecord_contribution_rejected(contribution_id) (durable tombstone)refund amount − fee from escrow → buyertry_mark_guest_fee_refunded(campaign,user) (NotOwed if still a participant· idempotent)refund_guest_fee_on_failure — icrc1_transfer (OHSHII guest fee back to user)Err(message)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 at get_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 runs bonding_curve::calculate_tokens_from_icp_amount against the live Campaign.tokens_sold/sale_supply inside the same borrow_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 full X ICP 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 × k ICP (450 at the 500-ICP default; the creator picks the raise 500 × k, k in 1..=10) — and the ICPSwap listing is 450k / 200M = 225k e8s/token (225 at the default). The curve_input_net flag 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. A purchase may never pay more ICP than the curve cost of the entire remaining supply: any amount above remaining_cost + 0.001 ICP is rejected — pre-payment by preflight_purchase (ExceedsRemainingSupply, no ICP moves) and authoritatively by add_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 on BelowMinimumPurchase. The whole-remainder grant applies only to a buyer whose remaining allocation covers the whole remainder. For any other buyer, an amount in the band is priced on the curve like any other purchase: it is accepted if its tokens fit the buyer’s allocation and refused before payment otherwise, and the sale is closed by the next buyer whose allocation covers what is left. Pre-flight, quote and atomic apply compute the granted tokens with one shared rule. The window (remaining cost, effective min/max, final-fill flag) is exposed read-only by dao_storage.get_purchase_window — it returns null for a non-Active or sold-out campaign — and is computed by the same helper the two gates use; the frontend completion flow consumes it directly, so quote and enforcement cannot drift. A final-fill whose amount exceeded the exact cost (by ≤ tolerance) sets TokenPurchaseResponse.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_e8s matches the full curve cost within the tolerance plus per-row inversion slack (≤ ~0.001 ICP per campaign in practice).

ConcernOwnerHow
Cycle floor + per-user rate limit + global rate limit + LGE toggle + relaunch blockBackend purchase_tokens_icrc2Synchronous pre-checks before any ICC
Tier resolution + is_active_voter flagohshii_governance.get_lge_eligibility1 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 buyerdao_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 / purchases per wallet / 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 transferBackend → ICP ledgericrc2_transfer_from(caller → escrow(contribution_id), amount, FIXED created_at_time); the only money-moving await
Forensic ICP logBackend store_critical_logSingle [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 + purchases per wallet + limit + target + minimum + bonding curve + persistence + indexdao_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.

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_status and short-circuits. Edge case: a true same-intent race is blocked at the backend by the per-contribution_id RAII lock, and any duplicate buyer→escrow transfer carries the fixed created_at_time persisted on the pending marker, so the ICP ledger dedups it (returns Duplicate) instead of debiting twice.

After a successful response, the frontend clears the localStorage entry so the next intent gets a fresh nonce.

Refusals that cost nothing are asked first. Before the page sends any ledger approval (ICP, or OHSHII for a Guest fee) or swaps ICP to acquire a Guest fee, it asks the same refusals the canister applies before charging: the buyer’s tier limit, the minimum purchase, the remaining supply, the sale state, the Guest slots, and whether the wallet covers the amount plus both ledger fees. A purchase refused at this point costs no fee, and the page says that nothing was approved or sent. If one of these reads fails, the page goes ahead and the canister applies the same checks itself.

  • Lookup ICC fails (transient network glitch on the query): backend returns Err to 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 − fee from that contribution’s escrow to the buyer, inline. A same-nonce retry short-circuits on the tombstone — no re-pay, no fee bleed.
  • Retry refused before it applies: when a retry of a still-pending purchase (same amount and nonce) is refused by the pre-flight check and an earlier attempt had already moved its ICP into the escrow, the purchase is closed first and the escrow is refunded to the buyer (amount − fee); the refusal says whether the ICP was returned or is still held in the escrow for sweep_escrow_refund. A fresh purchase refused by the pre-flight moves nothing. The same treatment applies when such a retry is refused before anything is charged for another reason: the Guest eligibility could not be determined, the campaign’s Guest cap is full, the Guest-fee charge moved nothing, or contributions are paused for the campaign’s governance cycles.
  • The Guest fee, charged once and returned once: a Guest-fee charge that moves no OHSHII (not enough OHSHII, no approval, or a balance that could not be read) leaves no fee recorded and takes no Guest place. A charge the OHSHII ledger never executed because the call never reached it counts as moving nothing. A charge whose outcome is unknown (the reply was lost, or the ledger answered with an error that can follow a completed transfer) is recorded as UNCONFIRMED: not paid, never refunded, and settled by the Guest’s next purchase, which re-issues the same transfer (same created_at_time, memo and recorded fee amount, so the ledger deduplicates it) before anything else, so the fee is taken once. The UNCONFIRMED record is cleared as “never taken” only when the OHSHII ledger itself refuses that re-issued transfer for lack of OHSHII or of an approval; any other answer leaves it UNCONFIRMED and the purchase refused. Every path that closes a purchase returns the fee once — the policy refusal at apply, the refused retry, and sweep_escrow_refund — so the outcome does not depend on which of them closes it first; the refusal text says whether the fee came back. The fee is returned only when the Guest has no other purchase in that campaign that can still apply: a counted purchase, or an open one (pending, under 24 hours, on an active campaign); otherwise it stays paid and covers that purchase. A refund whose transfer is not confirmed stays PENDING, and the Guest’s next purchase settles it first with the same transfer: if it landed, the fee is charged again once; if it did not, the fee still counts as paid. The refund carries no pinned ledger fee (the ledger applies its current one), so a change of the OHSHII ledger fee cannot make its re-issue fail before the ledger’s duplicate check; and only the ledger’s “insufficient funds” answer, which comes after that check, rolls it back. Any other error leaves it PENDING. A fee that was returned no longer counts as paid: the Guest’s next purchase in that campaign pays it again and counts toward the Guest cap again. A purchase refused before its ICP reached the escrow (the escrow is empty) stays open until it is closed: sweep_escrow_refund of it closes it, moves no ICP, and returns the Guest fee it was holding back, once. Because no ICP was returned, that call answers with the error ESCROW_EMPTY_NOTHING_REFUNDED, which says whether it closed the purchase; it is not a refusal. On a Failed campaign the refund paths (request_refund, refund_campaign and the orphan refund) settle an UNCONFIRMED charge or a PENDING refund first, with the same transfer, before deciding what to return. The ledger recognises that same transfer for 24 hours after it was first sent. After that, the settlement reads the OHSHII ledger’s own transaction history, archives included, over the whole period in which that transfer could have been recorded, and looks for that exact transfer: every field that identifies it must match (not the fee the ledger reports, which is the fee it charged) — the sender and recipient accounts with their subaccounts, the spender (the backend, for a charge), the amount, the memo (which names the campaign, in the 16 bytes the OHSHII ledger accepts) and the pinned created_at_time — so no other transfer, from the Guest or from anyone, can be taken for it. Found means it happened; not found in a complete read means it did not. The read resumes where the previous attempt stopped, so each attempt costs a bounded amount. Until the read is complete the record stays as it is and nothing is paid for it. If a Failed campaign’s 7-day refund window closes first, the residual sweep moves the campaign’s Guest-fee balance to the treasury, and a Guest fee still unsettled at that point is not returned.
  • Trap/upgrade between escrow funding and apply: the ICP sits in the deterministic per-contribution escrow. The fixed created_at_time on 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) and sweep_escrow_refund (refund to the buyer) are idempotent and on-chain. No off-chain reconciler.
  • What the escrow refund may return: sweep_escrow_refund refunds only a purchase that was NOT counted — rejected, still pending, or never recorded. A counted purchase’s escrow can only move forward to the campaign (sweep_escrow_to_lge, or the finalize reconciliation); if the campaign later fails, its refund is paid through request_refund. When the purchase’s status cannot be read, the escrow refund refuses rather than guesses. The refund always pays the buyer, and when the escrow balance cannot be read it answers with an error rather than reporting nothing to do. It stays available after the campaign has ended, for a purchase that was never counted (a counted one is refunded once, through request_refund). Before it returns the escrow of a purchase that is still pending or was never recorded, it closes that purchase, so the purchase can no longer be counted afterwards. Like the other value-moving calls it is protected by the Shield: it is rate-limited and, under load, asks for a proof-of-work (its last argument, opt blob). On the campaign page, a buyer whose purchase escrow still holds ICP sees a Recover funds button that makes this call with that purchase’s values. The page offers it only after reading the escrow’s balance on the ledger, and after the call it reads the escrow and the wallet again and reports what moved.
  • A forward never reads an unknown balance as empty: when the escrow’s balance cannot be read, the forward to the campaign is not sent and the purchase stays unforwarded, to be forwarded later; it is never recorded as forwarded while its ICP is still in the escrow.
  • What the forward to the campaign may move: sweep_escrow_to_lge forwards only a purchase that WAS counted. A purchase that is still pending, was rejected, or was never recorded is refused and pointed to sweep_escrow_refund, and so is one whose status cannot be read. Its success message says funds were forwarded only when that call moved them.
  • A deduplicated funding is checked, not trusted: when the ledger answers a retried escrow funding as a duplicate of an earlier transfer, the purchase is applied only if the escrow still holds the amount; otherwise it is refused (ESCROW_FUNDING_NOT_PRESENT) and nothing is credited.
  • Stale intent: a purchase intent whose pending marker is older than 23 hours cannot be completed (PURCHASE_INTENT_EXPIRED), because the ledger no longer recognises its pinned transfer timestamp; start again with a new nonce. Anything already deposited into that intent’s escrow stays refundable to the buyer.
  • Trap between apply (counted) and forward: the contribution is counted but its net still sits in the escrow; sweep_escrow_to_lge recovers 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). A Guest holding only ICP does not have to source OHSHII manually first: the frontend 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):

  1. Eligibility, fail-closed. Fetch get_lge_eligibility once. If eligibility is unavailable the whole flow aborts (it never assumes Guest or non-Guest).
  2. Shortfall check. Only Guests with a positive guest_fee_e8s are affected. If the wallet already holds enough OHSHII (fee + ledger-fee headroom), skip straight to the purchase. Otherwise compute the exact OHSHII shortfall.
  3. 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 generalized swapIcpForToken in utils/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-tagged stage: 'guest_fee_swap' and propagated — the flow never proceeds to spend ICP on the contribution).
  4. 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.
  5. Purchase. Only after the OHSHII is confirmed in the wallet does the flow delegate to the normal purchaseTokensIcrc2 path (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.


The user pays 5 ICP via ICRC-2; the backend verifies the payment and deploys the token ledger and index canisters.

BackendICPLedgerFrontendUserBackendICPLedgerFrontendUserCreate token (metadata, payment)prepare_token_creation()payment_info (amount, expiry, subaccount)icrc2_approve(backend, amount)create_token_with_icrc2_payment(args)icrc2_transfer_from (collect)allocate funds, deploy ledger + indexOk(ledger_id, index_id)Token created

prepare_token_creation runs the same two pre-payment mechanics as the LGE flow above: the identity gate at “any verified provider”, and the subnet check that refuses a pinned subnet the Cycles Minting Canister will not accept (SUBNET_NOT_OPEN) or an unlisted type name (SUBNET_TYPE_UNKNOWN) before the quote is issued. Both are described in full in Creating a Standalone Token.

Reference: src/backend/src/lib.rs — prepare_token_creation, create_token_with_icrc2_payment, validate_subnet_selection.


Six flows take ICP from a caller to pay for something the platform then builds: a standalone token, an LGE creation, an imported DAO, an index canister, a cycles top-up, and a discounted cycles purchase. All six run on the same custody model, described here once.

Every payment lands in its own subaccount of the backend, derived from the payment memo (never on the backend’s main account, which holds every other platform balance), and a durable journal row is written before anything else happens. Validation runs first, so an input the platform will refuse costs the caller nothing.

refused

accepted

success

failure

Payer

Validate every input: logo, name, symbol, cycles quote

The call fails and no ICP has moved

icrc2_transfer_from into the per-payment subaccount

Journal row written: Debited

Cycles leg converted FROM the subaccount

Build the ledger, canister or campaign

Sweep to the destinations frozen on the row: Completed, then Swept

Refund FROM the same subaccount: RefundPending, then Refunded

StateWhat it means
DebitedThe ICP is in the payment subaccount and nothing has left it.
CyclesLegDoneThe cycles leg has been converted out of that subaccount.
CompletedThe flow succeeded. The sweep may still be outstanding.
SweptThe sweep drained the subaccount.
RefundPendingA refund is owed and has not yet landed.
RefundedThe refund leg landed. A refund of zero is recorded here too: it means the subaccount was already empty, either because an earlier attempt landed or because the remainder was below the network fee, and the row states both.
RefundFailedThe ledger refused the refund with a definite error.
DebitAmbiguousThe debit call’s own answer was lost, so nobody knows whether the ICP moved.

DebitAmbiguous is the case worth understanding. The memo stays spent, because the one outcome that must be impossible is a retry pulling a second payment; the outcome is then settled by reading the subaccount’s balance from the ledger, not by guessing.

JournalCMCLedgerBackendPayerJournalCMCLedgerBackendPayera refusal here costs nothing, no ICP has movedalt[creation succeeds][creation fails]create_token_with_icrc2_payment1validate logo, name, symbol and re-quote the cycles cost2icrc2_transfer_from into the payment subaccount3Debited4convert the cycles leg FROM the subaccount5CyclesLegDone6deploy the token ledger and index canisters7Completed8sweep the remainder to the platform account9Swept10RefundPending11refund from the payment subaccount12Refunded, or RefundFailed13

The LGE fee is not one destination but five: the ICPSwap passcode reserve, the campaign subaccount, the sponsor reward band, the refundable finalization deposit, and the remainder to the platform account. All five are resolved before the debit and written on the journal row the moment the debit lands, so a retry replays the same amounts rather than recomputing them.

The finalization deposit (0.1 ICP, quoted as 0.1001 ICP because the creator also pays the ledger fee that moves it) goes to a backend-owned account derived from the campaign id, SHA-256("ohshii-finreserve:v1:" ‖ len(campaign_id) as 4 bytes big-endian ‖ campaign_id). It is never the campaign subaccount and never the platform account, so no refund, residual sweep or liquidity step can reach it. It pays the ledger fees of failed finalization passes (see Recovery from Frozen below); what it does not pay is returned to the creator. Campaigns created before the deposit existed carry no deposit and keep the earlier rules.

PoolManagerJournalCMCLedgerBackendCreatorPoolManagerJournalCMCLedgerBackendCreatorthe five sweep destinations are resolved here, before the debitalt[creation succeeds][creation fails]create_ico_with_icrc2_payment1validate the campaign inputs and resolve the fee split2icrc2_transfer_from into the payment subaccount3Debited, and the five resolved destinations are written on the row4convert the cycles leg FROM the subaccount5CyclesLegDone6create the campaign and its governance canister7Completed8leg 1, the ICPSwap passcode reserve, swept FIRST9leg 2, the campaign subaccount10leg 3, the sponsor reward band11leg 4, the finalization deposit, to the campaign's deposit account12leg 5, the remainder to the platform account13Swept14RefundPending15refund from the payment subaccount16Refunded, or RefundFailed17

The passcode leg is swept first because the pool-creation job is already waiting on it. That job now waits for the leg instead of spending the platform’s own balance:

yes, the row carries its block index

the leg was below the ledger fee and was settled with no transfer

a legacy campaign, created before payment custody

not yet, and the 660-second window is still open

the window closed and the leg never landed

A pool-creation job run starts

Has the passcode reserve leg landed?

Create the ICPSwap pool

Come back in 10 seconds. A wait is NOT an attempt, so it does not consume the retry budget

Freeze the campaign, naming the amount owed and the two recovery steps

A campaign frozen this way names what is owed. It is recovered by re-sending the leg and then re-driving the job, both through the guardian lever described in 2.5.9.

JournalCMCLedgerBackendImporterJournalCMCLedgerBackendImporteralt[creation succeeds][creation fails]create_imported_dao_with_icrc2_payment1validate the token and the campaign slot2icrc2_transfer_from into the payment subaccount3Debited4convert the cycles leg FROM the subaccount5CyclesLegDone6deploy the campaign governance canister7Completed8sweep the remainder to the platform account9Swept10RefundPending11refund from the payment subaccount12Refunded, or RefundFailed13

The index flow deposits the measured cycles credit rather than a constant: what the Cycle Minting Canister reported for this payment is written on the row, and the deposit sends that number. A credit that comes back short fails before the canister is created, so the refund is still the whole remainder.

prepare_index_canister_creation carries the same two pre-payment mechanics as the token and LGE flows: the identity gate at “any verified provider”, and the subnet check — a pinned subnet outside the Cycles Minting Canister’s default list and named types is refused with SUBNET_NOT_OPEN, an unlisted type name with SUBNET_TYPE_UNKNOWN, both before the quote and before any debit. The four choices the form offers, and what happens when the lists cannot be read, are in Creating a Standalone Token.

JournalCMCLedgerBackendPayerJournalCMCLedgerBackendPayeralt[creation succeeds][creation fails, or the credit is short]create_index_canister_icrc21validate the target ledger and re-quote the cycles cost2icrc2_transfer_from into the payment subaccount3Debited4convert the cycles leg FROM the subaccount5the credited cycles, measured6CyclesLegDone, with the measured credit recorded7create and install the index canister with that credit8Completed9sweep the remainder to the platform account10Swept11RefundPending12refund from the payment subaccount13Refunded, or RefundFailed14
TargetCanisterJournalCMCLedgerBackendPayerTargetCanisterJournalCMCLedgerBackendPayeralt[the top-up completes][the top-up fails]execute_cycles_topup1validate the target canister and re-quote the rate2icrc2_transfer_from into the payment subaccount3Debited4convert the cycles leg FROM the subaccount5deposit the cycles6CyclesLegDone7Completed8sweep the platform fee to the platform account9Swept10RefundPending11refund from the payment subaccount12Refunded, or RefundFailed13

This flow differs from the other five in two ways, and both are visible to the buyer. The cycles are delivered out of the platform’s own reserve, not minted from the payment, so CyclesLegDone here means delivered. And the ICP is owed to whoever stocked that reserve, so the success sweep forwards it to the reserve funder rather than to the platform account.

Once delivery has happened, no refund can be issued: returning the ICP as well would pay the buyer twice out of a reserve nothing can recall. A row that stalls after delivery is settled by forwarding, never by refunding.

JournalCyclesLedgerLedgerBackendBuyerJournalCyclesLedgerLedgerBackendBuyeralt[every check before delivery passes][a check before delivery fails]execute_discount_cycles_purchase1validate the request and the reserve balance2icrc2_transfer_from into the payment subaccount3Debited4deliver the cycles from the platform reserve5CyclesLegDone, delivered, no refund possible from here on6Completed7forward the ICP to the reserve funder8Swept9RefundPending10refund from the payment subaccount11Refunded, or RefundFailed12
  • A refund comes only from your own payment subaccount. It is never drawn from the platform’s own balances and never from another user’s funds. The amount is what that subaccount holds, less the network transfer fee.
  • The cycles already converted for a failed creation stay converted. The cycles leg runs out of the payment subaccount before the work begins, so a failed creation refunds the gross less the converted leg and the fee. The platform does not front cycles, and a refund is therefore never larger than what is still there.
  • Two creations running at the same moment do not interfere with each other. Every request the backend sends to the cycles ledger to create a canister carries its own creation timestamp, unique to that request, so two creations started in the same moment are never taken for one repeated request. The backend’s account on the cycles ledger is shared by every creation, and it can hold cycles left over from earlier ones; before a creation counts any of those towards its own canisters, it reserves what it will spend, and every other creation in progress leaves the reserved amount out of its own count. Two creations therefore never count the same leftover cycles, and the later one tops the account up for itself instead of finding it short. The reservation is released when the creation’s calls have answered; a creation request whose answer is lost in transit is outside it.
  • Validation runs before the debit. Every check that can fail on your input alone — logo, name, symbol, the cycles quote — runs while the only cost of failing is a wasted call.
  • reclaim_payment(memo) re-runs your own refund leg. It is callable by the journal payer and nobody else, it moves money only to that payer, and it is balance-driven: calling it twice refunds once. It does not create an entitlement — it accelerates one the journal already records.
  • get_my_payment_custody() shows you every row you paid for, with its state, the amount, the refund block if one landed, and the reason attached to the row. A refund of zero records that the subaccount was already empty — either an earlier attempt landed or the remainder was below the network fee — and the row states both.
  • A row the automatic pass cannot settle stays visible and stays yours. It is completed by the governance-set guardian (limited recovery scope) through guardian_resolve_payment_custody, whose four actions can pay only the journal’s own payer or the destinations already frozen on the row. None of them can send value to an account the caller chooses, and none of them touches a vote, a proposal or a campaign outcome.

The backend’s housekeeping timer runs every five minutes and works through a bounded list of the rows that still owe something: a pending or failed refund, an outstanding sweep, an unresolved cycles leg. It pages that list from a rotating cursor and settles a small number of rows per tick, so no single stuck row can starve the ones behind it.

A row whose flow is still executing is scanned and skipped — housekeeping does not second-guess work it cannot see. Once such a row is older than the ledger’s 24-hour deduplication window, no message can still be running it, and the pass logs it as an orphaned row naming the state, the amount and the action that moves it. That log is all it does: the row and the money are left exactly as they are, because the work may have finished with only its marker lost, and guessing would be worse than the gap. Recovery from there is the guardian lever above.

Reference: src/backend/src/lib.rs — create_token_with_icrc2_payment, create_ico_with_icrc2_payment, create_imported_dao_with_icrc2_payment, create_index_canister_icrc2, execute_cycles_topup, execute_discount_cycles_purchase, reclaim_payment, get_my_payment_custody, guardian_resolve_payment_custody.


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.

User

Select DAO

OHSHII

SONS token

ohshii_governance

sons_governance

vote

Voting power quadratic token-based

Timer expiry and auto-execute

  • 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_lock locks — 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 has 36,000 VP from one lock and 4,000 VP from another, displayed/votable VP remains 36,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).


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).

User

Select DAO: OHSHII or SONS

Choose category and method

Payload: target, args, optional WASM

Proposal fee / upload

create_proposal

ohshii_governance

sons_governance

Stored ONS proposal

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.

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.

Target Rust canisterManagement Canisterohshii_governance or sons_governanceTarget Rust canisterManagement Canisterohshii_governance or sons_governancealt[WASM under 2 MiB][WASM 2 MiB or more chunked]Revalidate WASM hash load sessioninstall_code mode Upgrade canister_id wasm_moduleUpgradeupload_chunk repeatedinstall_chunked_code canister_id chunk_store_id hashesUpgradeOk

Size ceiling. A governance canister accepts a module of at most 20 000 000 bytes (~19.1 MiB). Each canister states that limit directly, and both state the same one, so a module one accepts the other accepts too. An upload is sent in chunks — at most 1 000 000 bytes per chunk to OHSHII governance, 500 000 to a campaign DAO — and a session accepts up to 42 of them, which is enough to reach the byte ceiling at either chunk size. So the limit that binds is the module’s size, never the number of chunks a client happens to slice it into.

The ceiling is the same for every proposal shape, single-target and batch alike, and it is applied twice: to the size the proposal declares, before any fee is taken, and to the bytes actually uploaded. The frontend also 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.

How long a staged module is kept. A module staged for a proposal is kept for as long as that proposal is live — including a vetoed proposal whose override round is still open, however long the round runs — and is removed when the proposal ends: executed, rejected, expired or failed. A module staged but never used by a proposal is kept for a short window (30 minutes from the start of its upload) and then removed; the fee paid to stage it is not refunded, and the removal is recorded on-chain. Nothing else removes a live proposal’s module, so a proposal that passes always finds the module it declared.

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.

Leftover chunks on the target. A chunked upgrade stages its module in the target canister’s chunk store, which must start empty. When it does not — for example, chunks left behind by whoever installed the canister — the governance canister empties the store before staging, provided it is a controller of the target, and records the clear in its event log (chunk_store_auto_cleared). Only the target’s own store (or the governance canister’s own) is ever emptied automatically: a proposal that names a different store which is not empty is refused, and that store is left as it is. It never stages onto a store it could not empty, and it never empties a store that one of its own upgrades is staging into at that moment. The guardian can empty the chunk store of a controlled canister with guardian_clear_chunk_store, under the same refusals (see GOVERNANCE.md).

Reference: ohshii_governance/execution.rs — execute_upgrade_standard, execute_upgrade_chunked; sons_governance equivalent execution paths.

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, in a fixed order:

  1. The proposer calls prepare_asset_upload(target, expected_chunks, anchor) on the governance canister. It collects the proposal fee, gives the caller a 30-minute lease on the target, and grants the caller Prepare on it. It also removes any proposed batch on the target that no other live lease or live proposal holds, so a batch staged before this call does not survive it. anchor is null for a single upgrade. Inside a batch proposal (below) only the first staged step pays: every later step names that step’s upload session as its anchor, pays nothing, and still takes its own lease (ending no later than the anchor’s) and passes every other staging gate. One anchor admits at most nine such steps over its life; removing one does not free its place.
  2. With the same identity, the proposer uploads a batch to the asset canister and proposes it (create_batch, chunks, propose_commit_batch, compute_evidence).
  3. The proposer creates the UpgradeAssetCanister proposal while the lease is live. Creation runs the lease check first and only then validates the batch and its evidence against the asset canister. If the asset canister does not answer that validation within 15 seconds, the proposal is refused: nothing is created and no creation fee is taken.

At execution time the governance canister validates and commits the batch. No install_code is involved.

ohshii_governance or sons_governanceAsset canisterProposerohshii_governance or sons_governanceAsset canisterProposerThen, same identity: create_batch, upload chunks, propose_commit_batch, compute_evidenceAt execution, after an approving votealt[valid][invalid]prepare_asset_upload(target, chunks, anchor) — deposit (a batch follower naming its anchor pays nothing), 30-min lease, Prepare grantcreate_proposal(UpgradeAssetCanister: batch_id, evidence) while the lease is livevalidate_commit_proposed_batch(evidence)ValidationResult (valid / invalid)commit_proposed_batch(evidence)Apply batch (new assets / frontend content)OkExecution failed

Reference: 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 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:

  1. Create — create_proposal with a CanisterUpgradeBatch target validates the suite shape (role order, 32-byte hashes, sizes, chunking, no self-target, suite probes) and takes one proposal fee for the whole batch, whatever its number of steps. If a step was pre-staged with a paid session, that payment is the batch’s one deposit and creation charges nothing; every other pre-staged step must have been prepared as its follower (prepare_paid_upload(expected_chunks, anchor) / prepare_wasm_upload(…, anchor)). A batch that carries two paid sessions is refused (BATCH_MULTIPLE_DEPOSITS) before any fee is taken. With nothing pre-staged, creation takes one fee into the proposal’s own escrow. For every step without a pre-staged session the canister allocates an upload session; the proposal starts in PendingUpload.
  2. 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.
  3. Vote — the standard CRITICAL pipeline: fixed 7-day window, critical quorum/approval bar, guardian veto with override round.
  4. 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’s batch_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. Pin one ledger-suite release per batch, and keep the whole suite on one version before moving to the next release. Most DFINITY ledger-suite release notes name the oldest release the installed ledger must already run (“the installed ledger shall be running … or later”): ledger-suite-icrc-2026-02-02 and ledger-suite-icrc-2026-03-09 name ledger-suite-icrc-2025-10-27, and the releases from ledger-suite-icrc-2024-11-28 to ledger-suite-icrc-2025-10-27 name ledger-suite-icrc-2024-10-17. A later release may name another; read its notes. An older ledger refuses the upgrade during execution, after the vote, and the steps before it stay upgraded; such an upgrade is done in stages, one proposal per stage. The proposal form compares the ledger’s installed module hash with the attached ledger WASM and shows a warning, never a block, when a required intermediate release is missing (naming the stages in order), when the attached file is an earlier release than the installed one (several release notes state that downgrading the ledger is not supported), or when it is the other ledger build (nat64 or u256). It cannot check a build outside its table of published .wasm.gz hashes, and says so.

Management Canisterohshii_governance or sons_governanceProposerManagement Canisterohshii_governance or sons_governanceProposerPendingUpload — 15 min window7-day critical vote (guardian-vetoable)loop[each step in voted order]Recovery: fresh batch — committed steps auto-skipalt[a step fails]create_proposal CanisterUpgradeBatch (ordered steps)Validate order, hashes, suite probes· one fee· N sessionsupload chunks per step sessionAll steps staged → OpenPre-flight: controller check ALL targets (all-or-nothing)canister_info (skip if hash already matches)install_code / install_chunked_codeverify installed module hashpersist batch_step_results[k]ExecutionFailed + per-step record (remaining steps Pending)

Reference: ohshii_governance/execution.rs / sons_governance/execution.rs — execute_canister_upgrade_batch; GOVERNANCE.md for the ONS and SONS category blocks.


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:

  1. Create — create_proposal with a DappUpgradeBatch target 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 a validate_commit_proposed_batch probe on the target (the asset-lease check on all asset targets comes before the first probe). It takes one proposal fee for the whole batch: the first asset or code step staged before creation pays it (the batch deposit) and every other staged step is prepared as that step’s follower and pays nothing, so creation charges nothing more; with nothing staged, creation takes one fee into the proposal’s own escrow. A batch carrying two paid deposits, or a follower whose deposit is not in the batch, is refused (BATCH_MULTIPLE_DEPOSITS, FOLLOWER_WITHOUT_ITS_DEPOSIT) before any fee is taken. For every code step without a pre-staged session the canister allocates an upload session; the proposal starts in PendingUpload.
  2. 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_upload staging done before creation. When every code step is staged, the proposal flips PendingUpload → Open (a pure-asset batch lands Open immediately).
  3. Vote — the standard CRITICAL pipeline: fixed 7-day window, critical quorum/approval bar, guardian veto with override round.
  4. 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, runs install_code / install_chunked_code, and verifies the installed module hash; an asset step runs commit_proposed_batch. Per-step progress lives in the proposal’s batch_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 declared wasm_module_hash, and after execution confirm with dfx 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 dfx for 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 same ic_asset::compute_evidence the 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).

Asset canister (per asset step)Management Canisterohshii_governance or sons_governanceProposerAsset canister (per asset step)Management Canisterohshii_governance or sons_governanceProposerPendingUpload — 30 min window (asset steps pre-staged)7-day critical vote (guardian-vetoable)alt[code step][asset step]loop[each step in voted order]Recovery: fresh batch — committed code steps auto-skip· committed asset steps do NOT (omit or re-stage)alt[a step fails]create_proposal DappUpgradeBatch (ordered mixed-kind steps)Validate shape, category, provenance· per-asset-step validate ICC· one fee· sessions per code stepupload chunks per code-step sessionAll code steps staged → OpenPre-flight: controller check ALL targets incl. asset (all-or-nothing) + asset re-validatelive module hash (skip if already matches)install_code / install_chunked_codeverify installed module hashcommit_proposed_batch(batch_id, evidence)persist batch_step_results[k]ExecutionFailed + per-step record (remaining steps Pending)

Reference: 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_* time, before a proposal id exists) record the escrow subaccount of the upload session that paid, and follow the same outcome policy; a batch records its one deposit the same way. 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):

OutcomeSettlementNotes
Executed, or Approved for a signal-only proposal (ONS Motion; SONS Motion/UpdateCampaign)Full refund (fee_paid − ledger_fee) from the escrowA proposal the DAO adopts costs its proposer nothing: the cost of governance is borne by the proposals the DAO refused.
Approved, executable (execution still pending)Nothing yetTransient — settling early would make a later ExecutionFailed refund unpayable (the escrow would already be empty). The deposit stays in escrow until Executed / ExecutionFailed.
ExecutionFailedFull refund (fee_paid − ledger_fee) from the escrowA 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 treasuryRejection-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 outcomeA 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 Executing past a generous TTL (and is not awaiting a live self-upgrade) is reaped to ExecutionFailed and 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 pinned created_at_time (a re-claim of an already-committed refund is an exact ledger Duplicate — never a second payment) and the same one-shot refund_processed flag. 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 via get_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_refunds to 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_funds to 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 (Backend for a failed swap → pool_manager for failed liquidity); on SONS the campaign governance canister recovers its own position. An approved ONS proposal can also trigger the underlying sweep_pool_unused_balance directly. 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_time persisted 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 ledger Duplicate mapped to success; the per-proposal refund_processed flag 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 and claim_proposal_refund are 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).
  • 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 consumes a proposal id — a harmless gap in the sequence.)
  • Auditability — FeeCollected, RefundProcessed, RefundFailed, FeeForwardedToTreasury, FeeForwardFailed, FeeForfeited and RejectionCostForwarded(/Failed) events are written to the stable event log, so a proposal’s full money lifecycle is reconstructable.

quorum + pass

quorum + fail

no quorum from Open

upload abandoned, from PendingUpload

executable

signal-only Motion

ok

trap / fail

reaper

auto fails?

auto fails?

create_proposal: allocate id → fee into per-proposal escrow

Open / PendingUpload

prepare_* (single upload, or the anchor of a batch, guardian included): fee into the upload session escrow · followers pay nothing

300s resolution timer

Approved

Rejected

Expired

Expired (abandoned upload)

execute

Full refund: fee − ledger_fee (from escrow)

Executed

ExecutionFailed

stuck Executing > TTL

Partial: fee − rejection_cost − ledger_fee (from escrow)· residue → treasury

Forfeit + FeeForfeited

claim_proposal_refund (proposer fallback)

Reference: 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.


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.

  • Only a controller of a canister can call stop_canister and start_canister on 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:

  1. ohshii_governance does not call stop/snapshot/start itself.
  2. It sends an async request (notify) to the backend: backend_orchestrate_snapshot_workflow or backend_orchestrate_restore_workflow with target = self and cleanup_backend_controller_after_start = false.
  3. The backend (already a controller) runs: stop → snapshot or load_snapshot → start.
  4. No controller change is needed; the backend remains a controller.
Management Canisterohshii_launcher_backendohshii_governanceManagement Canisterohshii_launcher_backendohshii_governancecleanup_backend_controller = false, no changenotify backend_orchestrate_snapshot_workflow(self, replace_id, None, false)stop_canister(PM)Stoppedtake_canister_snapshot(PM)start_canister(PM)Running

Reference: 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:

  1. Governance adds the OhShii backend as a temporary controller (so the backend can stop/start the governance canister).
  2. Governance calls the backend via notify: backend_orchestrate_snapshot_workflow or backend_orchestrate_restore_workflow with target = self and cleanup_backend_controller_after_start = true.
  3. The backend runs: stop → snapshot or load_snapshot → start.
  4. 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.

ohshii_launcher_backendManagement Canistersons_governanceohshii_launcher_backendManagement Canistersons_governanceupdate_settings: add Backend as controllerOknotify backend_orchestrate_snapshot_workflow(self, replace_id, None, true)stop_canister(IG)Stoppedtake_canister_snapshot(IG)start_canister(IG)Runningupdate_settings: remove Backend from IG controllersOk

Reference: 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.

MgmtGovPoolMgrStorageBackendMgmtGovPoolMgrStorageBackendalt[Pool already exists][No pool yet]On pool errors campaign set to Frozen· on a failed campaign the Failed flip is also STATUS-ONLYseal-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 retryskip vesting lock creationalt[Delayed unlock enabled][No delayed vesting]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-onlyDAO autonomous, users claim tokensfinalize_ico_logicget_campaignvalidate state and success conditionsset state Finalizing (STATUS-ONLY write — does not clobber tokens_sold/contributions)reconcile unforwarded escrows → sweep stragglers into campaign subaccountcompute liquidity ICP — min(fixed target ~90% of raise, balance − buffer)(the balance-driven branch needs curve_input_net=Some(true), which nothing live carries)transfer_from_subaccountadd liquidity to existing poolstore_campaign_subaccountcompute liquidity ICP — min(fixed target ~90% of raise, balance − buffer)(the balance-driven branch needs curve_input_net=Some(true), which nothing live carries)transfer_from_subaccountcreate pool and add liquidityupdate_campaign with pool_idexecute_post_finalization_stepsSTEP 1 batch_create_vesting_locks (idempotent — "already created" converges)store INACTIVE vesting locks, each with its own custody subaccount (custody_funded = false) · queue one funding op per lockset governance_vesting_enabledSTEP 1.5 set_pool_canister_id (BEFORE STEP 2 — re-point SONS to the live pool· idempotent)STEP 2 transfer_position_to_governance (idempotent — no-op if already on governance)STEP 2 set_position_infoSTEP 2.5 settle deferred referral (drain held 5% BEFORE the seal· fail-closed on a transient sponsor lookup)STEP 2.7 release the LEDGER to the DAO — remove backend + ohshii_governance from IC controllers, SONS governance guaranteed in the same atomic write (fail-closed: the ledger is the custody canister)STEP 2.7 release the INDEX and any ledger ARCHIVES — best-effort: they custody nothing, so an unreachable, frozen or uninstalled one is logged and finalization continues (spawned ledger archives swept best-effort)STEP A set_campaign_info (final sync)STEP B finalize_governance (autonomy SEAL — removes OhShii's privileged access + IC controllers)STEP C canister_status + update_settings — VERIFY controller removal (NotAuthorized = already autonomous when proven· transient = retry, never false-seal)update_campaign state Completedmark_lge_completed_via_dao_storage(opt blob = null)

The pool’s opening price is permanent, and the liquidity leg checks it first

Section titled “The pool’s opening price is permanent, and the liquidity leg checks it first”

An ICPSwap pool’s price is fixed when the pool is created and can never be changed afterwards — there is no re-initialise available to the platform, and a pair can only ever have one pool per fee tier. Two consequences shape this step.

Creation refuses rather than guesses. If the amounts a pool would be created from do not yield a usable price — one of them zero, or a ratio outside what the pool can represent — pool creation fails and says so (POOL_PRICE_UNCOMPUTABLE, POOL_PRICE_OUT_OF_BAND). It does not fall back to a boundary value: a pool opened at the extreme of the range prices one side at essentially zero, and every full-range position minted into it afterwards computes zero liquidity, which ICPSwap rejects. The campaign would then be unable to place its liquidity, for good.

A campaign lists at the price it configured, or it does not list. The liquidity is placed only when the pool’s price sits at the campaign’s own listing price (within one price step below it and two above). When it does not, the liquidity leg refuses before any deposit moves rather than pushing the raise into a pool at another price, and a campaign that cannot place its liquidity ends as a failed campaign whose refunds are paid.

A mispriced pool is repaired only when the campaign holds more than its refunds need. If the pool has never held liquidity at its current price — it has never been a market there, so no holder can be affected — the platform can walk its price up to the configured one, using ordinary public operations: a small position is placed at the target price, trades move the price into it, and the position is then removed. Liquidity someone else has placed along the way is traded through at the price its own owner chose, never more than 1 % past the target. The repair never spends more of the raise than the campaign’s subaccount holds above what its refunds need. The subaccount of an ordinary campaign holds exactly what its refunds need, so on such a campaign a mispriced pool is not repaired: the campaign ends as a failed campaign and its refunds are paid. Only a pool whose price sits below the configured one is repaired; a pool whose price sits above it is never repriced. When a repair sells some of the campaign’s tokens along the way, the liquidity is placed with the tokens that remain (at least 90 % of the intended amount), at the same price.

Before its liquidity is placed, only the listing itself moves a campaign’s tokens. No transfer, withdrawal, position hand-over, fee collection or treasury liquidity operation moves them, or places them in a pool, until the campaign’s own liquidity position exists. The listing can move some of them: a price repair that trades through liquidity someone else placed on the pool sells tokens to that liquidity, at the price its owner chose.

The price this targets is the campaign’s own listing price — the mechanical result of the 200M-token : pool-ICP seed ratio shown on the creation form — so it follows the creator’s chosen fundraising target rather than any fixed figure.

Campaigns created with the two-sided listing

Section titled “Campaigns created with the two-sided listing”

Every campaign created from this release on places its liquidity as two one-sided ICPSwap positions instead of one full-range position. Campaigns created before keep the full-range position described above, unchanged, with one exception: dao-0000000011 also places its liquidity this way when it completes. Its contributions, refund pot and refunds are unchanged, and the price repair described above never runs for it. Its pool has ICP as the first token, so its ICP side lies above the listing price and its token side below it; the steps below are the same with the two sides mirrored.

  • During the LGE the pool holds no campaign position. The pool is still created at campaign creation, at the listing price, and stays empty. Until the campaign’s ICP side exists, pool_manager refuses every transfer of that campaign’s tokens out of its own account, unless the campaign has ended.

  • An empty pool may be removed by ICPSwap. ICPSwap may remove a pool whose total value stays below about 1 USD for about a month. Anyone can add an ICP-only position to the pool; the campaign does not require one, and such a position stays under the control of whoever adds it.

  • Optional position at creation. The creator may choose to fund a small ICP-only position (0.01 to 10 ICP) inside the creation payment. It is placed in the pool when the campaign is created, entirely on the ICP side of the campaign’s listing price (the price set by the seed ratio, at which the pool is created), so it holds ICP only and sells no campaign tokens. The creation form suggests an amount computed as 1 USD × 3 ÷ the ICP price read on-chain from ICPSwap’s ICP/ckUSDC pool (rounded up to 0.01 ICP, at least 0.1 ICP), and shows no suggestion when that price cannot be read. ICPSwap’s rule on low-value pools is ICPSwap’s and can change; the form points the creator to ICPSwap for it. Where the position goes:

    • the campaign completes: it becomes part of the campaign liquidity and passes to the campaign’s governance canister with the rest;
    • the campaign fails: it is removed and its ICP is sent to the creator, less two ICP ledger fees;
    • the creation fails after payment: the amount is refunded with the rest of the payment.

    No step of completion depends on this position.

  • Completion does not depend on the pool created at the start. Before adding liquidity, pool_manager asks the ICPSwap factory which pool the pair has now:

    • the pool created with the campaign: it is used;
    • a pool someone else created for the pair after ICPSwap removed the first one: it is used. If its price is lower than the campaign’s listing price, pool_manager first moves it up by swapping a small amount of ICP in; a pool that holds no campaign tokens exchanges nothing in that swap;
    • no pool: pool_manager creates one at the campaign’s listing price. The ICPSwap creation fee (passcode) is paid by the creator or the community, never by the platform treasury. Each campaign has its own funding account on pool_manager (a subaccount derived from the campaign id). Until it holds the live passcode price plus three ICP ledger fees, completion stops by name (LP_PASS_DEFERRED_POOL_GONE_AWAITING_PASSCODE_FUNDING): the status names the amount still missing and the account (as pool_manager + subaccount and as an account id), nothing moves, and the campaign stays Frozen. Anyone may send the amount. The next completion attempt then moves exactly the passcode price plus two fees to pool_manager’s main account, buys the passcode, creates the pool and continues. If nobody funds it, the campaign’s usual completion window applies: the day-14 failsafe marks it Failed, and every buyer is refunded from the campaign’s own subaccount, which the passcode never draws on. Whatever the funding account holds beyond what the passcode used is sent, less one ledger fee, to the campaign’s governance canister if the campaign completes, or to the creator if it fails, including amounts that others sent.

    The campaign’s records, and its governance canister, then point at the pool that holds the liquidity.

  • At completion the ICP side is added first. The whole raise is added as an ICP-only position over the range below the launch price. Because the position lies entirely on the ICP side of the price, it takes the whole raise wherever the pool’s price stands. The campaign is marked Completed after this step.

  • The token side follows as an automatic job. The launcher’s housekeeping asks pool_manager to add the LP token allocation as a token-only position above the price, every few minutes until it lands. The whole allocation is deposited into the pool first, and the campaign’s tokens stay locked on pool_manager until the token side exists. If the pool’s price has moved past the launch price in the meantime, the attempt first sells 1 % of the allocation into the pool to bring it back, and issues the mint together with one last sale of a single token unit (both calls leave in the same message), which undoes a price move that landed after the sale when the two run back to back. Other messages can still run between them, so this is a second layer; the named stops below are what bounds a listing that does not land. Across all attempts these automatic sales stop at 10 % of the allocation; the job then stops and says so (MODEL_E_MOAT_CAP_REACHED), naming the way to continue: the campaign’s governance canister’s voters adopt a Motion whose text contains MODEL_E_LIST_NOW:<campaign id>, and anyone relays it to pool_manager with model_e_list_now(campaign id, proposal id). pool_manager reads the proposal from that campaign’s governance canister and refuses anything but an adopted Motion carrying that campaign’s marker; a second relay changes nothing. Each proposal of a campaign is read at most once a minute, whoever relays it, and the limits depend only on the order (campaign, proposal), never on who sends it, so relays from many fresh principals cost an honest relayer nothing. A relay never reads the DAO’s newest Motion: pool_manager reads it itself, in its automatic pass (driven by the backend’s housekeeping about every 5 minutes), for each campaign stopped where a relay can carry an order, a few campaigns per pass, the stalest first. A relay for a proposal above the newest Motion read so far, or sent before the first read, is refused before it runs (MODEL_E_RELAY_FRONTIER_BUSY, or “has no Motion above”); nothing moves, and it is accepted once a later pass has read the Motion. A Motion runs for days, so its relay normally passes on the first try; the wait is visible only right after a pool_manager upgrade (until the next pass) or for a Motion relayed within minutes of its adoption. The page sends a refused relay again by itself for up to about 6 minutes. The campaign’s governance canister is looked up again for every relay (MODEL_E_SONS_UNREADABLE when that lookup fails). A relay that would change nothing (the listing is already done, the hand-over has happened, or the order is already recorded) is turned away before it runs, with the refusal it would otherwise return; the anonymous principal cannot relay. The order moves no funds and does not change the listing price. No other party can give it: the guardian lever that could, on the launcher backend, now refuses (MODEL_E_GUARDIAN_LEVER_RETIRED).

    After the order, every attempt that finds the price past the launch price sells one more 1 % of the allocation and then adds the token side, until it lands, the job stops by name (below) or the allocation is exhausted. The token side is always placed from the launch price: each sale stops once the price is back there, and when liquidity someone else placed on the way absorbs the sale, nothing is placed and the next attempt sells again. A failed attempt moves nothing else; the tokens stay available for the next attempt. While the allocation waits in the pool for the token side, every other operation that would withdraw pool_manager’s balance from that pool is refused. If the orders’ sales use up the whole allocation before the token side can be placed, the job ends by name (MODEL_E_LP_ALLOCATION_SOLD_OUT): no token side exists, and the ICP the sales received goes to the governance canister as below.

  • A listing that does not land is bounded. Before any order of the campaign’s governance canister, the automatic job sells at most about 10 % of the allocation, at about the launch price, and then stops by name: at the 10 % stop above or, possibly earlier, at MODEL_E_LISTING_BLOCKED, after several attempts (about half an hour) that could not land. After an order (a list-now Motion, or a resume from that stop), every attempt that does not land counts, so the job again stops by name after several such attempts and waits for the next order; it also ends by name if the allocation is used up (MODEL_E_LP_ALLOCATION_SOLD_OUT). And 7 days after the ICP side, pool_manager stops the job and hands what it holds for the campaign to its governance canister (below). At MODEL_E_LISTING_BLOCKED the job records what happened, which get_campaign_liquidity returns: the number of attempts, the tokens they sold and the ICP they received, everything sold so far, and the pool’s tick when the job stopped. The ICP still goes to the campaign’s governance canister, as below. One cause that produces this is how ICPSwap’s SwapPool works (version 3.7.0): no call moves the price and adds liquidity in one message, and a swap has no limit on where the price stops, so while the pool has no liquidity above the launch price its price can move between the two steps for the cost of ledger fees. The campaign page shows the numbers, this explanation, and a ready proposal text for the ICPSwap DAO (a call that swaps up to a price limit and adds liquidity in the same message). To resume, with the count back at 0: the governance canister’s voters adopt a Motion whose text contains MODEL_E_RESUME_LISTING:<campaign id>, and anyone relays it with model_e_list_now(campaign id, proposal id) (each proposal resumes once; a MODEL_E_LIST_NOW Motion is refused at this stop). The order moves no funds by itself, does not change the listing price and does not lift the 10 % stop; the relay also runs one listing attempt right away. A Motion runs for its whole voting period (at least 3 days), so an order meant for the window below must be proposed in time.

  • After 7 days the listing is the governance canister’s. If the token side has not been placed 7 days after the ICP side was added, pool_manager stops the job for good and, once the campaign has completed (below), hands to the campaign’s governance canister (its own account) what it holds for the campaign: the LP tokens it withdraws from the pool and any not yet deposited, the creator’s seed position if there is one, and, by the leg below, the ICP the sales received. Each leg is recorded before it is sent and resolved from balances after a lost reply, so nothing is sent twice. The part withdrawn from the pool counts only once it has ARRIVED in the hand-over subaccount (ICPSwap pays a withdraw from a queue after debiting it). If the pool’s payment fails, the hand-over stops by name (MODEL_E_HANDOVER_STOPPED_POOL_PAYOUT_MISSING) and checks again for up to one hour; then it records the missing amount and finishes. The hand-over subaccount and the moat-proceeds subaccount only ever pay the campaign’s governance canister: whatever reaches them later (for example ICPSwap returning a failed payment) stays there until anyone calls model_e_sweep_to_dao(campaign_id), with or without a wallet, which sends it to that governance canister once the campaign has completed. The destination is fixed; the caller cannot change it. A call that would be refused is, as a rule, turned away before it runs, and admitted sweeps are limited per campaign and across all campaigns (MODEL_E_SWEEP_BUSY_GLOBAL: try again in about ten minutes). Nothing reads these accounts on a schedule after the hand-over. get_campaign_liquidity returns the deadline while it runs and then the hand-over (MODEL_E_HANDED_TO_DAO, the amount and the destination). After MODEL_E_HANDED_TO_DAO no order resumes the job, and the governance canister completes the listing with its own proposals (for example AddLiquidityToPool or CreatePoolAndAddLiquidity). AddLiquidityToPool is a critical proposal: with the default settings it needs at least 20 voters and 350,000 voting power, so a campaign DAO with fewer voters cannot pass it. Proposing it, like any proposal of the campaign DAO, takes a 30,000-token deposit in liquid campaign tokens (the guardian excepted); a buyer’s tokens sit in vesting locks until the first tranche unlocks, one vesting period (7 or 30 days) after activation.

  • Nothing goes to the governance canister before the campaign has completed. Every payment to the campaign’s governance canister described here (the hand-over, the ICP the sales received, model_e_sweep_to_dao) waits until pool_manager has seen the campaign Completed, so that its contributors’ vesting locks and voting power exist first. It then starts on its own. A campaign that never completes sends nothing to its governance canister, and model_e_sweep_to_dao is refused (MODEL_E_WAITS_FOR_COMPLETED) until then.

  • The ICP the sales receive goes to the campaign’s governance canister. Once the token side lands, or the job has stopped by name (at the 10 % stop, at the blocked-listing stop, at the end above, or at the hand-over), pool_manager withdraws that ICP from the pool to a subaccount of its own derived from the campaign id, then, once the campaign has completed, transfers it to the campaign’s governance canister. Nothing is added to the pool after the token side exists. Each leg is recorded before it is sent; a lost reply is resolved from pool_manager’s own balance in the pool, so a retry never sends it twice. After an order, the next sale waits until a transfer in progress has finished.

  • One campaign liquidity. The positions (the ICP side and the token side) are handed to the campaign’s governance canister, and get_campaign_liquidity on pool_manager lists them together, with the ICP owed and sent to the governance canister.

If the mint fails after the deposit, the ICP goes back to the campaign. When the liquidity mint fails after the ICP was deposited into the pool, the amount this attempt deposited is withdrawn back to the campaign’s own subaccount (never to a shared account), and the next attempt starts again from there. The returned amount is derived from what this attempt deposited, so it never includes anything else held in the pool. If the withdrawal itself fails, the ICP stays in the pool and the amount is recorded as owed to the campaign, with its pool and subaccount. The return is then repeated from that record (retry_campaign_icp_return), always to the campaign’s own subaccount and never through a shared account. The launcher’s hourly finalize retry and guardian_retry_finalization repeat an owed return first, before they read the subaccount; if it is still owed the retry is skipped for that hour and is not counted as a failed attempt, and the owed amount counts as the campaign’s own ICP when the retry checks its deposit. The guardian can also repeat it on its own (guardian_retry_campaign_icp_return, which only forwards to the pool manager). While it is owed, the campaign cannot be marked Failed, because its refunds would be paid from a subaccount missing that ICP. While it is owed, or while one of the campaign’s deposits into the pool has an unresolved outcome, nothing moves the pool manager’s unused ICP on that pool to its own account (no sweep, liquidity removal or fee collection runs there), and the record is cleared only when the return is paid: a retry that finds the owed ICP gone from the pool after a definite refusal keeps the record, says so by name, and the campaign still cannot be marked Failed. The one amount that does not return is the small repair position described above: ICPSwap pays it out to the pool manager’s own account when it is removed, and that outflow is logged.

Vesting-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. Before it runs, finalization checks that the governance canister actually holds the total the vesting locks need; if it does not, finalization stops with VESTING_UNFUNDED, the campaign stays Frozen with no locks created, and a retry completes once the tokens are there. 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)) and custody_funded = false. The per-contributor unlock-fee reserve covers N unlock 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·fee from the governance main account into that lock’s subaccount, idempotently (balance-gated, pinned dedup so a retry never double-funds), then flips custody_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/TreasurySwap proposal 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:

  1. 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 is Active, Frozen, or Inactive (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.
  2. 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.
  3. 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.
  4. 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.
  5. Observability: success emits a LockStateChanged stable event (recipient_changed / recipient_permanent_set); business-rule failures emit ErrorOccurred — both surface on the per-DAO observability panel.

Notes: these methods exist only on a SONS canister running a wasm that includes them (on an older one the UI shows “upgrade pending”); 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”.

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 privileged 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 verify-custody-then-fail chokepoint (resolve_wedged_lge_to_failed): pool_manager must confirm that no liquidity position was minted and that the pool’s ICP deposit account holds at most dust — i.e. the raise is entirely in the campaign subaccount — 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; a campaign whose raise is parked in the pool’s deposit account (a finalize ICP leg transferred but not yet credited) is refused by name (FAILED_FLIP_RAISE_IN_POOL_DEPOSIT_ACCOUNT) and the failsafe drives the finalize retry instead, whose ICP leg completes the deposit from its per-campaign record. For a campaign whose leg ran under that record, the pre-flight counts only the campaign’s own ICP in the deposit account (capped by the account’s balance); ICP another flow parked there does not block its flip. 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; every Failed write — the failsafe, the guardian lever, the housekeeping classifier and the raw admin_update_campaign_status setter — asks pool_manager’s preflight_campaign_failed_flip first and is refused by name while the pool’s deposit account holds the campaign’s ICP (recovery: sweep_pool_unused_balance, then proposal_pool_manager_withdraw back to the campaign subaccount).

The finalization deposit and the failed-attempt fees. Each failed finalization pass costs the campaign subaccount its ledger fees (0.0004 ICP on the first pass, 0.0003 ICP on each later one). A pass that pool_manager defers (LP_PASS_DEFERRED_*: refused before anything moved) costs nothing and is not counted as a pass that moved funds, so it neither consumes the deposit nor explains a shortfall; it still counts as an automatic attempt toward the failsafe floor, so a pool that stays busy cannot keep a campaign from its day-14 resolution. For a campaign created with a finalization deposit (section 2.5.4):

  • the hourly automatic retries run only while the deposit can still cover the fees consumed so far, one more pass (0.0004 ICP) and the deposit’s own two settlement transfers; after that they stop, and the stop is recorded;
  • the 14-day failsafe then fires even with fewer than 24 recorded attempts: the stop waives the attempts floor, including when the stop is that the deposit never arrived (no automatic retry is paid from the refund pot meanwhile; if the deposit lands later, the automatic retries resume);
  • a deposit that never reached its account is returned to the creator: within 24 hours of the creation the payment’s sweep can re-send it, and after that window the sweep reads the deposit account, and when it holds less than the deposit (the deposit never landed; a stray amount sent to that public account does not change this) it sends the deposit from the creation payment back to the creator, once, with a pinned transfer. The deposit account cannot be emptied by a treasury transfer (FINALIZE_RESERVE_ACCOUNT_REFUSED), so that reading cannot be faked into a second payment;
  • guardian_retry_finalization (and the other retry levers) can still run an attempt after the stop, but only while the worst case of one more failed pass keeps the refund pot within 10 ledger fees of what the refunds draw; when the deposit never arrived, and for a campaign created before the deposit existed, the same bound applies with nothing added by a deposit; otherwise the attempt is refused by name (FINALIZE_RETRY_WOULD_BLOCK_FAILED_FLIP), with the reason and what unblocks it: the Failed flip while the gap is within the tolerance, and beyond it a transfer of the exact gap into the campaign subaccount. In those two cases (no deposit arrived, or a campaign created before the deposit existed) an attempt the bound would refuse is still admitted when pool_manager confirms that the campaign’s liquidity position exists: the raise is then already in the pool, a retry moves nothing out of the refund pot, and the campaign cannot be marked Failed anyway. If pool_manager cannot answer, the attempt is refused, and that refusal does not ask for any transfer; nor does the refusal beyond the tolerance while the campaign is not Frozen (a finalize pass is running or stopped mid-way) or while pool_manager still holds part of the raise (its ICP leg left in the pool’s deposit account, or a failed-mint return it still owes): there the transfer would not let the campaign be marked Failed, and the refusal names what to wait for or which lever to run instead;
  • at the Failed flip the deposit tops the refund pot up to what the refunds draw plus a margin of 10 ledger fees, capped by what the deposit holds, with one pinned transfer; what the deposit does not pay is returned to the creator by the next housekeeping pass. On a completion the whole deposit is returned.

One tolerance for every Failed flip. Every Failed write, with or without a deposit, is refused by name (FAILED_FLIP_REFUND_POT_SHORT) while the refund pot would stay short by more than 10 ledger fees, the most a refund leg absorbs by paying the last claimant its balance less one fee; the refusal names the amount, where the value can be, and the recovery path. get_finalize_retry_status(campaign_id) reports the refused flip (shortfall, refusal text, whereabouts), the automatic attempts, the next attempt, the deposit consumed and remaining, and the earliest automatic Failed flip. Campaigns created before the deposit existed keep automatic hourly retries until the failsafe, as before; their guardian-forced retries follow the bound above.

When the failsafe is due but the pot is short. Once the failsafe is due, a Failed flip refused only because the refund pot is short no longer schedules another retry: each one would be paid from the buyers’ pot and widen the gap. The campaign stays Frozen in a named state (FAILSAFE_HALTED_POT_SHORT in get_finalize_retry_status, kept on the campaign’s stable wedge record so it reads the same right after an upgrade), which names the exact amount missing and the account to fund (the campaign subaccount of pool_manager); the refusal says whether the gap is failed-pass fees or a missing raise; the flip is asked again every hour at no cost to the pot, and it goes through as soon as the gap is within the tolerance. Before the failsafe is due the same refusal says the automatic retries still run. The failsafe keeps driving the finalize retry for every pool_manager refusal a retry resolves (FAILED_FLIP_RAISE_IN_POOL_DEPOSIT_ACCOUNT, FAILED_FLIP_DEPOSIT_UNRESOLVED, FAILED_FLIP_ICP_RETURN_PENDING), and asks again next hour on an inconclusive probe. A refusal name it does not know halts by name instead (FAILSAFE_HALTED_UNCLASSIFIED_REFUSAL): nothing is paid and the flip is asked again every hour.

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 terminal Completed/Failed campaigns — it recovers a pre-completion Frozen or stranded Finalizing one — 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.

If a step cannot complete, the campaign is rolled back to Frozen and the work is resumed later — it is never left half-committed. The steps that move or hand over value are deliberately fail-closed, and every one of them is written so that running it again after a partial pass converges instead of doubling. What that means in practice is that a finalization interrupted by an unreachable canister, an out-of-cycles callee or a transient error is a pause, not a loss.

Finalizing

Frozen + sold out

yes

no, cause still present

top the canister up

finalization interrupted

campaign state

hourly re-drive pass

retries automatically

hourly failsafe pass

retries automatically

completed?

Completed — DAO autonomous

stays Frozen, retried next hour

ONS guardian: retry finalization

re-drives the whole pipeline on demand

ONS guardian: retry pool creation

only when the pool job itself is frozen

cause outside the platform

e.g. the token ledger has no cycles

What the ONS guardian can do, and what it cannot. The guardian can re-drive a campaign that is Frozen or stranded in Finalizing, on demand and without waiting for a vote — the same pipeline, from wherever it stopped. It cannot touch a campaign that has already settled: a Completed or Failed campaign is refused by name, so the lever recovers a wedge and can never revisit a decided outcome. It also cannot force a campaign that has neither reached its target nor expired, and it cannot mark a sold-out campaign as failed while its liquidity position exists. Where the obstacle is outside the platform — most plainly, a token canister that has run out of cycles — the retry keeps failing by design until the canister is topped up, and then converges on the next pass.

Finalize deposit (per-campaign record, retry-safe)

Section titled “Finalize deposit (per-campaign record, retry-safe)”

How much of the TOKEN side goes in, and why it is not the configured figure. The pool must open at the price the campaign configured, so the token leg is balanced against the ICP that actually arrives:

tokens deposited = liquidity_tokens × ICP deposited / target_icp_for_liquidity

(adapt_lp_token_amount; a creator’s custom_icp_liquidity_e8s replaces the target as the denominator.) An under-target raise therefore adds a smaller but balanced position rather than stranding unminted tokens — and a campaign that raised exactly its target still deposits marginally fewer tokens than liquidity_tokens, because the ICP arrives one ledger fee short of the announced amount and the token side follows it.

For example, a campaign that raised its target deposited 19 999 995 555 555 334 tokens against a configured 20 000 000 000 000 000 (≈44.4 tokens less, which is not a fee), while dao-0000000011, whose draw was 441.1 ICP against a 450 ICP target, deposited 0.98024 of its configured tokens. The pool’s unused_balance is 0 for both assets afterwards: everything deposited is minted into the position.

The single money leg that moves the raise into the pool is pool_manager’s deposit from the campaign subaccount into the pool’s pool_manager-derived deposit account, followed by the pool’s deposit. The raise never transits the pool_manager main account, so there is no intermediate hop to pin and no shared account ever holds one campaign’s raise. The division of labour:

  • The backend decides how much. On every finalize pass it resolves the deposit amount from the campaign subaccount’s live balance — a creator custom_icp_liquidity_e8s override wins; otherwise the balance minus the leg’s two ledger fees (FINALIZE_DRAW_BUFFER_E8S, 20,000 e8s), capped at the fixed 450 × k target for legacy campaigns — and validates it before anything moves: a resolved amount below 1 ICP (FINALIZE_MIN_LIQUIDITY_E8S = 100,000,000 e8s) is refused unless custom_icp_liquidity_e8s is explicitly set (FINALIZE_DUST_FLOOR); an unreadable balance fails closed (FINALIZE_DEPOSIT_BALANCE_UNAVAILABLE); an amount the subaccount cannot pay together with the two fees is refused (FINALIZE_DRAW_UNAFFORDABLE — the usual trigger is an unclamped override). Every refusal leaves the raise untouched in the subaccount, rolls the campaign back to Frozen, and is retriable.
  • pool_manager moves it. The ICP leg names its source explicitly — the campaign subaccount on this path, its own main account only for the emergency manual_add_liquidity path — and refuses the reserved all-zero subaccount by name (LP_LEG_RESERVED_SUBACCOUNT) before any transfer, since that value is the main account itself. The campaign subaccount pays the deposit plus two ledger fees: the transfer into the pool’s deposit account, and the pool’s own move from that account into its main account. With the resolver’s buffer equal to that overhead, a net-basis campaign’s subaccount is emptied exactly.
  • Idempotency comes from a per-campaign record, not the account balance. The pool’s deposit account is one per (pool, pool_manager): a treasury operation on the same pool parks its ICP there too, so its balance cannot say how much of it is this campaign’s. pool_manager keeps a durable record per campaign (icp_leg_in_flight_e8s(campaign_id), a query the backend can call) of the ICP its leg put there and the pool has not yet taken. The leg sends only what is still missing toward amount + fee; it journals each transfer before sending it, pinned (created_at_time), and re-sends one whose reply was lost as the same transfer, so the ledger applies it once. It journals each deposit with the account balance and pool_manager’s unused balance in the pool, both read just before it. When a deposit loses its reply, it reads the unused balance at once: a deposit that landed shows there, and is recorded as landed. It is also recorded as landed when the same attempt’s return of its own ICP finds that ICP in the pool. Otherwise the next retry resolves it from the account balance, and fails closed by name (ICP_LEG_DEPOSIT_UNRESOLVED) whenever anything has flowed into the account since, so another flow’s ICP is never deposited into the campaign position. After a day, the guardian can have such a deposit resolved from the ICP ledger’s own history of that account (guardian_resolve_unlanded_campaign_deposit): it is recorded as not landed when the ledger shows no transfer out of the account since the deposit was sent, or as landed when it shows the deposit’s own transfer, and any part of a landed deposit not already returned to the campaign is then recorded as owed back to the campaign’s subaccount. The lever takes only the campaign id, moves no ICP, and refuses every other state. A sweep of the pool’s deposit account clears the records of the ICP it moved. The record is capped by the account’s live balance before each transfer, so ICP moved out of the account by another path is re-sent rather than assumed. A pool that already holds the campaign’s position is skipped before any leg runs. A wedge after the transfer but before the pool’s deposit leaves the raise in the pool’s deposit account; the finalize retry completes it through the same probe — including after a transfer whose reply was lost: the backend counts the campaign’s own ICP recorded there (icp_leg_in_flight_e8s, capped by the account’s balance) when it re-checks the first pass’s deposit and when it resolves the retry’s amount (never above the first pass’s), so the retry re-sends the pinned transfer, which the ledger applies once, and finishes the deposit (the next hourly retry completes the campaign), and a refund is refused by name while the pool’s deposit account holds that campaign’s ICP (REFUND_RAISE_IN_POOL_DEPOSIT_ACCOUNT), so refunds can never under-pay against a split raise.
  • Frozen retry matrix. A Frozen campaign is force-succeeded (driven toward Completed) only with evidence: it reached its sale target (the same tolerance the housekeeping classifier uses). That predicate never un-reaches, so a campaign that once entered the liquidity leg re-qualifies on every retry. A Frozen campaign that expired without reaching target falls through to the failure branch and converges to Failed so refunds unlock. A Frozen campaign that neither reached target nor expired is refused (OHSHII_FINALIZE_FROZEN_NOT_READY) — creation-failure placeholders and mid-curve freezes must never be one-click funded toward a pool by an accidental retry.
  • Concurrency. A second finalization pass while one is mid-flight returns FINALIZE_ALREADY_IN_PROGRESS — a distinct prefix so a caller (or the retry-liquidity proposal) can tell “in progress” apart from a completed pass and from a real failure.

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). A relaunch driven by OHSHII governance (ONS) or by the backend’s authorized caller is 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 Frozen state — a Completed or Failed LGE is terminal and can never be relaunched (this terminal-state guard is enforced unconditionally, including for a 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_lost confirms the loss against the ICPSwap factory — the backend queries the factory’s getPool for 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 force path exists to override only the pool-loss probe, for a pool that is broken in a way the probe cannot detect. It is reachable by the backend’s authorized caller, including an approved OHSHII governance proposal: because governance reaches the backend through the free-form call target, such a proposal is always at the critical threshold and is guardian-vetoable. The override still cannot relaunch a Completed or Failed campaign, still cannot relaunch a campaign whose SONS governance has been sealed (the repoint would be refused by that DAO whoever asked, so the attempt could only burn a fee), and does not bypass the fund-safety of the flow.

If a relaunch gets stuck. A relaunch is a multi-step rebuild, and a step can fail in the middle — leaving the campaign marked as relaunching, or a later step owed a retry. Two recovery entry points cover that, and three parties can reach them:

  • the campaign’s own DAO guardian — the single principal that campaign’s governance canister currently accepts, which is a different question from who holds the platform’s own guardian role. This works today, without a vote, so a campaign can unwedge itself while the thing that is wedged is the campaign. It is rate-limited to a small number of recovery calls per campaign per hour, shared between the two entry points, so a looping client cannot hammer them; the refusal names the limit, the campaign and when to retry, and is written to the campaign’s public relaunch log.
  • an approved OHSHII governance proposal. Clearing a stuck marker is classified critical and is guardian-vetoable, because forcing it while a rebuild is still running abandons the canisters that rebuild had already created. Retrying a step is standard: it replays a payload the flow already saved and creates nothing.
  • the backend’s authorized caller for its other privileged recovery methods.

Neither entry point moves any value, and neither can pay an address a caller chooses.

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_options at ledger init — they apply to every archive the ledger spawns later):
    • New deploys: primary controller is the campaign’s sons_governance canister, with NNS Root and CycleOps in more_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_governance and the governance canister in more_controller_ids; finalization sweeps any such archive (see STEP 2.7 below), and the DAO can also remove leftover platform controllers itself via a SONS RemoveController proposal.

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_governance are removed from the governance config.admins field and finalized = true is set,
    • A campaign DAO whose guardian is empty receives one. The backend calls finalize_governance : (opt principal) with the campaign’s post-LGE guardian (the guardian chosen at creation, else the creator). The governance canister writes it only when its own guardian is empty, in the same message that sets finalized = true; a guardian that is already set is never replaced. The offer passes the same validate_guardian_choice check as every other guardian-set entry point: an anonymous offer writes nothing, and an invalid principal is refused without stopping finalization. A fill is recorded as a post_lge_guardian_filled lifecycle event; a refusal is recorded as a post_lge_guardian_fill_refused error event that names the offered principal and the reason. The argument is optional on the wire, so a caller that omits it and a governance canister that predates it both keep working,
    • finalize_governance reads the configuration again after its last inter-canister call and changes only the fields it owns (the admin set, the guardian fill, finalized), so a configuration change that lands while finalization waits on another canister is kept, not overwritten. If another finalization completed in the meantime, it returns Governance already finalized.
  • An upgrade of a campaign governance canister applies a one-time correction only to a canister that carries the LGE completion seal, was never finalized, and whose configuration was read successfully: it records the missing dao_storage reference and clears that seal, so the canister’s own finalization can run. A finalized canister, or one whose configuration could not be read, is left untouched.
  • An upgrade of a campaign governance canister whose new module cannot read its configuration, its campaign data or its pool information (a stored value that does not decode, or a storage cell whose header is not recognised) is refused: the canister keeps its previous module and its stored bytes unchanged, and the error names each unreadable value. No default is ever written in their place, and while one of them cannot be read every write to it is refused.
  • execute_post_finalization_steps also 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 atomic update_settings that is guaranteed to contain the campaign’s sons_governance canister (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 to Frozen and the retry converges idempotently (a “not a controller” rejection means the release already happened).
    • The ledger leg is the one that must succeed: it is the canister that holds the token supply, so a failure there rolls the campaign back and the retry converges.
    • The index leg is best-effort. An index canister serves transaction lookups and custodies nothing, and it can be out of cycles, frozen, or uninstalled for reasons that say nothing about the campaign’s money — so a failure there is logged and finalization continues, leaving at most a controller entry the DAO can clean up later. Making it blocking would let an auxiliary canister strand a campaign that had already placed its liquidity.
    • 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 RemoveController proposal.
  • execute_post_finalization_steps finally seals governance autonomy:
    • Reads the current IC-level controllers of the governance canister,
    • Removes the backend and ohshii_governance from that controllers list, keeping at least one controller (governance, NNS Root, and monitoring controllers such as blackhole/CycleOps),
    • Calls update_settings on 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_governance canister plus the same safety/monitoring controllers — the OhShii backend and ohshii_governance are removed,
  • OhShii backend and the global OHSHII governance (ohshii_governance) no longer hold any privileged or controller position 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).

A campaign becomes Failed when:

  • It expires without selling out (housekeeping check_expired_icos or check_and_update_campaign_status sets state to Failed when expiry_timestamp has passed), or
  • Finalization is run on a campaign that did not reach the token target (e.g. finalize_ico is 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_timestamp by 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 (or, for a campaign created with a finalization deposit, with its automatic retries stopped by the deposit): the wedge failsafe resolves it to Failed through the verify-custody-then-fail chokepoint — pool_manager confirms no liquidity position exists and the pool’s ICP deposit account holds at most dust, so the refunds that open can always pay; while the raise is parked in that deposit account the flip is refused by name and the finalize retry completes the deposit instead, and every refused attempt leaves a [FAILED_FLIP_REFUSED] line in the campaign’s own log (get_campaign_logs), so the repeated retries are visible from the campaign’s register. 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 to Completed).

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. The campaign subaccount holds the net that was forwarded into it, so the refund can never over-draw. Before any ICP leaves that subaccount, pool_manager runs a pre-flight on the campaign’s ICPSwap pool: if the pool’s ICP deposit account (owned by the pool, keyed by pool_manager’s principal) holds more than dust, a finalization leg was transferred there but never credited, and the refund is refused by name (REFUND_RAISE_IN_POOL_DEPOSIT_ACCOUNT) with nothing paid, rather than paid at a reduced amount from a partly emptied subaccount. The backend rolls the refund mark back and the request is retried once the parked leg is resolved; an unreadable balance refuses too (fail closed, retriable). The legs are already net of one ledger fee each (split_refund_legs_e8s), so the pot covers every contributor by construction; what can still leave a gap is a MANUAL un-park, which charges the pot ~5 fees. A shortfall within ten ledger fees is therefore paid short rather than refused (REFUND_PAID_SHORT_BY_FEES), a larger one is refused by name (REFUND_POT_SHORT_BEYOND_FEE_COVER) and an empty pot outright (REFUND_POT_EMPTY). The amount SENT is pinned per (campaign, buyer) — full or short — so a retry re-sends it identically and the ledger dedups: the pinned created_at_time only makes a retry safe while the amount is identical too. If, after such a recovery, the campaign subaccount is short of a refund leg by a handful of ledger fees (the round trip costs the pot five), pool_manager covers the shortfall from its own main account before paying — at most ten fees, pinned per contributor so a retry cannot cover twice, and logged by name (REFUND_FEE_HOLE_COVERED_FROM_MAIN); a larger shortfall is refused by name (REFUND_POT_SHORT_BEYOND_FEE_COVER) with nothing paid.

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.

OHSHII ledgerICPLedgerpool_managerdao_storageBackendFrontendUserOHSHII ledgerICPLedgerpool_managerdao_storageBackendFrontendUserCampaign becomes Failed (expiry or finalize on non-sold-out)Within 7 days of expirypre-flight — the pool's ICP deposit account must hold ≤ dust, else refuse by name (nothing paid)transfers from campaign subaccount (no re-applied 90%)alt[referral_leg > 0 (deferred campaign with ESCROW rows)]idempotent (mark-before-pay)· NotOwed if the user is still a participantopt[Guest paid the one-time OHSHII guest fee (try_mark_guest_fee_refunded → Pay)]update_campaign state = FailedRequest refund (campaign_id)request_refund(campaign_id)get_campaignvalidate: state Failed, deadline, has contributionsmark_contributions_refunded_atomic(user, campaign_id)Okcompute refund — 90%-of-gross·creator 70/90 of that (every live campaign)split_refund_legs_e8s → (campaign_leg, referral_leg)(held funds only, each net of ledger fee)process_single_refund(campaign_id, user, campaign_leg_e8s)transfer_from_subaccount(campaign subaccount, user, campaign_leg)transfer (ICP to user)Okrefund_referral_leg(campaign_id, user, referral_leg_e8s)(ESCROW rows only· pre-escrow rows folded into campaign leg)transfer_from_subaccount(referral subaccount, user, referral_leg)transfer (ICP to user)Okrefund_guest_fee_on_failure — icrc1_transfer (OHSHII guest fee back to user, from the per-campaign guest-fee sub)RefundResult::OkRefund successful
  • Refund deadline: 7 days after campaign.expiry_timestamp; after that, request_refund returns 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 separate refund_referral_leg from 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).
  • 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 same mark_contributions_refunded_atomic + process_single_refund (campaign-subaccount) path; it is idempotent. Storage also exposes admin_clear_refunded_status and admin_mark_all_refunded for bookkeeping.
  • Refund below the ledger fee: a refund whose amount does not exceed the ~0.0001 ICP ledger fee its transfer would bear (in practice the creator’s own seed row of a campaign) is not sent: request_refund and refund_campaign answer REFUND_BELOW_LEDGER_FEE with nothing sent and nothing marked, and give the same answer on every later call. The campaign’s refund pot pays no fee for it, so it never reduces what other contributors receive.
  • Guest fee refund (OHSHII): if a contributor paid the one-time guest fee (charged only to Guest-tier participants on their first contribution), request_refund and the bulk refund_campaign also 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 calls drain_campaign_referral with 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.


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):

  1. The approved proposal calls proposal_treasury_create_custom_pool / proposal_treasury_add_liquidity on the backend.
  2. 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.
  3. The backend calls the pool_manager, which runs its own journaled saga: for a create, a deterministic factory getPool probe 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 plausible metadata() is rejected).
  4. 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 from unused-balance deltas, and a completed op returns success without moving funds).

Remove to treasury (pool_manager):

  1. 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).
  2. decreaseLiquidity (fatal on error, amounts recorded) → claim (pending fees, recorded) → withdraw of 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, pinned created_at_time) from the pool_manager main account to the backend treasury. ICPSwap also pays the removed amounts out on its own queue: when that payout reaches the main account first, the explicit withdraw finds nothing to withdraw, and the removal waits for the arrival instead and forwards what arrived, less the ledger fee.
  3. 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:

  1. 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.
  2. Create — create_proposal with 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: on ONS, aaaaa-aa, the governance canister itself, the OHSHII / ICP / cycles ledgers, the OhShii Locker lock_storage, the locker backend and the locker strongbox; on a SONS DAO, aaaaa-aa, the governance canister itself, the ICP / cycles ledgers and its own campaign ledger + index. Creation fails closed if the target does not publish a parseable candid:service, if method_name is absent from it, or if the text does not type-check against the signature — the proposal is never created against an un-encodable call.
  3. Vote — the standard CRITICAL pipeline.
  4. 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).
Target canisterManagement Canisterohshii_governance or sons_governanceProposerTarget canisterManagement Canisterohshii_governance or sons_governanceProposer7-day critical vote (guardian-vetoable)custom_call_preview(target, method, candid_text)canister_metadata(target, "candid:service")parse service, encode text vs real signaturerender + sha256 + module_hash (no proposal)create_proposal (custom call) — re-encode + pin module_hash/sha256/renderre-verify target module_hash + controller (fail-closed on drift)dispatch pinned blob (bounded wait)reply / reject

10.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 targetProposal statusEvent record
Any reply — including a business Err/err armExecutedThe 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, flaggedcustom_call_pin.outcome_unknown = true and a CustomCallOutcomeUnknown event. The effect on the target canister has to be verified — 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)ExecutionFailedOnly here — the call demonstrably never ran, so failing it (and allowing a fresh proposal) is safe.

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:

  1. Create — create_proposal with an HTTPS-outcall target validates the request shape (a well-formed url, a max_response_bytes in 1..=2 MiB, a known transform id).
  2. Vote — the standard CRITICAL pipeline.
  3. Execute — the executor computes the real outcall cost from the request shape and max_response_bytes and 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.

ResultProposal statusEvent
2xx responseExecutederror = None
Non-2xx (e.g. 500)still Executederror = 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)ExecutionFailedstatus 0, error set

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.


  • ARCHITECTURE.md — Canister map and governance scope table.
  • 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.
  • GOOGLE_VERIFICATION.md — Google verification (Standard): the Internet-Identity attribute-bundle mechanism, one Google account per wallet, the address-normalisation rules, privacy model, governance controls and escape hatches.
  • TELEGRAM_VERIFICATION.md — Telegram verification (Strong): the flow, the privacy model, and the veto-override attestation it now carries.
  • WORLD_ID_VERIFICATION.md — World ID voter verification (Strong, legacy): flow, privacy model, configuration states including the accepts_new_proofs retirement flag, threshold-ECDSA RP signatures, rate limits and governance controls.
  • DECIDEID_VERIFICATION.md — DecideID voter verification (Strong, legacy): configuration states including the accepts_new_proofs retirement flag, the VC and OIDC paths, privacy model, rate limits and pause levers.
  • FRONTEND.md — Frontend architecture: React + JSX + JSDoc @ts-check type-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.