Skip to content

Core Workflows

This document describes the main end-to-end flows with Mermaid diagrams: LGE creation, token creation, voting, proposal creation, proposal execution (including the difference between backend WASM upgrade and frontend/asset upgrade), the proposal fee → refund lifecycle across every outcome, self-snapshot for governance canisters (with the backend as orchestrator), LGE finalization (success and failure paths), and campaign failure and refunds.


The creator sets the LGE’s fundraising target — any multiple of 500 ICP, from 500 ICP up to 5000 ICP (target_icp_for_liquidity = k × 500, integer k in 1..=10, in steps of 500; 500 ICP is the default). The bonding curve keeps the same shape and sells the same tokens at every target — 700M on the curve, 200M into the pool, 100M DAO reserve, unchanged — and only the ICP prices scale by k: ~90% of the chosen raise seeds the ICPSwap pool (450 × k ICP, 450 at the 500-ICP default) and the pool opens at 450k / 200M = 225k e8s/token (0.00000225 × k ICP/token, 0.00000225 at the default; the end-of-curve price 0.00000133 × k stays ~41% below it at every k). The 500-ICP figures used elsewhere on this page are the k = 1 example, not a fixed model — and are distinct from the one-time creation fee described next.

The user pays 5 ICP via ICRC-2; the backend creates an empty governance canister, ledger, and index (with governance as controller), installs the governance WASM, and registers the campaign in storage and pool manager. The index deployment, the governance WASM install and the pool-manager registration run in parallel (they only depend on the ledger and governance ids), and the ICPSwap pool phase is detached: the create call returns right after the campaign is stored and synced to governance, while the pool completes in a background job.

The campaign is stored as Frozen while the pool is being created, and flips to Active only when the pool is confirmed ready. Because the pool phase is detached (it runs after the create call returns, for up to a few minutes), an Active campaign would be purchasable before its pool exists — and if contributions reached the sale target before the pool was created, finalization would fail for lack of a pool. Storing the campaign Frozen blocks contributions (the purchase path gates on Active), so no funds can move until the detached job creates the pool and reveals the canister ids + pool id. On a pool-creation failure the campaign simply stays Frozen (canisters preserved) and is recoverable by the ONS guardian.

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)install governance WASMregister 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 our ICPSwap pool is created — otherwise someone could create the pool first with a wrong initial price. So the campaign record hides the ledger/index/governance ids (stored None), the token is registered in the public created-tokens registry (get_all_created_tokens) and synced to the governance canister only in the detached job’s finalize step, after the pool exists. On a pool-creation failure the ids stay hidden everywhere; the guardian retry reveals and registers them only once the pool is successfully created.

Pool-creation failure and guardian retry. If the detached job exhausts its bounded retries (a definite ICPSwap failure, or a transient failure that never resolves), the campaign stays Frozen, all canisters are preserved, and the pool job is kept (marked frozen). The ONS guardian (or an admin) can re-drive it with guardian_retry_pool_creation(campaign_id) (Shield/guardian-gated, same pre-auth model as guardian_retry_finalization): it resets the job and re-runs pool creation; on success the campaign flips Frozen → Active and opens to contributions. admin_clear_pool_job(campaign_id) is an admin give-up for a corrupt/stuck job. A stuck job whose timer chain died silently is also re-armed by the housekeeping watchdog and by post_upgrade.

Concurrency: at most 3 LGE creations platform-wide and 1 per creator may be in flight at once. The slot is reserved before the fee debit (and the scaled cycles floor is checked there too), so a capacity/cycles rejection never costs the user anything; the error message says the approval remains valid and to retry in a few minutes.

Post-LGE guardian: the creator can optionally designate a guardian for the new DAO (defaults to the creator). The anonymous principal and every OhShii infrastructure canister are rejected at prepare_ico_creation (and re-checked at campaign creation and at SONS install). The guardian can only trigger emergency/maintenance actions on the DAO — it can never access funds.

Reference: src/backend/src/lib.rsprepare_ico_creation, create_ico_with_icrc2_payment, create_ico_campaign, create_empty_governance_canister, install_governance_code, run_pool_creation_job, finalize_campaign_after_pool (Frozen→Active), freeze_campaign_after_pool_failure, guardian_retry_pool_creation, admin_clear_pool_job, sweep_stuck_pool_jobs, get_ico_creation_progress, validate_post_lge_guardian.


1.5 Participate in an LGE (purchase tokens)

Section titled “1.5 Participate in an LGE (purchase tokens)”

LGE participation follows the Voter Benefits Protocol (5 tiers: Guest / Human / Fish / Shark / Whale). See GOVERNANCE.md § Voter Benefits Protocol for the full tier table and axis definitions.

The backend is a payment gateway: business logic — bonding curve, per-user purchase limit, sale-target gate, and idempotency — lives inside dao_storage. The backend validates, fetches eligibility, and routes the ICP through a per-contribution escrow before it reaches the campaign pool.

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.

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 → tokens_received = curve(GROSS — the full ICP sent)→ 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 }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 | 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.

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).
Fast-fail UX (limit / target / minimum)dao_storage.preflight_purchase (query)1 ICC query BEFORE the transfer. Same gates as the atomic apply. ICC errors are tolerated (the apply will re-enforce the gates atomically).
ICP 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 + 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.

  • 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.
  • 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) are idempotent, on-chain, callable by the authorized canister (governance) and via self-call. No off-chain reconciler.
  • Trap between apply (counted) and forward: the contribution is counted but its net still sits in the escrow; sweep_escrow_to_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). Previously a Guest holding only ICP could not participate — they had to source OHSHII manually first. The frontend now removes that dead end: it can auto-swap ICP for exactly the missing OHSHII before the purchase, so a Guest can complete an LGE contribution end-to-end with ICP alone. This is a frontend capability (purchaseTokensWithAutoGuestFee in utils/canister.js) layered on top of the unchanged backend purchase path — the backend still receives a normal purchase_tokens_icrc2 call with the OHSHII guest fee already in the wallet.

The flow, when the caller opts in (autoSwapEnabled):

  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_SETTLEDno 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

Reference: src/backend/src/lib.rsprepare_token_creation, create_token_with_icrc2_payment.


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

Reference: ohshii_governance/execution.rsexecute_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: the proposer uploads a batch and proposes it; 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 canisterProposerBefore execution: create_batch, upload chunks, propose_commit_batch, compute_evidencealt[valid][invalid]validate_commit_proposed_batch(evidence)ValidationResult (valid / invalid)commit_proposed_batch(evidence)Apply batch (new assets / frontend content)OkExecution failed

Reference: ohshii_governance/execution.rsexecute_asset_upgrade (validate_commit_proposed_batch then commit_proposed_batch); CreateProposalForm.jsx — upgrade_frontend / Upgrade Asset flow.

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. Createcreate_proposal with a CanisterUpgradeBatch target validates the suite shape (role order, 32-byte hashes, sizes, chunking, no self-target, suite probes) and collects one standard proposal fee for the whole batch (pre-staged paid sessions are accepted per step; their payment is reused, never double-charged). For every step without a pre-staged session the canister allocates an upload session; the proposal starts 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 (operator checklist). Pin one ledger-suite release per batch; never skip releases (sequential-version enforcement is built into the official ledger WASMs and an install of a skipped-ahead version fails); keep the whole suite on one version before moving to the next release.

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.rsexecute_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. Createcreate_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. It collects one proposal fee for the whole batch — pre-staged paid sessions are reused and never double-charged; each asset step additionally rides its own already-paid prepare_asset_upload staging on its target. 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 (e.g. dfx deploy <asset-canister> --network ic --compute-evidence), and compare the evidence byte-for-byte against the step’s declared evidence.

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.rsexecute_dapp_upgrade_batch; GOVERNANCE.md for the ONS and SONS category blocks.


5.5 Proposal fee → refund lifecycle (every outcome)

Section titled “5.5 Proposal fee → refund lifecycle (every outcome)”

Creating a proposal pulls a creation fee (ICRC-2 transfer_from) into the governance canister, snapshotted on the proposal as fee_paid together with the rejection_cost and the voting parameters active at creation. A 300-second resolution timer drives every proposal to a terminal state and returns the fee according to the outcome — so the refunded amount is dynamic per DAO (it follows whatever proposal_creation_fee / rejection_cost the DAO had voted at creation).

Refund per outcome (net of the ledger transfer fee):

OutcomeRefundNotes
Approved (Motion) / Executed / ExecutionFailedFull (fee_paid − ledger_fee)A proposal the DAO approved is refunded in full — whether it stayed a Motion, executed, or its execution trapped (a failed execution is not the proposer’s fault).
Rejected / Expired (an Open proposal that missed quorum)Partial (fee_paid − rejection_cost − ledger_fee)The rejection_cost is withheld: forwarded to the OhShii backend treasury on ONS; retained in the governance canister on SONS (SONS has no separate treasury).
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 Approved (full) or Rejected (partial)A guardian veto opens an override round; the timer finalizes the proposal — Vetoed is never a terminal state of its own, so the refund follows the final Approved/Rejected outcome (see the guardian overridable veto in GOVERNANCE.md).

Liveness & recovery.

  • The resolution timer is always armed; each tick runs expire → execute approved → reap stuck-Executing → process refunds, so a fee is returned 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.
  • The guardian can call force_process_refunds to drain any owed refunds/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. Admins and ONS proposals can also trigger the underlying sweep_pool_unused_balance directly. See the guardian capability table in GOVERNANCE.md.

Safety properties.

  • No double refund — the refund transfer uses a deterministic created_at_time persisted on the first attempt, so any retry is an exact ledger Duplicate (the proposer is never paid twice); a per-proposal refund_processed flag is the one-shot guard.
  • 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.
  • AuditabilityFeeCollected, RefundProcessed, RefundFailed, FeeForfeited and (ONS) RejectionCostForwarded 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

Motion

ok

trap / fail

reaper

create_proposal: collect fee → fee_paid snapshot

Open / PendingUpload

300s resolution timer

Approved

Rejected

Expired

Expired (abandoned upload)

execute

Full refund: fee − ledger_fee

Executed

ExecutionFailed

stuck Executing > TTL

Partial: fee − rejection_cost − ledger_fee

Forfeit + FeeForfeited

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.rsadmin_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.rsexecute_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.rsbackend_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 (net: actual subaccount balance· legacy: ~90% of raise)transfer_from_subaccountadd liquidity to existing poolstore_campaign_subaccountcompute liquidity ICP (net: actual subaccount balance· legacy: ~90% of raise)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 A set_campaign_info (final sync)STEP B finalize_governance (autonomy SEAL — removes OhShii admins + 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)

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

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). finalize_ico_logic is idempotent and convergent on retry: each step recognizes its own already-done end-state (pool id already set, position already on governance, vesting locks already created, governance already finalized), and a re-run after the autonomy seal skips the pre-seal admin steps and only verifies the controller removal. So re-running finalization on a Frozen campaign drives it the rest of the way to Completed without manual surgery.

Two recovery paths 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 only recovers a pre-completion Frozen 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.

Finalize LP-drain pin (idempotent, retry-safe)

Section titled “Finalize LP-drain pin (idempotent, retry-safe)”

The single money leg that moves the raise into the pool is the transfer campaign subaccount → pool_manager main account (transfer_from_subaccount), which the pool_manager then deposits and mints. This leg used to recompute its draw from the live subaccount balance on every attempt — so if the transfer landed but the reply was lost (Frozen rollback), the retry would read the now-drained subaccount and either wedge (InsufficientFunds forever) or, worse, fund a pool with dust while the real raise sat commingled on the pool_manager main account. The finalize drain pin closes that hole (FINALIZE_DRAIN_PINS, backend MemoryId 21; keyed by campaign_id):

  • Pin before the transfer. On the first finalize pass the backend resolves the draw once and writes a durable pin — the drawn amount, a fixed created_at_time, and a snapshot of the campaign-subaccount balancebefore it moves any ICP. An existing pin always wins: a retry reuses the pinned amount and never recomputes it.
  • Deterministic, dedup-safe transfer. The leg sends pin.amount + 20,000 e8s (the 20k covers the pool_manager’s two 10k fee hops) with created_at_time = pin.created_at_time. The pool_manager maps a ledger TxDuplicate to Ok, so a retry re-sends the byte-identical transfer and the ICP ledger dedups it instead of debiting twice.
  • drained short-circuit. Once the transfer is confirmed (or a retry proves it landed), the pin is marked drained = true and every subsequent pass skips the transfer entirely and proceeds straight to add-liquidity with the pinned amount.
  • DEFINITE vs AMBIGUOUS failure handling. A failed transfer is classified: an AMBIGUOUS outcome (call lost / SysUnknown — may have landed) keeps the pin and returns an error, so the next retry re-sends the identical (deduped) tx — it is never recomputed. A DEFINITE InsufficientFunds is proof the transfer did not land, so the pin is re-built fresh (never certified as landed). A DEFINITE TxTooOld (the pinned tx aged past the ledger’s 24h dedup window) triggers a verify-before-repay check on the balance delta versus the pin-time snapshot: a drop of ≈ the pinned debit ⇒ landed (mark drained, proceed in the same pass); an unchanged balance ⇒ not landed (re-pin fresh); anything ambiguous, or an unreadable balance ⇒ fail closed (operator must inspect the ledger). Every error rolls the campaign back to Frozen and is retriable.
  • Dust floor. A resolved draw below 1 ICP (FINALIZE_MIN_LIQUIDITY_E8S = 100,000,000 e8s) is refused before the pin is written — and again before funding — unless custom_icp_liquidity_e8s is explicitly set (FINALIZE_DUST_FLOOR). A refused dust draw therefore never leaves a sticky pin, and a dust amount is never minted into a pool.
  • Pin-time affordability. The resolved draw plus overhead must fit inside the live subaccount balance at pin time, otherwise the pin is refused with FINALIZE_DRAW_UNAFFORDABLE (the usual trigger is an unclamped custom_icp_liquidity_e8s override) — a loud, recoverable failure rather than a silent “landed” mis-certification.
  • Frozen retry matrix. A Frozen campaign is force-succeeded (driven toward Completed) only with evidence: it reached its sale target, or a drain pin already exists (finalize has already started moving LP funds — converging to Completed is the only fund-safe direction). A Frozen campaign that expired without reaching target and has no pin falls through to the failure branch and converges to Failed so refunds unlock. A Frozen campaign that neither reached target nor expired (no pin) is refused (OHSHII_FINALIZE_FROZEN_NOT_READY) — creation-failure placeholders and mid-curve freezes must never be one-click drained toward a pool by an accidental admin/guardian retry.
  • Concurrency. A second finalization pass while one is mid-flight returns FINALIZE_ALREADY_IN_PROGRESS — a distinct prefix so an operator (or the retry-liquidity proposal) can tell “in progress” apart from a completed pass and from a real failure.
  • Escape hatch — admin_clear_finalize_drain_pin(campaign_id). Removes a corrupt or stale pin. Operator caveat: before clearing, verify the actual on-chain fund location (look for the pinned transfer — pin.amount + 20k at the pinned created_at_time, from the campaign subaccount — on the ICP ledger). A relaunch of a wedged campaign requires clearing the pin after the funds have been re-seated on the campaign subaccount, otherwise the reused pin would re-drive the stale draw.

Completed is committed only after the on-chain liquidity position is verified non-zero; Frozen is the universal retry parking state.

Relaunch (recovery when the ICPSwap pool is lost)

Section titled “Relaunch (recovery when the ICPSwap pool is lost)”

What it is for. A live LGE’s ICPSwap pool can disappear out from under it — ICPSwap can remove or uninstall the pool, or the pool’s (ICP, token, 3000) key can be reclaimed by a different canister — while the campaign still holds contributor funds. Relaunch rebuilds such a campaign: it mints a fresh ledger + index for the same token, reuses the existing SONS governance canister (the ledger is relinked, not the governance recreated), creates a new ICPSwap pool, and reopens the campaign to Active. Existing contributions and their ICP are preserved untouched in the campaign’s escrow subaccount; a mid-curve relaunch recreates the pool empty so liquidity is not locked at an arbitrary price.

Who can trigger it, and the cost. Any authenticated user may fund a relaunch of an eligible campaign — it is a recovery that benefits the campaign, and the payer covers only the real recovery cost via ICRC-2: the cycles for the two new canisters (ledger + index) plus a fresh, live-priced ICPSwap passcode reserve (no platform margin is charged on a relaunch). OHSHII governance (ONS) and platform operators are platform-funded (no fee). Existing contribution funds are never used to pay for the relaunch.

Eligibility — the pool loss is verified on-chain. A campaign is relaunch-eligible only when all of these hold:

  • it is in the Frozen state — a Completed or Failed LGE is terminal and can never be relaunched (this terminal-state guard is enforced unconditionally, including for a platform-operator force call);

  • it is a SONS (per-campaign governance) LGE;

  • it is not already relaunching;

  • it still records a pool id and a ledger id;

  • check_pool_is_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 platform-operator force path exists to override only the pool-loss probe (for a pool that is broken in a way the probe cannot detect) — it still cannot relaunch a Completed or Failed campaign, and it does not bypass the fund-safety of the flow.

Path consistency. Relaunch rebuilds the ledger, index and pool through the same helpers the initial LGE creation uses (same ledger/index deployment, same initial-balance split keyed on the bonding-curve version, same pool-creation call into the pool manager). The new ledger id is not exposed on any public surface until the new pool key is claimed (the same anti-front-running protection as initial creation).

Controllers during LGE creation and after finalization

Section titled “Controllers during LGE creation and after finalization”

During LGE creation (see create_ico_campaign, create_ledger_canister_via_cycles_ledger, create_index_canister_internal, create_governance_canister_internal in src/backend/src/lib.rs):

  • Governance canister (sons_governance) controllers at creation time:
    • ohshii_launcher_backend (this backend canister),
    • blackhole monitor (cycle monitoring),
    • CycleOps balance checker,
    • ohshii_governance,
    • NNS Root.
  • Ledger and index controllers for LGE tokens:
    • Same full governance set as above (backend, blackhole monitor, CycleOps, ohshii_governance, NNS Root),
    • Plus the governance canister itself when its ID is passed in.
  • Archive controllers for the ledger:
    • Primary archive controller: ohshii_launcher_backend,
    • more_controller_ids: ohshii_governance and the governance canister (for LGE tokens).

These controllers are needed so OhShii can:

  • Monitor cycles (blackhole + CycleOps),
  • Upgrade WASM and perform emergency maintenance (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 and finalized = true is set.
  • execute_post_finalization_steps then:
    • 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),
  • OhShii backend and the global OHSHII governance (ohshii_governance) no longer have direct admin or controller powers over that SONS governance canister.

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

When a campaign is Failed, contributors can request a refund within 7 days of the campaign expiry. 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 (gross vs net). The refund amount is computed by the backend and the pool_manager transfers it exactly (it no longer re-applies a 90% factor). The campaign subaccount holds the net that was forwarded into it:

  • Pre-escrow / legacy campaigns (e.g. dao-0000000011): unchanged from before — 90% of contributed ICP for normal contributors, 70% for the creator.
  • New (escrow) campaigns: the contributor is refunded 100% of the net recorded for their contributions (the net that actually funds the LGE, ≈ the same e8s as the legacy 90%-of-gross since fees were already taken at purchase); the creator keeps the same contributor↔creator ratio.

Either way the refund draws from the net actually held in the subaccount, so it can never over-draw.

OHSHII ledgerICPLedgerpool_managerdao_storageBackendFrontendUserOHSHII ledgerICPLedgerpool_managerdao_storageBackendFrontendUserCampaign becomes Failed (expiry or finalize on non-sold-out)Within 7 days of expirytransfers 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 (legacy: 90%-of-gross· net: 100%-of-net· + creator split)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 (legacy 90%-of-gross / 70% creator; new campaigns 100%-of-net with the same creator ratio) 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). The pool_manager no longer re-applies a 90% factor (that double-discount over-drew a net-funded subaccount).
  • Concurrency: Backend uses per-campaign and per-user locks so only one refund is in progress per user per campaign at a time.
  • Bulk (authorized-caller): refund_campaign(campaign_id) processes all pending refunds for a failed campaign in one go, routing each user through the 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.
  • 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 sweeps any un-refunded residual ICP (the campaign subaccount plus the per-campaign referral subaccount) to the DAO 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 the DAO treasury by the OHSHII leg of the same sweep (sweep_expired_campaign_residuals) 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.)

Reference: src/backend/src/lib.rsrequest_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.rsmark_contributions_refunded_atomic, try_mark_guest_fee_refunded, mark_campaign_swept; src/pool_manager/src/lib.rsprocess_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 by unused-balance delta oracles, 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.
  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.rsproposal_treasury_create_custom_pool, proposal_treasury_add_liquidity, proposal_retry_treasury_liquidity, get_treasury_liquidity_ops; src/pool_manager/src/lib.rstreasury_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.rsexecute_add_liquidity_to_pool, execute_sweep_pool_unused_balance, get_pool_positions.


  • ARCHITECTURE.md — Canister map and governance scope table.
  • WORKFLOWS.md — Create LGE, token, vote, create/execute proposal, backend vs frontend upgrade, self-snapshot flows.
  • OHSSHII_LOCKER_INTEGRATION.md — OhShii Locker token list and lock management via ONS proposals, SONS vesting vs Locker.
  • GOVERNANCE.md — ONS vs SONS, proposal categories, snapshot/restore.
  • LIQUID_DEMOCRACY.md — Vote delegation on ONS: follow / unfollow flows, accept-followers toggle, dismiss-follower, snapshot cascade model, four-layer rate limits, cross-canister batched VP fetch contract, event log reference, FAQ.
  • LOGGING_SYSTEM.md — Three-tier logging architecture, stable memory log details, frontend monitoring.
  • WORLD_ID_VERIFICATION.md — World ID voter verification: flow, privacy model, three-state config (not_configured / optional / required), threshold-ECDSA RP signatures, rate limits, governance controls and operator checklist.
  • FRONTEND.md — Frontend architecture: React + JSX + JSDoc @ts-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.