Core Workflows
This document describes the main end-to-end flows with Mermaid diagrams: LGE creation, token creation, voting, proposal creation, proposal execution (including the difference between backend WASM upgrade and frontend/asset upgrade), the proposal fee → refund lifecycle across every outcome, self-snapshot for governance canisters (with the backend as orchestrator), LGE finalization (success and failure paths), and campaign failure and refunds.
1. Create LGE
Section titled “1. Create LGE”The creator sets the LGE’s fundraising target — any multiple of 500 ICP, from 500 ICP up to 5000 ICP (target_icp_for_liquidity = k × 500, integer k in 1..=10, in steps of 500; 500 ICP is the default). The bonding curve keeps the same shape and sells the same tokens at every target — 700M on the curve, 200M into the pool, 100M DAO reserve, unchanged — and only the ICP prices scale by k: ~90% of the chosen raise seeds the ICPSwap pool (450 × k ICP, 450 at the 500-ICP default) and the pool opens at 450k / 200M = 225k e8s/token (0.00000225 × k ICP/token, 0.00000225 at the default; the end-of-curve price 0.00000133 × k stays ~41% below it at every k). The 500-ICP figures used elsewhere on this page are the k = 1 example, not a fixed model — and are distinct from the one-time creation fee described next.
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.
Progress is observable in two ways: the memo-keyed log stream (get_ico_creation_logs, which now also authorizes the payer via the progress record so it stays readable through the detached tail) and the typed per-step progress query get_ico_creation_progress(payment_memo) (steps PaymentVerified → SlotAllocated → GovernanceCreated → LedgerCreated → IndexCreated / GovernanceInstalled / SubaccountRegistered (parallel) → CampaignStored → GovernanceSynced → PoolCreating → PoolCreated → Completed, each InProgress/Done/Failed). The typed query authenticates against the payer recorded in the progress entry, so it keeps answering after completion — the frontend resume banner uses it to restore progress after a page reload.
Anti-front-running (ledger id stays hidden until the pool exists). The token ledger id must not appear on any public surface until our ICPSwap pool is created — otherwise someone could create the pool first with a wrong initial price. So the campaign record hides the ledger/index/governance ids (stored None), the token is registered in the public created-tokens registry (get_all_created_tokens) and synced to the governance canister only in the detached job’s finalize step, after the pool exists. On a pool-creation failure the ids stay hidden everywhere; the guardian retry reveals and registers them only once the pool is successfully created.
Pool-creation failure and guardian retry. If the detached job exhausts its bounded retries (a definite ICPSwap failure, or a transient failure that never resolves), the campaign stays Frozen, all canisters are preserved, and the pool job is kept (marked frozen). The ONS guardian (or an admin) can re-drive it with guardian_retry_pool_creation(campaign_id) (Shield/guardian-gated, same pre-auth model as guardian_retry_finalization): it resets the job and re-runs pool creation; on success the campaign flips Frozen → Active and opens to contributions. admin_clear_pool_job(campaign_id) is an admin give-up for a corrupt/stuck job. A stuck job whose timer chain died silently is also re-armed by the housekeeping watchdog and by post_upgrade.
Concurrency: at most 3 LGE creations platform-wide and 1 per creator may be in flight at once. The slot is reserved before the fee debit (and the scaled cycles floor is checked there too), so a capacity/cycles rejection never costs the user anything; the error message says the approval remains valid and to retry in a few minutes.
Post-LGE guardian: the creator can optionally designate a guardian for the new DAO (defaults to the creator). The anonymous principal and every OhShii infrastructure canister are rejected at prepare_ico_creation (and re-checked at campaign creation and at SONS install). The guardian can only trigger emergency/maintenance actions on the DAO — it can never access funds.
Reference: src/backend/src/lib.rs — prepare_ico_creation, create_ico_with_icrc2_payment, create_ico_campaign, create_empty_governance_canister, install_governance_code, run_pool_creation_job, finalize_campaign_after_pool (Frozen→Active), freeze_campaign_after_pool_failure, guardian_retry_pool_creation, admin_clear_pool_job, sweep_stuck_pool_jobs, get_ico_creation_progress, validate_post_lge_guardian.
1.5 Participate in an LGE (purchase tokens)
Section titled “1.5 Participate in an LGE (purchase tokens)”LGE participation follows the Voter Benefits Protocol (5 tiers: Guest / Human / Fish / Shark / Whale). See GOVERNANCE.md § Voter Benefits Protocol for the full tier table and axis definitions.
The backend is a payment gateway: business logic — bonding curve,
per-user purchase limit, sale-target gate, and idempotency — lives
inside dao_storage. The backend validates, fetches eligibility, and routes the
ICP through a per-contribution escrow before it reaches the campaign pool.
Per-contribution escrow (custody model). ICP moves Buyer → a per-contribution
backend escrow subaccount (deterministic, derived from the buyer + campaign +
amount + nonce) → forwarded net to the campaign’s pool_manager subaccount on
apply-OK, or refunded from that same escrow on apply-reject. The escrow is a
single-purchase, watertight compartment, so a refund can never touch another
in-flight purchase’s funds. Recovery is fully on-chain (idempotent sweep
methods + a durable per-contribution lifecycle); the [ICP_RECEIVED] /
[ICP_TRANSFER_RAW_OK] logs are observability only, never a recovery input —
there is no off-chain reconciler.
1.5.1 Sequence
Section titled “1.5.1 Sequence”Sponsor attribution is verification-gated (anti-Sybil). The sponsor split is resolved only when the buyer is verified (
is_verified, already fetched atget_lge_eligibility). For an unverified buyer the sponsor share is 0% and stays with the OhShii treasury, and no referral connection is persisted on-canister — so unverified identities cannot farm referral storage or rewards. The check is at purchase time and is not retroactive. See SPONSOR_SYSTEM.md → Referral registration & verification gating.
Token amount is computed by storage, not by the backend. The backend passes the curve INPUT to
add_contribution_atomic; storage runsbonding_curve::calculate_tokens_from_icp_amountagainst the liveCampaign.tokens_sold/sale_supplyinside the sameborrow_mut. For every live campaign — both legacy (curve_input_net = None) and new escrow (curve_input_net = Some(false)) — the curve always prices on the gross ICP a buyer sends: the fullXICP is curve progress. The escrow still forwards the net ~90% to the campaign subaccount; the per-leg ICP ledger fees are absorbed by the 5% platform band, so the pool draws exactly its target —450 × kICP (450 at the 500-ICP default; the creator picks the raise500 × k,kin1..=10) — and the ICPSwap listing is450k / 200M = 225k e8s/token(225 at the default). Thecurve_input_netflag governs the curve input, the contributed-total accumulation, the finalize drain, and the refund basis together — no campaign is ever on a mixed basis. The net basis (curve_input_net = Some(true)) is retired (it broke the listing ratio) and no live campaign uses it.
1.5.2 What runs where
Section titled “1.5.2 What runs where”| Concern | Owner | How |
|---|---|---|
| Cycle floor + per-user rate limit + global rate limit + LGE toggle + relaunch block | Backend purchase_tokens_icrc2 | Synchronous pre-checks before any ICC |
Tier resolution + is_active_voter flag | ohshii_governance.get_lge_eligibility | 1 ICC; backend fail-safes to Human limit + Guest-fee-required on any error |
| Idempotency probe (was this contribution_id already applied?) | dao_storage.lookup_contribution_status (query) | 1 ICC query BEFORE the transfer. On Some, short-circuit with the cached receipt — no second ICRC-2 transfer. ICC errors propagate to the user as Err (paying on a glitched lookup is unsafe). |
| Fast-fail UX (limit / target / minimum) | dao_storage.preflight_purchase (query) | 1 ICC query BEFORE the transfer. Same gates as the atomic apply. ICC errors are tolerated (the apply will re-enforce the gates atomically). |
| ICP transfer | Backend → ICP ledger | icrc2_transfer_from(caller → escrow(contribution_id), amount, FIXED created_at_time); the only money-moving await |
| Forensic ICP log | Backend store_critical_log | Single [ICP_RECEIVED] event with block_index, amount_e8s, contribution_id. Stable storage, survives upgrade. Observability only — on-chain escrow state (not the log) is the recovery source; there is no off-chain reconciler. |
| Atomic apply: dedup + limit + target + minimum + bonding curve + persistence + index | dao_storage.add_contribution_atomic (update) | All inside ONE CAMPAIGNS.with(borrow_mut). No .await between dedup check and CONTRIBUTION_ID_INDEX insert. Returns AlreadyApplied { ..., block_index } (storage-authoritative values) on a duplicate. |
1.5.3 The client_nonce contract
Section titled “1.5.3 The client_nonce contract”The frontend generates a UUIDv4 (crypto.randomUUID()) and persists it
in localStorage under a key derived from (user_principal, campaign_id, amount_icp_e8s). The same intent always rebuilds the same key, which
returns the same nonce, which produces the same contribution_id in the
backend.
Properties:
- F5 / tab reload mid-purchase: localStorage survives — the retry
rebuilds the same id and short-circuits at
lookup_contribution_status. - Real network error / timeout: caller retries with the same nonce → same id → short-circuits if storage already applied; pays once if not.
- User changes amount: localStorage key changes → fresh nonce → new intent (correct behavior).
- Two tabs, same intent: same key → both tabs read the same nonce →
whichever fires second hits
lookup_contribution_statusand short-circuits. Edge case: a true same-intent race is blocked at the backend by the per-contribution_idRAII lock, and any duplicate buyer→escrow transfer carries the fixedcreated_at_timepersisted on the pending marker, so the ICP ledger dedups it (returnsDuplicate) instead of debiting twice.
After a successful response, the frontend clears the localStorage entry so the next intent gets a fresh nonce.
1.5.4 Failure paths
Section titled “1.5.4 Failure paths”- Lookup ICC fails (transient network glitch on the query): backend
returns
Errto the user without paying. Frontend surfaces “retry later”; the next attempt with the same nonce succeeds when storage is reachable. - Preflight ICC fails: backend continues to the transfer. Storage’s atomic apply enforces the gates and rejects with a typed variant if the user is not eligible; on a reject the escrow is refunded.
- Apply rejects (policy gate): storage writes a durable rejection
tombstone and the backend refunds
amount − feefrom that contribution’s escrow to the buyer, inline. A same-nonce retry short-circuits on the tombstone — no re-pay, no fee bleed. - Trap/upgrade between escrow funding and apply: the ICP sits in the
deterministic per-contribution escrow. The fixed
created_at_timeon the pending marker means a retry’s transfer is deduped (no double debit), and the escrow balance is the authoritative recovery source —sweep_escrow_to_lge(forward) andsweep_escrow_refund(refund) are idempotent, on-chain, callable by the authorized canister (governance) and via self-call. No off-chain reconciler. - Trap between apply (counted) and forward: the contribution is counted but
its net still sits in the escrow;
sweep_escrow_to_lgerecovers it, and finalize reconciles any such stragglers into the campaign subaccount before draining. - dao_storage upgrade during the apply: ICC fails, backend returns
Err. User retries with the same nonce; the lifecycle status probe short-circuits if the original apply landed pre-upgrade, otherwise the atomic apply runs cleanly.
See LOGGING_SYSTEM.md for the [ICP_RECEIVED] / [ICP_TRANSFER_RAW_OK] event
schema. These latches are observability only — on-chain escrow state, not the
logs, is the recovery source.
1.5.5 Guest ICP-only participation (automatic OHSHII fee swap)
Section titled “1.5.5 Guest ICP-only participation (automatic OHSHII fee swap)”A Guest-tier participant pays a one-time OHSHII guest fee on their first
contribution (see GOVERNANCE.md § Voter Benefits Protocol).
Previously a Guest holding only ICP could not participate — they had to source
OHSHII manually first. The frontend now removes that dead end: it can auto-swap
ICP for exactly the missing OHSHII before the purchase, so a Guest can complete an
LGE contribution end-to-end with ICP alone. This is a frontend capability
(purchaseTokensWithAutoGuestFee in utils/canister.js) layered on top of the
unchanged backend purchase path — the backend still receives a normal
purchase_tokens_icrc2 call with the OHSHII guest fee already in the wallet.
The flow, when the caller opts in (autoSwapEnabled):
- Eligibility, fail-closed. Fetch
get_lge_eligibilityonce. If eligibility is unavailable the whole flow aborts (it never assumes Guest or non-Guest). - Shortfall check. Only Guests with a positive
guest_fee_e8sare affected. If the wallet already holds enough OHSHII (fee + ledger-fee headroom), skip straight to the purchase. Otherwise compute the exact OHSHII shortfall. - Quote + swap. Preview the ICP needed for the shortfall on the OHSHII/ICP
ICPSwap pool (
getIcpForOhshiiPreview, which already accounts for the pool’s auto-withdraw fee and adds an input safety margin), then swap (swapIcpForOhshii, a specialization of the generalizedswapIcpForTokeninutils/ohshiiSwap.js) with a minimum-out floor sized so the received OHSHII covers the fee. Guarantee: if the swap throws, no LGE purchase is made (the error is re-taggedstage: 'guest_fee_swap'and propagated — the flow never proceeds to spend ICP on the contribution). - Post-swap verification. Re-read the OHSHII balance; if it has not yet settled
to the required amount the flow aborts with
GUEST_FEE_NOT_SETTLED— no purchase, no fee charged — and the user retries in a few seconds. - Purchase. Only after the OHSHII is confirmed in the wallet does the flow
delegate to the normal
purchaseTokensIcrc2path (which re-fetches eligibility and performs the Guest-fee approve + ICP approve + Shield-wrapped purchase from §1.5.1).
Recovery. The swap uses ICPSwap’s deposit → swap → withdraw sequence, whose
output withdraw is queued asynchronously by the pool (an ok from the pool
means the transfer was enqueued, not that it has settled — arrival is confirmed by a
ledger balance change). If a swap partially executes — the ICP deposit lands but the
swap or the auto-withdraw does not — the funds sit in the OHSHII/ICP pool as the
user’s in-pool unused balance (or in their pool deposit subaccount), never lost.
recoverStuckPoolFunds(identityOrAgent, poolCanisterId, userPrincipalText) in
utils/ohshiiSwap.js sweeps both (deposit-subaccount rescue, then unused-balance
withdraw) back to the user’s main account.
2. Create Token
Section titled “2. Create Token”The user pays 5 ICP via ICRC-2; the backend verifies the payment and deploys the token ledger and index canisters.
Reference: src/backend/src/lib.rs — prepare_token_creation, create_token_with_icrc2_payment.
3. Vote
Section titled “3. Vote”The user selects a DAO (OHSHII → ohshii_governance, or a SONS token → that campaign’s sons_governance), then votes on a proposal. The governance canister’s timer handles expiry and auto-execution.
- ONS: Voting power from OHSHII governance voting power (quadratic function of locked OHSHII managed by ohshii_governance/locker). Lock points are not used for voting. The ohshii_governance timer runs expiry and execution.
- SONS: Voting power from the LGE campaign token (quadratic voting based on that DAO’s locks — both LGE vesting locks and user-created manual
create_additional_locklocks — held in that sons_governance); each sons_governance has its own timer. - Cap behavior (both ONS and SONS): cap applies to the aggregated VP of all eligible locks owned by the same principal (not only per single lock). Example with cap
36,000: if a user has36,000VP from one lock and4,000VP from another, displayed/votable VP remains36,000. - Vote delegation (ONS only): verified ONS voters can delegate their vote to another verified principal. When the delegate casts a vote, the same ohshii_governance timer that handles expiry also drains a delegation queue and applies each follower’s vote with their own snapshot voting power. Delegation never crosses into SONS proposals. See LIQUID_DEMOCRACY.md for the follow/unfollow lifecycle, the accept-followers toggle, the dismiss-follower flow, the cascade architecture, and the security model.
Reference: OnsVotingPage.jsx (DAO selector, vote calls, Following tab + Voting Power delegation surfaces), ohshii_governance and sons_governance (vote, timer, execute).
4. Create Proposal
Section titled “4. Create Proposal”The user chooses the DAO, then the proposal category and payload (e.g. ExecuteAdminMethod with target canister and method). For ONS, categories include UpgradeCanister, UpgradeGovernanceCanister, ExecuteAdminMethod, CriticalGovernanceOperation, etc. For SONS, categories include ExecuteAdminMethod, UpgradeCanister, UpgradeGovernanceCanister, CriticalGovernanceOperation, UpgradeAssetCanister, etc. Creation may require a proposal fee (and for upgrades, WASM upload).
Reference: CreateProposalForm.jsx, GOVERNANCE.md for full category and method lists.
5. Execute Proposal: Backend WASM Upgrade vs Frontend (Asset) Upgrade
Section titled “5. Execute Proposal: Backend WASM Upgrade vs Frontend (Asset) Upgrade”Execution of approved proposals differs by target type.
5.1 Backend (Rust) canister upgrade
Section titled “5.1 Backend (Rust) canister upgrade”Voting tier: every canister upgrade is Critical — the standard backend upgrade (
UpgradeCanister) and the governance self-upgrade (UpgradeGovernanceCanister) both run the critical pipeline (fixed 7-day window, critical quorum/approval bar, guardian veto with override round), the same as the batch upgrades in §5.3/§5.4. See GOVERNANCE.md.
The executor (ohshii_governance or sons_governance) calls the IC management canister to install the new WASM on the target. For WASM under the message size limit, install_code is used; for larger WASM, chunks are uploaded and install_chunked_code is used.
Reference: ohshii_governance/execution.rs — execute_upgrade_standard, execute_upgrade_chunked; sons_governance equivalent execution paths.
5.2 Frontend (asset) canister upgrade
Section titled “5.2 Frontend (asset) canister upgrade”Voting tier: the frontend (asset) upgrade (
UpgradeAssetCanister) is also Critical — fixed 7-day window, critical quorum/approval bar, guardian-vetoable. See GOVERNANCE.md.
The target is an IC asset canister. The flow uses the asset canister’s batch API: the proposer uploads a batch and proposes it; at execution time the governance canister validates and commits the batch. No install_code is involved.
Reference: ohshii_governance/execution.rs — execute_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:
- Create —
create_proposalwith aCanisterUpgradeBatchtarget validates the suite shape (role order, 32-byte hashes, sizes, chunking, no self-target, suite probes) and collects one standard proposal fee for the whole batch (pre-staged paid sessions are accepted per step; their payment is reused, never double-charged). For every step without a pre-staged session the canister allocates an upload session; the proposal starts inPendingUpload. - Upload — the frontend reads the proposal back to learn the per-step session ids, then uploads each WASM in chunks. The batch upload window is 15 minutes (longer than the single-target window: up to five 20 MiB modules ≈ 100 chunk calls). On each session’s final chunk the assembled hash is checked against that step’s declared hash. When every step is staged, the proposal flips
PendingUpload → Open. - Vote — the standard CRITICAL pipeline: fixed 7-day window, critical quorum/approval bar, guardian veto with override round.
- Execute — pre-flight first: the controller gate is all-or-nothing — if governance does not control even one target, nothing is installed. Then the steps run sequentially, in the voted order, stop-on-first-error. Per step: idempotent skip if the live module hash already equals the step hash, staged-hash revalidation (TOCTOU),
install_code/install_chunked_code, post-install hash verify, then the step result is persisted before the next step begins. Per-step progress lives in the proposal’sbatch_step_results(Pending/Started/Skipped/Executed/Failed) and is rendered step-by-step on the proposal card.
No-arg upgrades. A step without an explicit upgrade argument sends the canonical empty Candid tuple, never raw empty bytes — stock ledger and index canisters decode it as “no change”, while raw empty bytes would trap their post_upgrade. Index steps may alternatively set a typed sync-interval convenience field instead of hand-encoding an argument.
Partial failure and recovery. There is no automatic rollback — downgrading a ledger to an earlier release is not supported upstream, so a rollback lever would be a second incident, not a fix. A failed step leaves the suite mid-upgrade (a state the ordered procedure explicitly tolerates): the proposal ends ExecutionFailed with the per-step record, and recovery is fix-forward — submit a fresh batch proposal for the same suite; steps whose live module hash already matches auto-skip, so only the uncommitted steps re-run.
Release discipline (operator checklist). Pin one ledger-suite release per batch; never skip releases (sequential-version enforcement is built into the official ledger WASMs and an install of a skipped-ahead version fails); keep the whole suite on one version before moving to the next release.
Reference: ohshii_governance/execution.rs / sons_governance/execution.rs — execute_canister_upgrade_batch; GOVERNANCE.md for the ONS and SONS category blocks.
5.4 Dapp batch upgrade (UpgradeDappBatch)
Section titled “5.4 Dapp batch upgrade (UpgradeDappBatch)”One proposal upgrades an arbitrary ordered set of canisters — 1–10 steps, mixing code installs and asset (frontend) commits — in a single vote. Both DAOs support it: ONS for any OhShii canister it controls (the launcher fleet, the locker fleet, the quests canisters, or any custom principal) plus its own governance canister as a final self-step; SONS for the campaign frontend, custom targets, and its own governance canister. The proposal category is UpgradeDappBatch (Critical, guardian-vetoable — see GOVERNANCE.md). This is the general dapp-release lane; the ledger-suite path (§5.3) stays specialized for a token’s money rails.
Why proposer order, executed verbatim. Unlike a ledger suite, a dapp release has no protocol-fixed order: the safe sequence is release-specific (a storage-before-backend release and its reverse are both legitimate). So the chain validates step shape, not release semantics, and the executor runs the voted step list exactly as written — the DAO votes on the literal order it sees, with no executor reordering. The UI nudges a code → asset → self order (asset commits are not idempotent, so committing them last minimizes the chance a code-step failure strands a consumed asset commit; the self-step is forced last anyway), but the order is the proposer’s and reviewers’ responsibility.
Create → stage → vote → execute:
- Create —
create_proposalwith aDappUpgradeBatchtarget validates the shape: 1–10 steps, pairwise-distinct targets, self-step-last (and only if its kind is code), per-payload shape, category rules (ONS: Core = a known OhShii canister; SONS: Core = the governance canister itself only — every other target is Custom), per-step provenance (git_repo_url+git_commit_hash+build_instructions, all required non-empty), and for each asset step avalidate_commit_proposed_batchprobe on the target. It collects one proposal fee for the whole batch — pre-staged paid sessions are reused and never double-charged; each asset step additionally rides its own already-paidprepare_asset_uploadstaging on its target. For every code step without a pre-staged session the canister allocates an upload session; the proposal starts inPendingUpload. - Stage — the frontend reads the proposal back to learn the per-code-step session ids and uploads each WASM in chunks (the 30-minute upload window). Asset steps consume no upload session — their bytes already live on the target asset canister from the
prepare_asset_uploadstaging done before creation. When every code step is staged, the proposal flipsPendingUpload → Open(a pure-asset batch landsOpenimmediately). - Vote — the standard CRITICAL pipeline: fixed 7-day window, critical quorum/approval bar, guardian veto with override round.
- Execute — pre-flight first: the controller gate is all-or-nothing over EVERY target — asset targets included — if governance does not control even one target, nothing is installed and nothing is committed. Pre-flight also re-validates each code step’s staged hash, re-runs each asset step’s
validate_commit_proposed_batch(TOCTOU — the staged batch may have changed during the vote), and checks the per-target cycles floor. Then the steps run sequentially, in the voted order, stop-on-first-error, dispatched by kind: a code step revalidates the staged hash, runsinstall_code/install_chunked_code, and verifies the installed module hash; an asset step runscommit_proposed_batch. Per-step progress lives in the proposal’sbatch_step_results(Pending/Started/Skipped/Executed/Failed) and is rendered step-by-step on the proposal card.
Two proofs — verify each step by its kind. A dapp batch carries two different artifact kinds, each with its own proof:
- Code steps are verified by MODULE HASH: reproducibly build from the cited repo + commit (per the step’s
build_instructions), compare the result against the step’s declaredwasm_module_hash, and after execution confirm withdfx canister info <target> --network ic. - Asset steps are verified by EVIDENCE: rebuild the frontend from the cited commit, run the asset canister’s compute-evidence flow (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).
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 → 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):
| Outcome | Refund | Notes |
|---|---|---|
| Approved (Motion) / Executed / ExecutionFailed | Full (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
Executingpast a generous TTL (and is not awaiting a live self-upgrade) is reaped toExecutionFailedand refunded in full. - The guardian can call
force_process_refundsto 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_fundsto sweep them — both tokens, both reclaim scenarios, live fees — back to the executing canister’s own treasury. The destination is fixed in code (the guardian cannot name a recipient), it is idempotent, and it fails closed while a treasury op is mid-flight on that pool. On ONS the guardian selects which canister holds the funds (Backendfor a failed swap →pool_managerfor failed liquidity); on SONS the campaign governance canister recovers its own position. Admins and ONS proposals can also trigger the underlyingsweep_pool_unused_balancedirectly. See the guardian capability table in GOVERNANCE.md.
Safety properties.
- No double refund — the refund transfer uses a deterministic
created_at_timepersisted on the first attempt, so any retry is an exact ledgerDuplicate(the proposer is never paid twice); a per-proposalrefund_processedflag 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.
- Auditability —
FeeCollected,RefundProcessed,RefundFailed,FeeForfeitedand (ONS)RejectionCostForwardedevents are written to the stable event log, so a proposal’s full money lifecycle is reconstructable.
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.
6. Self-Snapshot: Backend as Orchestrator
Section titled “6. Self-Snapshot: Backend as Orchestrator”When the snapshot target is the governance canister itself (ohshii_governance or sons_governance), the canister cannot perform stop → snapshot → start on itself: a canister that calls stop_canister(self) would never complete the call (deadlock). So both governance canisters delegate the stop/snapshot/start workflow to the ohshii_launcher_backend, which acts as orchestrator.
6.1 Technical constraint
Section titled “6.1 Technical constraint”- Only a controller of a canister can call
stop_canisterandstart_canisteron it. - If the target is the caller itself, the call to
stop_canister(self)does not return; the canister stops before responding. - Therefore the workflow must be run by another canister that is a controller of the target. The backend is that orchestrator.
6.2 OHSHII Governance (ONS) self-snapshot / self-restore
Section titled “6.2 OHSHII Governance (ONS) self-snapshot / self-restore”The ohshii_governance is already deployed with the backend as one of its controllers. When the snapshot/restore target is the ohshii_governance itself:
- ohshii_governance does not call stop/snapshot/start itself.
- It sends an async request (
notify) to the backend:backend_orchestrate_snapshot_workfloworbackend_orchestrate_restore_workflowwithtarget = selfandcleanup_backend_controller_after_start = false. - The backend (already a controller) runs: stop → snapshot or load_snapshot → start.
- No controller change is needed; the backend remains a controller.
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:
- Governance adds the OhShii backend as a temporary controller (so the backend can stop/start the governance canister).
- Governance calls the backend via notify:
backend_orchestrate_snapshot_workfloworbackend_orchestrate_restore_workflowwithtarget = selfandcleanup_backend_controller_after_start = true. - The backend runs: stop → snapshot or load_snapshot → start.
- After start, the backend removes itself from the governance canister’s controllers (so the backend does not remain a controller).
This temporary-controller pattern is required so that (a) the backend can perform stop/start on the governance canister, and (b) the backend does not retain control after the workflow. The Create Proposal form shows the target canister when creating a snapshot proposal; when the target is the current governance canister (SONS), this backend temporary-controller flow is used and can be explained in the UI.
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.
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)) andcustody_funded = false. The per-contributor unlock-fee reserve coversNunlock transfers plus one funding transfer. - The SONS timer (running independently of finalize, so a slow or retried finalize never blocks it) drains a funding-op queue: for each lock it moves
net + N·feefrom the governance main account into that lock’s subaccount, idempotently (balance-gated, pinned dedup so a retry never double-funds), then flipscustody_funded = true. - Unlock payouts are then paid from the lock’s own subaccount to the beneficiary; the governance main account holds only the DAO treasury (the ~100M/41.59M treasury allocation, proposal fees, referral pot) — never lock-backing tokens. A
TreasuryWithdraw/TreasurySwapproposal can no longer reach a lock’s funds.
This is invisible to contributors: they still activate_vesting and receive tranches on the same schedule. Legacy campaigns created before this change (and any lock with no subaccount) keep the pre-isolation main-account behaviour unchanged. See GOVERNANCE.md → “Per-lock custody isolation” for the full model, the cancelled-lock reclaim path, and the treasury-integrity guard.
Recovery from Frozen (retry vs relaunch)
Section titled “Recovery from Frozen (retry vs relaunch)”If finalization fails partway — a transient inter-canister error, or the pool-link / position / autonomy-seal steps not all completing — the backend rolls the campaign back to Frozen, a recoverable, retryable state (the flip is STATUS-ONLY). 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 terminalCompleted/Failedcampaigns — it only recovers a pre-completionFrozenone — and completes the campaign’s intended seal + autonomy rather than altering it. - Pool unreachable → Relaunch. If the liquidity pool itself is gone, the recovery is a relaunch (fresh ledger/index/pool, preserving contributions, governance and vesting), not a finalize retry. See the dedicated relaunch section below.
Finalize LP-drain pin (idempotent, retry-safe)
Section titled “Finalize LP-drain pin (idempotent, retry-safe)”The single money leg that moves the raise into the pool is the transfer campaign subaccount → pool_manager main account (transfer_from_subaccount), which the pool_manager then deposits and mints. This leg used to recompute its draw from the live subaccount balance on every attempt — so if the transfer landed but the reply was lost (Frozen rollback), the retry would read the now-drained subaccount and either wedge (InsufficientFunds forever) or, worse, fund a pool with dust while the real raise sat commingled on the pool_manager main account. The finalize drain pin closes that hole (FINALIZE_DRAIN_PINS, backend MemoryId 21; keyed by campaign_id):
- Pin before the transfer. On the first finalize pass the backend resolves the draw once and writes a durable pin — the drawn amount, a fixed
created_at_time, and a snapshot of the campaign-subaccount balance — before it moves any ICP. An existing pin always wins: a retry reuses the pinned amount and never recomputes it. - Deterministic, dedup-safe transfer. The leg sends
pin.amount + 20,000e8s (the 20k covers the pool_manager’s two 10k fee hops) withcreated_at_time = pin.created_at_time. The pool_manager maps a ledgerTxDuplicatetoOk, so a retry re-sends the byte-identical transfer and the ICP ledger dedups it instead of debiting twice. drainedshort-circuit. Once the transfer is confirmed (or a retry proves it landed), the pin is markeddrained = trueand every subsequent pass skips the transfer entirely and proceeds straight to add-liquidity with the pinned amount.- DEFINITE vs AMBIGUOUS failure handling. A failed transfer is classified: an AMBIGUOUS outcome (call lost /
SysUnknown— may have landed) keeps the pin and returns an error, so the next retry re-sends the identical (deduped) tx — it is never recomputed. A DEFINITEInsufficientFundsis proof the transfer did not land, so the pin is re-built fresh (never certified as landed). A DEFINITETxTooOld(the pinned tx aged past the ledger’s 24h dedup window) triggers a verify-before-repay check on the balance delta versus the pin-time snapshot: a drop of ≈ the pinned debit ⇒ landed (markdrained, proceed in the same pass); an unchanged balance ⇒ not landed (re-pin fresh); anything ambiguous, or an unreadable balance ⇒ fail closed (operator must inspect the ledger). Every error rolls the campaign back toFrozenand is retriable. - Dust floor. A resolved draw below 1 ICP (
FINALIZE_MIN_LIQUIDITY_E8S = 100,000,000e8s) is refused before the pin is written — and again before funding — unlesscustom_icp_liquidity_e8sis explicitly set (FINALIZE_DUST_FLOOR). A refused dust draw therefore never leaves a sticky pin, and a dust amount is never minted into a pool. - Pin-time affordability. The resolved draw plus overhead must fit inside the live subaccount balance at pin time, otherwise the pin is refused with
FINALIZE_DRAW_UNAFFORDABLE(the usual trigger is an unclampedcustom_icp_liquidity_e8soverride) — a loud, recoverable failure rather than a silent “landed” mis-certification. - Frozen retry matrix. A
Frozencampaign is force-succeeded (driven towardCompleted) only with evidence: it reached its sale target, or a drain pin already exists (finalize has already started moving LP funds — converging toCompletedis the only fund-safe direction). AFrozencampaign that expired without reaching target and has no pin falls through to the failure branch and converges toFailedso refunds unlock. AFrozencampaign that neither reached target nor expired (no pin) is refused (OHSHII_FINALIZE_FROZEN_NOT_READY) — creation-failure placeholders and mid-curve freezes must never be one-click drained toward a pool by an accidental admin/guardian retry. - Concurrency. A second finalization pass while one is mid-flight returns
FINALIZE_ALREADY_IN_PROGRESS— a distinct prefix so an operator (or the retry-liquidity proposal) can tell “in progress” apart from a completed pass and from a real failure. - Escape hatch —
admin_clear_finalize_drain_pin(campaign_id). Removes a corrupt or stale pin. Operator caveat: before clearing, verify the actual on-chain fund location (look for the pinned transfer —pin.amount + 20kat the pinnedcreated_at_time, from the campaign subaccount — on the ICP ledger). A relaunch of a wedged campaign requires clearing the pin after the funds have been re-seated on the campaign subaccount, otherwise the reused pin would re-drive the stale draw.
Completed is committed only after the on-chain liquidity position is verified non-zero; Frozen is the universal retry parking state.
Relaunch (recovery when the ICPSwap pool is lost)
Section titled “Relaunch (recovery when the ICPSwap pool is lost)”What it is for. A live LGE’s ICPSwap pool can disappear out from under it — ICPSwap can remove or uninstall the pool, or the pool’s (ICP, token, 3000) key can be reclaimed by a different canister — while the campaign still holds contributor funds. Relaunch rebuilds such a campaign: it mints a fresh ledger + index for the same token, reuses the existing SONS governance canister (the ledger is relinked, not the governance recreated), creates a new ICPSwap pool, and reopens the campaign to Active. Existing contributions and their ICP are preserved untouched in the campaign’s escrow subaccount; a mid-curve relaunch recreates the pool empty so liquidity is not locked at an arbitrary price.
Who can trigger it, and the cost. Any authenticated user may fund a relaunch of an eligible campaign — it is a recovery that benefits the campaign, and the payer covers only the real recovery cost via ICRC-2: the cycles for the two new canisters (ledger + index) plus a fresh, live-priced ICPSwap passcode reserve (no platform margin is charged on a relaunch). OHSHII governance (ONS) and platform operators are platform-funded (no fee). Existing contribution funds are never used to pay for the relaunch.
Eligibility — the pool loss is verified on-chain. A campaign is relaunch-eligible only when all of these hold:
-
it is in the
Frozenstate — aCompletedorFailedLGE is terminal and can never be relaunched (this terminal-state guard is enforced unconditionally, including for a platform-operator force call); -
it is a SONS (per-campaign governance) LGE;
-
it is not already relaunching;
-
it still records a pool id and a ledger id;
-
check_pool_is_lostconfirms the loss against the ICPSwap factory — the backend queries the factory’sgetPoolfor the campaign’s token pair and classifies:- the key now resolves to a different pool canister → lost (key reclaimed),
- the pool is still in the registry but its
metadata()no longer responds → lost (canister uninstalled), - the factory reports the pool as absent → lost (removed from factory),
- the pool’s
metadata()responds (alive) → not eligible, - the factory is unreachable / the probe times out → fail-closed, not eligible (relaunch is blocked until the loss can be verified).
The factory probe runs replicated with generous timeouts and retries so a transient timeout does not mis-classify a live pool as lost, nor a dead pool as alive.
A platform-operator force path exists to override only the pool-loss probe (for a pool that is broken in a way the probe cannot detect) — it still cannot relaunch a Completed or Failed campaign, and it does not bypass the fund-safety of the flow.
Path consistency. Relaunch rebuilds the ledger, index and pool through the same helpers the initial LGE creation uses (same ledger/index deployment, same initial-balance split keyed on the bonding-curve version, same pool-creation call into the pool manager). The new ledger id is not exposed on any public surface until the new pool key is claimed (the same anti-front-running protection as initial creation).
Controllers during LGE creation and after finalization
Section titled “Controllers during LGE creation and after finalization”During LGE creation (see create_ico_campaign, create_ledger_canister_via_cycles_ledger, create_index_canister_internal, create_governance_canister_internal in src/backend/src/lib.rs):
- Governance canister (
sons_governance) controllers at creation time:ohshii_launcher_backend(this backend canister),- blackhole monitor (cycle monitoring),
- CycleOps balance checker,
ohshii_governance,- NNS Root.
- Ledger and index controllers for LGE tokens:
- Same full governance set as above (backend, blackhole monitor, CycleOps, ohshii_governance, NNS Root),
- Plus the governance canister itself when its ID is passed in.
- Archive controllers for the ledger:
- Primary archive controller:
ohshii_launcher_backend, more_controller_ids:ohshii_governanceand the governance canister (for LGE tokens).
- Primary archive controller:
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_governanceare removed from the governance config.admins andfinalized = trueis set.
execute_post_finalization_stepsthen:- Reads the current IC-level controllers of the governance canister,
- Removes the backend and
ohshii_governancefrom that controllers list, keeping at least one controller (governance, NNS Root, and monitoring controllers such as blackhole/CycleOps), - Calls
update_settingson the management canister so that backend and ohshii_governance are no longer IC controllers for the per-LGE governance canister.
The result is that, after finalization:
- The per-LGE governance canister is controlled by its own DAO (token holders) plus safety/monitoring controllers (NNS Root and cycle monitors),
- OhShii backend and the global OHSHII governance (
ohshii_governance) no longer have direct admin or controller powers over that SONS governance canister.
8. Campaign failure and refunds
Section titled “8. Campaign failure and refunds”A campaign becomes Failed when:
- It expires without selling out (housekeeping
check_expired_icosorcheck_and_update_campaign_statussets state to Failed whenexpiry_timestamphas passed), or - Finalization is run on a campaign that did not reach the token target (e.g.
finalize_icois invoked — by the backend’s own flow or the authorized caller — on a non–sold-out campaign; the backend marks it Failed).
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.
- Refund deadline: 7 days after
campaign.expiry_timestamp; after that,request_refundreturns 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 separaterefund_referral_legfrom the per-campaign referral subaccount (escrow rows); for pre-escrow/legacy campaigns the referral subaccount is empty and the full 95% is drawn from the campaign subaccount. Each leg is net of its own ~0.0001 ICP ledger fee (the participant bears it). The pool_manager no longer re-applies a 90% factor (that double-discount over-drew a net-funded subaccount). - Concurrency: Backend uses per-campaign and per-user locks so only one refund is in progress per user per campaign at a time.
- Bulk (authorized-caller):
refund_campaign(campaign_id)processes all pending refunds for a failed campaign in one go, routing each user through the samemark_contributions_refunded_atomic+process_single_refund(campaign-subaccount) path; it is idempotent. Storage also exposesadmin_clear_refunded_statusandadmin_mark_all_refundedfor bookkeeping. - Guest fee refund (OHSHII): if a contributor paid the one-time guest fee (charged only to Guest-tier participants on their first contribution),
request_refundand the bulkrefund_campaignalso return that OHSHII to them on a Failed campaign, alongside the ICP refund. The exact amount charged is snapshotted at contribution time, so the refund is independent of any later fee-config change, and it is idempotent (recorded per campaign+user). Eligibility is read from the recorded payment, not the user’s current tier (tiers can change over time). - 7-day residual sweep (automatic): once a campaign’s 7-day refund window has fully closed, a periodic housekeeping task 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.rs — request_refund, refund_campaign, compute_failed_refund_e8s, refund_guest_fee_on_failure, sweep_expired_campaign_residuals, finalize_ico (Failed path), check_expired_icos, check_and_update_campaign_status; src/storage/src/lib.rs — mark_contributions_refunded_atomic, try_mark_guest_fee_refunded, mark_campaign_swept; src/pool_manager/src/lib.rs — process_single_refund, transfer_from_subaccount, drain_campaign_referral.
9. Treasury liquidity operations (ONS)
Section titled “9. Treasury liquidity operations (ONS)”ONS can create ICPSwap pools, add liquidity to existing pools, and remove liquidity back to the treasury — all funded from the DAO treasury (the backend main account) and executed through CRITICAL-tier ExecuteAdminMethod proposals (7-day window, higher quorum/approval, guardian-vetoable). Positions created this way are owned by the pool_manager (like LGE positions) and tracked in its treasury-position registry (get_treasury_positions query). One full-range position is minted per approved add/create proposal.
Create / add (backend orchestrator → pool_manager executor):
- The approved proposal calls
proposal_treasury_create_custom_pool/proposal_treasury_add_liquidityon the backend. - The backend journals the operation in stable memory (op id, amounts, per-ledger funding legs with pinned
created_at_time) BEFORE moving any funds, then transfers each leg from the backend main account to the pool_manager main account (a re-sent leg deduplicates at the ledger). The ICPSwap passcode reserve is topped up shortfall-only. - The backend calls the pool_manager, which runs its own journaled saga: for a create, a deterministic factory
getPoolprobe first (if the pool was front-run into existence during the public vote, the op degrades to add-liquidity on the existing pool); passcode purchase (skip-if-exists);createPool; then deposits (exact approvals, NET amounts), a position-id snapshot, the full-range mint with the actually-credited amounts, the registry write, and a residual sweep. For an add, the target pool is first verified against the ICPSwap factory (a lookalike canister that merely echoes plausiblemetadata()is rejected). - Failure at any point leaves the op journaled as failed with funds parked on the pool_manager main account or as pool “unused balance” — both recoverable on-chain: a follow-up
proposal_retry_treasury_liquidity(op_id)proposal re-sends only the missing legs and resumes the pool_manager saga from its phase journal (idempotent; deposits are resumed by unused-balance delta oracles, and a completed op returns success without moving funds).
Remove to treasury (pool_manager):
- The approved proposal calls
proposal_remove_liquidity_to_treasury(pool, position, amount?)— valid targets are treasury-registry positions, or campaign-mapped positions only when the campaign is terminal (Completed/Failed; a Frozen campaign’s LGE position can never be drained mid-recovery). decreaseLiquidity(fatal on error, amounts recorded) →claim(pending fees, recorded) →withdrawof the exact recorded amounts (never a balance-driven sweep — the pool’s unused balance is shared across positions) → forward legs of the recorded amounts (fee-netted, pinnedcreated_at_time) from the pool_manager main account to the backend treasury.- A partially-forwarded removal is re-drained idempotently by
proposal_retry_treasury_forward(op_id).
The proposer sets create/add amounts at proposal time; the pool price can move during the 7-day vote, so the mint consumes the amounts at the pool’s ratio and sweeps the residual back to the pool_manager main account (recorded on the op; retrievable via the existing critical proposal_pool_manager_withdraw). The legacy self-funded, governance-owned custom-pool path (proposal_create_custom_pool) is deprecated: new proposals for it are rejected at creation; proposal_remove_custom_pool_liquidity / proposal_transfer_custom_pool_position remain for any legacy governance-owned position.
SONS equivalents are self-funded from the SONS treasury (its main account) and self-owned: CreatePoolAndAddLiquidity (now critical), the new AddLiquidityToPool (critical, factory-verified, new position per proposal, custody-earmark netting on the campaign token) and SweepPoolUnusedBalance (standard-tier recovery of stranded deposits), with per-DAO position tracking via get_pool_positions. RemoveLiquidity already returns funds to the SONS treasury and, like TransferLiquidityPosition / CollectFees, can optionally target a specific registry position.
Reference: src/backend/src/lib.rs — proposal_treasury_create_custom_pool, proposal_treasury_add_liquidity, proposal_retry_treasury_liquidity, get_treasury_liquidity_ops; src/pool_manager/src/lib.rs — treasury_create_pool_and_add_liquidity, treasury_add_liquidity, proposal_remove_liquidity_to_treasury, proposal_retry_treasury_forward, get_treasury_positions; src/sons_governance/src/lib.rs — execute_add_liquidity_to_pool, execute_sweep_pool_unused_balance, get_pool_positions.
See also
Section titled “See also”- ARCHITECTURE.md — Canister map and governance scope table.
- WORKFLOWS.md — Create LGE, token, vote, create/execute proposal, backend vs frontend upgrade, self-snapshot flows.
- OHSSHII_LOCKER_INTEGRATION.md — OhShii Locker token list and lock management via ONS proposals, SONS vesting vs Locker.
- GOVERNANCE.md — ONS vs SONS, proposal categories, snapshot/restore.
- LIQUID_DEMOCRACY.md — Vote delegation on ONS: follow / unfollow flows, accept-followers toggle, dismiss-follower, snapshot cascade model, four-layer rate limits, cross-canister batched VP fetch contract, event log reference, FAQ.
- LOGGING_SYSTEM.md — Three-tier logging architecture, stable memory log details, frontend monitoring.
- WORLD_ID_VERIFICATION.md — World ID voter verification: flow, privacy model, three-state config (not_configured / optional / required), threshold-ECDSA RP signatures, rate limits, governance controls and operator checklist.
- FRONTEND.md — Frontend architecture: React + JSX + JSDoc
@ts-checktype-checking, the canister-types.js + utils/canister.js bridges, Candid wire conventions, auth providers, and the TypeScript version policy. - Deploy/VOTER_VERIFICATION_GUIDE.md — Verifying WASM hashes for DAO proposals.