Governance: ONS vs SONS
This document explains the two governance paths: ONS (OHSHII Governance, OhShii ecosystem) and SONS (LGE Governance, one DAO per LGE campaign), and lists all proposal types and what they do. For how these mechanisms add up to capture resistance — the threat model, the honest limits, and the comparison with the SNS — see Governance & Capture Resistance.
When to use which
Section titled “When to use which”| User choice | Governance canister | Token for voting | Scope of proposals |
|---|---|---|---|
| OHSHII (or “ONS”) | ohshii_governance (single fixed canister) | OHSHII token (quadratic voting on locked tokens) | Launcher canisters, OhShii Locker, the OHSHII token suite (ledger/index/archive), and the OHSHII DEX liquidity positions |
| Select a SONS token | That token’s sons_governance (one per campaign) | That LGE campaign token | Only that LGE: treasury, WASM, asset canister, pool |
The frontend uses a single Governance page (OnsVotingPage.jsx). The user selects the DAO via a dropdown: OHSHII (ohshii_governance) or one of the SONS tokens. The selected value sets governanceCanisterId: for OHSHII it is OHSHII_GOVERNANCE_CANISTER_ID; for SONS it is the campaign’s governance_canister_id from storage. All subsequent proposal list, create, vote, and execute calls go to that canister.
Failed campaigns and refunds are not governance actions: when a campaign fails (expires without selling out), contributors request refunds via the backend method request_refund within 7 days (contributors on campaigns with deferred referral custody (referral_deferred=Some(true)) reclaim ~95% — the net ~90% plus the held referral 5%, tier-independent; legacy pre-escrow campaigns refund ~90%; the creator stays ~70% in both cases). A Guest-tier contributor is also refunded the 30,000 OHSHII Guest fee on a failed campaign (idempotent, snapshotted at contribution time). See WORKFLOWS.md (Campaign failure and refunds).
Proposal flow diagram
Section titled “Proposal flow diagram”Two-axis voting model (ONS and SONS)
Section titled “Two-axis voting model (ONS and SONS)”Every proposal has a type (NORMAL or CRITICAL) and two possible closing
modes. The model is identical for ONS (ohshii_governance) and SONS
(sons_governance). The values below are the defaults; they remain
DAO-tunable via proposal (see Voting parameters and config via proposals).
| NORMAL proposal | CRITICAL proposal | |
|---|---|---|
| Voting window | Fixed 3 days (a guardian may shorten a NORMAL proposal it creates down to 1 day) | Fixed 7 days |
| Standard close (at deadline) | Quorum 250,000 VP + 15 voters, then simple majority > 50% (adopt > reject) | Quorum 350,000 VP + 20 voters, then ≥ 65% approval |
| Immediate approval (before deadline) | Disabled — a NORMAL proposal always runs its full window | Quorum 450,000 VP + 30 voters, ≥ 75% approval and a positive guardian vote |
| Immediate rejection | Disabled | Symmetric: 450,000 VP + 30 voters, approval ≤ 25% |
Which proposals are CRITICAL is decided by the code — see
is_critical_category / proposal_uses_critical_thresholds /
admin_method_call_is_critical (ONS) and is_critical_proposal /
is_critical_admin_method (SONS). A proposal runs the CRITICAL tier when any
of these holds:
- its category is critical (the upgrade categories,
CriticalGovernanceOperation,SetGuardian, the two arbitrary-dispatch categoriesCustomCallandHttpsOutcall, andSetDaoCanisterRegistry, all of which are always critical) — see the category list below; - its target is an add/replace-guardian proposal;
- it is an
ExecuteAdminMethodwhose raw call is in the per-method critical set — on ONS (admin_method_call_is_critical): the treasury / balance withdrawals, liquidity removal, ICPSwap position transfers, the treasury-funded liquidity operations (proposal_treasury_create_custom_pool,proposal_treasury_add_liquidity,proposal_retry_treasury_liquidity,proposal_remove_liquidity_to_treasury,proposal_retry_treasury_forward), OhShii Locker lock mutations (admin_update_lock_status/admin_delete_lock/admin_transfer_lock_recipient), the OhShii Locker custody moveadmin_subaccount_transfer(it moves tokens out of a lock’s custody subaccount, the same blast radius as a treasury withdrawal; the strongbox itself is a reservedCustomCalltarget, see below), the locker token delisting (remove_supported_token— a token-wide lock cancellation), and campaign deletion; plus two conditional cases —proposal_update_token_metadatawhen it targets the OHSHII token ledger, andadmin_update_ico_infowhen it edits the OhShii root campaigndao-0000000001. On SONS (is_critical_admin_method): the equivalentAdminMethods —TreasuryWithdraw,RemoveLiquidity,TransferLiquidityPosition,CreatePoolAndAddLiquidity,AddLiquidityToPool,ModifyLock,DeleteLock,TransferLockBeneficiary(plus the pre-existingTransferLockCreator,BatchSetFollow,UpgradeIndexCanister). Pool creation / liquidity addition run the critical tier because they move treasury funds in a single proposal.
These per-method criticals stay under the ExecuteAdminMethod category (they
target other canisters, so they cannot use the self-only
CriticalGovernanceOperation) but run the full CRITICAL tier — 7-day window,
higher quorum/approval, and guardian-vetoable.
Guardian-removal proposals keep their extra hardening: a hard 7-day window, never an immediate decision, and the guardian cannot vote on its own removal. Like every Critical proposal, removal is vetoable-but-overridable — the guardian may veto its own removal, but cannot block the community override that completes it.
Reading the per-category “Threshold” field (hardcoded vs votable)
Section titled “Reading the per-category “Threshold” field (hardcoded vs votable)”In the per-category lists below, the Threshold is just the tier — Standard or Critical. Two different things hide behind that word:
- Which tier a category uses is HARDCODED — it changes only with a canister
upgrade (
is_critical_category/proposal_uses_critical_thresholdson ONS,is_critical_proposalon SONS). So “Standard” / “Critical” below is the fixed classification, not a number. - The numeric values of each tier are DAO-VOTABLE DEFAULTS — quorum VP, voter counts, approval %, voting-window lengths and the veto-override bar live in stable memory and are changed by proposal (see Voting parameters and config via proposals), within fixed safety floors.
- Hardcoded safety floors no vote can lower: guardian-removal runs a hard 7-day window and can never pass immediately (it can be vetoed, but only into the community override round); the veto-override window can never drop below 1 day and its quorum / minimum-voters can never be zero.
The complete votable-vs-hardcoded inventory (defaults, floors, which proposal changes each) is in Voting parameters and config via proposals.
ONS (OHSHII Governance): all proposal categories and methods
Section titled “ONS (OHSHII Governance): all proposal categories and methods”OHSHII Governance uses proposal categories for the type of action, and for ExecuteAdminMethod it uses AdminMethodCallData (target canister + method name + Candid-encoded args). The following lists every category and the main executable methods.
The ProposalCategory enum defines 18 variants, but create_proposal (via
validate_category_target_pairing in ohshii_governance/src/lib.rs) rejects 5
legacy categories as retired (see “Retired categories” at the end of this
section). The 13 categories that can be created today (the 11 below plus
TreasuryCyclesWithdraw, the cycles-ledger treasury recharge — Standard tier,
not guardian-vetoable — and SetDaoCanisterRegistry) each get a subsection
below — scope, target, threshold, and what it does. CustomCall and
HttpsOutcall (both always Critical, guardian-vetoable) are the
arbitrary-dispatch lanes: a single on-chain-encoded Candid call to a controlled
canister, and a typed DAO-composed HTTPS outcall to an external server. The
newest, SetDaoCanisterRegistry (also always Critical), is the only
category whose execution changes nothing but what the DAO says about itself. For how to read “Threshold”,
see Reading the per-category Threshold field.
Motion (ONS)
Section titled “Motion (ONS)”- Scope: community signal / discussion.
- Target:
Motion(text). - Threshold: Standard.
- What it does: records the vote; no on-chain execution.
UpgradeCanister (ONS)
Section titled “UpgradeCanister (ONS)”- Scope: upgrade the WASM of a Rust canister ONS controls (backend, dao_storage, pool_manager, ledger/index, …). Cannot target
ohshii_governanceitself — self-upgrades go throughUpgradeGovernanceCanister. - Target:
CanisterUpgrade(CanisterUpgradeData). - Threshold: Critical. Guardian-vetoable. (Every canister upgrade is a code change to a trusted component, so it clears the same bar as a governance self-upgrade. The category is kept distinct from
UpgradeGovernanceCanisteronly for the self-target validation, not for a threshold difference.) - What it does: validates the staged WASM hash + size (re-verified after install — TOCTOU), then
install_code/install_chunked_code. - The target is stopped for the install: before installing, the executor stops a running target, waiting up to 60 seconds for its open calls to finish, installs, then starts it again. If the target does not stop in time, nothing is installed and a target that was running is started again; the proposal fails with that reason. A target that was already stopped, or was already stopping, is left stopped after the install. If the start after a successful install fails (three attempts), the failure is recorded as an event and a delayed start is retried; the delayed start stands down if another proposal has changed the target’s state in the meantime (for example a later
StopCanister). Only one lifecycle operation runs on a given canister at a time: an upgrade, a start or stop, and a snapshot take, load or restore of the same canister refuse each other instead of interleaving. The same rules apply to every step of the batch upgrade proposals below, to the upgrades the launcher backend performs on campaign governance, ledger and index canisters, and to the upgrades a campaign governance canister (SONS) executes through its own proposals (canister and ledger-suite upgrades, index upgrades). The backend never upgrades a campaign governance canister while that campaign is being finalized, and finalization does not start while that upgrade is in progress. - Snapshot and restore always restart the target: the snapshot and restore workflows (on ONS and on each SONS) stop the target, take or load the snapshot, and then start it again in every case — also when the stop timed out (nothing is taken or loaded) or the take or load failed (the error is returned after the restart).
UpgradeGovernanceCanister (ONS)
Section titled “UpgradeGovernanceCanister (ONS)”- Scope: upgrade the
ohshii_governanceWASM itself;target_canister_idMUST be the governance canister. - Target:
CanisterUpgrade(CanisterUpgradeData). - Threshold: Critical. Guardian-vetoable.
- Refused when a critical setting cannot be read: if the new code cannot decode the stored guardian, voting parameters, excluded-countries list, one of the four verification configurations (World ID, DecideID, Telegram, Google), the verification salt that binds each Telegram account and Google address to one wallet, or the marker that keeps the one-time defaults one-time, the upgrade fails and the previous code keeps running with the stored values untouched; the error names the setting and says whether its stored value did not decode or its storage header is not recognised. An unreadable salt is never replaced by a new one. A fixed module is needed to upgrade. While a setting cannot be read, its setter refuses to write; an unreadable verification configuration reads as disabled and closed to new proofs, and an unreadable guardian as no guardian (no principal is ever substituted).
- What it does: self-upgrade through the
post_upgradehook. When the proposal executes, the governance canister first pauses: until the upgrade is applied it refuses every operation that would contact another canister — votes, proposal execution, verification, refunds, the guardian’s resolution drain — with an error that starts withSELF_UPGRADE_QUIET, and it waits for the calls it had already started to finish. Queries and purely local operations keep working, and a refused operation has done nothing, so it can simply be retried after the upgrade. The install is then sent without waiting for its reply, and the upgraded canister marks the proposalExecuted. With no call in progress the pause lasts a few seconds; it ends as soon as the last call already in progress returns. If those calls have not finished after 10 minutes, nothing is installed and the proposal is markedExecutionFailed(SELF_UPGRADE_QUIET_TIMEOUT). If the install is refused, the previous code keeps running, stays paused, and marks the proposalExecutionFailed(SELF_INSTALL_REFUSED) after 30 minutes. The calls the pause waits for include every outbound call the canister makes, among them the signing calls of identity verification on ONS and the treasury-withdraw transfer on SONS; a call whose reply has already arrived no longer holds the pause when its continuation fails, while a call that is really still open keeps holding it.
UpgradeAssetCanister (ONS)
Section titled “UpgradeAssetCanister (ONS)”- Scope: commit a frontend (asset canister) batch.
- Target:
AssetUpgrade(AssetUpgradeData). - Threshold: Critical. Guardian-vetoable. (A frontend upgrade is a code/content change to a trusted surface, so it clears the same critical bar as every other upgrade.)
- What it does:
validate_commit_proposed_batch(at creation and again at execution) thencommit_proposed_batch. At creation the asset-lease check runs before the validation call, and a target that does not answer within 15 seconds gets the proposal refused with nothing created and no fee taken. - Automatic batch cleanup: an asset canister allows at most ONE proposed batch, and proposed batches never expire upstream — so a failed proposal would otherwise block every future frontend upgrade. Every non-executed terminal transition (rejected, expired, veto-upheld, execution-failed) therefore schedules an automatic
delete_batchon the target; the governance timer retries it with exponential backoff and, after repeated failures, emits a visibleasset_batch_cleanup_gave_upevent calling for manual cleanup (guardian fallback, also reachable by the transition-phase deploying principals:guardian_delete_asset_batch). Batches staged on the asset canister but never attached to any proposal are discovered and removed by an orphan probe (triggered by the abandoned-upload-session sweep and by the nextprepare_asset_upload). A batch referenced by a still-live proposal is never deleted. Pending work is public via theget_pending_asset_cleanupsquery. The same cleanup applies to everyAssetCommitstep of anUpgradeDappBatchwhose step did not commit.
UpgradeCanisterBatch (ONS)
Section titled “UpgradeCanisterBatch (ONS)”- Scope: upgrade a token’s ledger suite — index, ledger, and archive canisters — in ONE proposal, executed sequentially in the canonical safe order Index → Ledger → Archive(s). The Ledger step is mandatory (it anchors the suite); the Index step is optional (an auxiliary canister a DAO may not maintain — including it is recommended when one exists); archives are proposer-supplied and explicit — there is no hidden expansion: an archive spawned after the vote ships in the next batch. Cannot target
ohshii_governanceitself. - Target:
CanisterUpgradeBatch(CanisterUpgradeBatchData)— an ordered list of 1–5CanisterUpgradeSteps, each carrying a target canister, a suite role (Index | Ledger | Archive), a WASM hash + size, an optional Candid-encoded upgrade arg, and its own upload session. - Threshold: Critical. Guardian-vetoable.
- What it does: validates the role order and suite shape at creation — with read-only suite probes (
archives()on the ledger,ledger_id()on the index) applied verify-when-verifiable: a probe that answers and contradicts the proposal rejects it; a probe the ledger flavor does not expose logs a warning and proceeds on the proposer’s explicit list. Stages one upload session per step, then executes sequentially with stop-on-first-error: an all-or-nothing controller pre-flight (one uncontrolled target means zero installs), per-step idempotent skip (live module hash already equals the step hash), hash revalidation (TOCTOU), install, post-install verify, and per-step progress persisted in the proposal’sbatch_step_results. No automatic rollback — ledger downgrades are unsupported upstream; a partial failure ends inExecutionFailedwith the per-step record, and recovery is fix-forward: a fresh batch proposal whose already-committed steps auto-skip. See WORKFLOWS.md §5.3 for the full flow.
UpgradeDappBatch (ONS)
Section titled “UpgradeDappBatch (ONS)”-
Scope: upgrade an arbitrary ordered set of canisters — 1–10 steps — in ONE proposal, executed verbatim in the order the proposer specifies (no canonical order; the chain validates step shape, not release semantics). Each step carries one target + one kind + one category + its own git provenance:
- kind —
code(a WASMinstall_code/install_chunked_code) orasset(a frontend asset-canistercommit_proposed_batch); - category — Core (a known OhShii canister: the launcher fleet — backend, dao_storage, pool_manager, the launcher frontend — the locker fleet, the quests canisters, and
ohshii_governanceitself) or Custom (any other principal, rendered with a warning in the proposal view); - provenance — per step (
git_repo_url+git_commit_hash+build_instructions, all required non-empty), because a dapp batch mixes artifacts from several repos.
The governance canister itself may be upgraded only as the FINAL step (and nothing may follow it). The ledger-suite batch (
UpgradeCanisterBatch) remains the specialized suite path;UpgradeDappBatchis the general dapp-release lane. - kind —
-
Target:
DappUpgradeBatch(DappUpgradeBatchData)— an ordered list ofDappUpgradeSteps (target + category + typed payload (WasmorAssetCommit) + per-step provenance). -
Threshold: Critical. Guardian-vetoable.
-
What it does: validates the shape at creation — step count (1–10), pairwise-distinct targets, self-step-last, per-payload shape, Core membership (target must be a known OhShii canister), per-step provenance, and for each asset step an
validate_commit_proposed_batchprobe on the target. Stages one upload session per code step (30-minute window; asset steps consume no session — their bytes already live on the target asset canister). Execution runs an all-or-nothing controller pre-flight over EVERY target — asset targets included (one uncontrolled target means zero installs and zero commits), then sequential, stop-on-first-error: code steps re-validate the staged hash (TOCTOU), install, and verify the module hash; asset steps re-validate thencommit_proposed_batch; per-step progress persists in the proposal’sbatch_step_results. Fix-forward recovery (no rollback): a fresh batch — committed code steps auto-skip by live module hash; asset steps never auto-skip (a committed batch is consumed). See WORKFLOWS.md §5.4 for the full flow.
ExecuteAdminMethod (ONS)
Section titled “ExecuteAdminMethod (ONS)”- Scope: call a named method from the
ADMIN_ALLOWLISTon a canister ONS controls. The general-purpose lane, and the home of the actions the retired categories used to cover. - Target:
AdminMethodCall(AdminMethodCallData)=target_canister_id+method_name+ Candid-encodedmethod_args+description. - Threshold: the per-method TIER is resolved separately from the allowlist gate. Standard by default, with a per-method CRITICAL set (
admin_method_call_is_critical): treasury / balance withdrawals (proposal_treasury_withdraw,proposal_pool_manager_withdraw,proposal_pm_withdraw,proposal_pm_withdraw_account_id), the multi-recipient treasury batch (proposal_treasury_transfer_batch: up to 20 payments from the treasury in one proposal, or one OHSHII linear lock in the OhShii Locker owned by its recipient. The canister the proposal targets pays: the launcher backend, the pool manager orohshii_governance. Several payments in one proposal are available from the launcher backend only. A lock can be funded from any of the three, from its main account or from a named subaccount, and the tokens land in the lock’s own custody subaccount. A launcher-backend subaccount is refused at creation, because those subaccounts hold per-campaign Guest-fee custody. The source account must hold the lock amount plus two ledger fees when the proposal is created; the legs run in order, a failed leg does not stop the others, each leg’s result is kept on the proposal, and the proposal readsExecutedwith aPARTIAL:note when only some legs landed or a leg’s outcome is unknown), liquidity removal (proposal_remove_liquidity,proposal_remove_custom_pool_liquidity), ICPSwap position transfers (proposal_transfer_pool_position,proposal_transfer_custom_pool_position), the treasury-funded liquidity operations (proposal_treasury_create_custom_pool,proposal_treasury_add_liquidity,proposal_retry_treasury_liquidity,proposal_remove_liquidity_to_treasury,proposal_retry_treasury_forward), OhShii Locker lock mutations (admin_update_lock_status,admin_delete_lock,admin_transfer_lock_recipient), the locker token delisting (remove_supported_token), campaign deletion (admin_delete_campaign), stopping a non-self managed canister (proposal_stop_canister) and non-self controller changes (admin_add_controller_to_canister/admin_remove_controller_from_canister), plus the three transfer-journal levers (admin_resolve_ambiguous_transfer,admin_authorize_repeat_transfer,admin_force_clear_ambiguous_transfer— they retire a record that is currently REFUSING a fund exit, so they inherit the tier of the withdrawal they unblock), run the CRITICAL tier (7-day window, higher quorum/approval, guardian-vetoable) without leaving this category. Two methods are conditionally critical:proposal_update_token_metadatawhen the target ledger is the OHSHII token, andadmin_update_ico_infowhen it edits the root campaigndao-0000000001. The four defensive fund-moversicrc1_transfer/emergency_transfer/icrc2_approve/icrc2_transfer_fromare deliberately not on the allowlist (no frontend path). A move out of an OhShii Locker lock’s custody subaccount goes through the typed, criticaladmin_subaccount_transferon the locker backend; the strongbox itself is a reservedCustomCalltarget. Separately, a set of creation-time pre-gate fast-fails runs before the positive allowlist: a handful of self-targeted dangerous methods are redirected to CriticalGovernanceOperation; stopping the governance canister or the OHSHII ledger is hard-blocked (keyed on the decoded target, so a different managed canister can be stopped by proposal); the management canisteraaaaa-aais blocked as anAdminMethodCalltarget (a raw management-canister call belongs on no ad-hoc allowlist lane; it is also structurally reserved fromCustomCall); andremove_supported_tokenfor the OHSHII ledger,admin_list_token, and the deprecatedproposal_create_custom_poolare rejected outright. - Outcome of a value-moving call: for a method on the value-moving list, the recorded status follows what actually moved. A call that did everything reads
Executed. A partial result the target can describe (it answersOkstarting withPARTIAL_OUTCOME:) readsExecutedwith that description as its note. An outcome that cannot be determined — the reply was lost, or the target answersErrstarting withOUTCOME_UNKNOWN:— readsExecutedwith anAMBIGUOUS:note and is not executed again.ExecutionFailedmeans nothing moved. The proposal deposit is refunded in full on bothExecutedandExecutionFailed. The pool manager withdrawals (proposal_pool_manager_withdraw,proposal_pm_withdraw_account_id), the ONS withdrawalproposal_pm_withdraw, the funding leg and the pool-manager call of the backend treasury liquidity operations (proposal_treasury_create_custom_pool,proposal_treasury_add_liquidity,proposal_retry_treasury_liquidity), the liquidity decrease ofproposal_remove_liquidityandproposal_remove_custom_pool_liquidity, and the OhShii Locker custody moveadmin_subaccount_transferstart their error withOUTCOME_UNKNOWN:when the outcome of their own transfer is unknown, and ONS reads that marker on every error shape. - What it does:
call_rawto the target method — but only after a compile-time positive allowlist admits the call. There is no free-form passthrough: anAdminMethodCallwhose(target_canister_id, method_name)pair is not present in the on-chainADMIN_ALLOWLISTconstant (currently 86 pairs across four fixed ICC targets — backend, pool_manager,ohshii_governance-self, OhShii Locker backend) is rejected at proposal creation. Adding a method needs a canister upgrade; arbitrary one-off external calls go through CustomCall instead. The pre-gate blocks/redirects above run first as fast-fails, and the per-method critical tier is resolved separately — but the authoritative authorization gate is the positive allowlist (see ExecuteAdminMethod: allowed method names and targets below).
SetGuardian (ONS)
Section titled “SetGuardian (ONS)”- Scope: elect, replace, or remove the platform guardian.
- Target:
SetGuardian(SetGuardianData { new_guardian })— a non-anonymousSome(p)= add/replace;None/ anonymous = remove. - Threshold: Critical for add/replace (guardian-vetoable). Removal is hardened: a hardcoded 7-day window, never immediate, and the guardian cannot vote on its own removal; like every Critical proposal it is vetoable-but-overridable (see Guardian Overridable Veto).
- What it does: validates the principal (rejects anonymous, the governance canister, the management canister, and OhShii infra canisters), sets the guardian, then best-effort syncs the guardian caches on the SONS + non-ONS canisters.
- A removal survives every later upgrade. The default guardian and the default World ID / DecideID configurations that
post_upgradecan fill in are applied at most once per canister, behind a persisted latch; a guardian removed or a configuration cleared by an approved proposal is not put back by a later upgrade. The latch is itself one of the critical settings above: an upgrade whose code cannot read it is refused, so a damaged latch can never cause the defaults to be applied a second time.
CriticalGovernanceOperation (ONS)
Section titled “CriticalGovernanceOperation (ONS)”- Scope: the high-risk lane — controller add/remove and snapshot restore/delete targeting the governance canister, voting-parameter changes, the platform suggested-default excluded-countries list, and the centralized Shield kill switch. Cannot stop the governance canister.
- Target:
AdminMethodCall(..)restricted to an 18-method allowlist — the four self-management ops (admin_add_controller_to_canister,admin_remove_controller_from_canister,admin_restore_snapshot_with_workflow,admin_delete_canister_snapshot),set_voting_parameters,set_default_excluded_countries(the platform-wide suggested-default list of countries excluded from LGE participation, pre-populated into new LGE creation flows, together with its baseline subset — the entries included in every launch, which creators cannot remove and replacement proposals cannot drop; updating the default never touches any campaign’s already-resolved list), and the ten voter-verification setters/revocations (set_world_id_config,admin_clear_world_id_verified,set_decideid_config,admin_clear_decideid_verified,set_decideid_mode,set_decideid_oidc_config,set_telegram_config,admin_clear_telegram_verified,set_google_config,admin_clear_google_verified), and the two provider-intake switches (set_world_id_accepts_new_proofs,set_decideid_accepts_new_proofs), which sit on this list AND on theExecuteAdminMethodallowlist because their tier depends on the argument: reopening a provider to new proofs runs critical, closing it does not — orSetShieldPreauthMode(..). - Threshold: Critical. Guardian-vetoable.
- What it does: executes the allowlisted method, or fans the requested Shield pre-auth mode out to the 5 core canisters (backend, dao_storage, ohshii_governance, locker backend, lock_storage).
CustomCall (ONS)
Section titled “CustomCall (ONS)”- Scope: dispatch ONE arbitrary Candid call to a named method on ANY canister ONS controls, where the argument is supplied as human-readable Candid text and the governance canister — not the frontend — encodes it. The general escape hatch for a method that has no dedicated typed proposal path, without ever letting the UI become the interpreter.
- Target: a custom-call payload = target canister id + method name +
candid_text_args(a Candid textual value). - Threshold: Always Critical. Guardian-vetoable. Fixed 7-day window, critical quorum/approval bar, override round — a raw call to an arbitrary method is at least as dangerous as an upgrade, so it never runs the standard tier.
- On-chain encoding (the security model). At creation the governance canister fetches the target’s published
candid:servicemetadata (via the management canistercanister_metadata), parses it, and encodescandid_text_argsagainst the REAL signature ofmethod_nameinto the executable blob. The frontend is NEVER the interpreter — this structurally forecloses the “vote on X, execute Y” class of attack, because the bytes that will run are derived on-chain from the exact human-readable text the voters saw. The proposal pins the target module_hash, the sha256 of the encoded payload, and a normalized render of the call. - Re-verification at execution. Before dispatch the executor re-verifies the pinned module_hash — fail-closed if the target’s code changed at all since the vote (a changed target is a different program than the one the DAO approved) — and that governance is still a controller of the target, then dispatches with a bounded wait (never unbounded).
- Reserved targets (a CustomCall can never target them). The management canister
aaaaa-aa, the governance canister itself, the OHSHII / ICP / cycles ledgers, the OhShii Lockerlock_storage(the voting-power source of truth) and the locker backend (its only writer), and the OhShii Locker strongbox (the custody canister that holds every lock’s tokens) are structurally reserved (is_reserved_custom_target), checked at creation and again at execution. This keeps the money-rail, voting-power, custody and self-management surfaces on their dedicated typed proposal paths: lock changes and custody moves go through the locker backend’s individually classifiedExecuteAdminMethodentries (custody moves through the criticaladmin_subaccount_transfer). A free-form call to the strongbox could otherwise move any lock’s tokens outside the locker’s checks, or grant a standing transfer right (authorize_proxy) with no further vote. The governance canister remains a controller of the strongbox, so a change the DAO needs there goes through a critical canister-upgrade proposal. - Preview before proposing.
custom_call_preview(target, method_name, candid_text_args)is an update that returns the exact render + sha256 + module_hash the canister WOULD encode, without creating a proposal, so a proposer sees precisely what the DAO will vote on. It is rate-limited (a cycle-reserve floor + a global cap — ONS 120/hour) as a Sybil cycle-drain defence. - What it does / outcome: see the CustomCall workflow — the executor follows an “Executed unless provably no-op” policy (a reply, including a business
Err/errarm, means the call ran, so the proposal is Executed; an ambiguous outcome is Executed + flagged for manual verification; only a clean, provably-no-effect system reject fails it).
HttpsOutcall (ONS)
Section titled “HttpsOutcall (ONS)”- Scope: a typed, DAO-composed HTTPS request to an external server — a canister HTTPS outcall, voted before it is sent. (It is a canister HTTPS outcall; it is not a bridge and not an oracle.)
- Target: an HTTPS-outcall payload =
url,method(Get/Post/Head), requestheaders, an optionalbody, a mandatorymax_response_bytes(1..=2 MiB), amax_cyclescap,is_replicated, and a fixed, wasm-exported transform chosen by id (StripHeaders/StripHeadersAndBody). A proposal can never supply its own transform — only pick one of the built-in ones — so the response-normalization code is always canister code the proposer cannot inject into. - Threshold: Always Critical. Guardian-vetoable.
- Cost gate. At execution the executor computes the real outcall cost from the request shape and
max_response_bytesand aborts if cost >max_cycles— the DAO’s declared ceiling is enforced, never silently exceeded. - What it does / outcome: see the HttpsOutcall workflow. A 2xx →
Executed(eventerror = None); a non-2xx (e.g. 500) → stillExecuted, with the status in the event (error = Some("HTTP status N")) — the request WAS delivered and a non-idempotent POST may already have had a side effect, so a retry would risk double execution; only a transport-level failure (the outcall never completed) →ExecutionFailed(status 0, error set).
SetDaoCanisterRegistry (ONS)
Section titled “SetDaoCanisterRegistry (ONS)”- Scope: replace the DAO canister registry — the public list of canisters this DAO declares it operates, rendered by the DAO Canisters panel on the governance page next to the intrinsic canisters (governance / ledger / index), which are read certified from the IC itself. The registry half is an attestation by the DAO, not a proof; the panel labels it as such, and every module hash it shows is still read live from the certified state tree, never taken from a registry record.
- Target:
DaoCanisterRegistryData { entries : vec ManagedCanister }, where each entry is{ canister_id, name, category }. Execution REPLACES the whole list — there is no add/remove delta, so what voters read is exactly what lands. A delta would be unreviewable: its outcome would depend on state at execution time, which may change between the vote and the execution. - An EMPTY list is valid and is the removal path. It clears the registry permanently: the one-shot platform seed keys off a
writtenlatch, not off emptiness, so it never comes back on a later upgrade. - Validation (at creation AND again at execution, fail-closed): at most 30 entries; no duplicate
canister_id; the anonymous principal, the management canister (aaaaa-aa) and every non-canister principal are rejected;name1–64 chars andcategory1–32 chars after trim. Characters that would need escaping in a frontend (< > & ' " \and control characters) are rejected, not stripped — a governance decision must land byte-for-byte as voted. Errors carry the offending row index. - Threshold: Always Critical. Guardian-vetoable. It moves no funds and grants no power — but it is a public trust surface: a hostile entry in the list a DAO publishes as “canisters we operate” is a phishing vector. The practical consequence is deliberate and worth stating plainly: removing a hostile entry takes at least the full 7-day critical window, and a guardian can veto the removal into a 24h override round.
- DISPLAY-ONLY — a hard constraint. No canister may read this list as an authorization input: not a cycle-balance membership test, not an upgrade-target predicate, not a fee/quota decision, not a controller check. The moment it gates something, a list that means “the DAO says these are ours” becomes a privilege table, and the proposal that edits it silently becomes a privilege-escalation vector. In particular it is not SONS’s
get_managed_canisters(dynamic discovery), which does feed a cycle-balance predicate and must stay exactly as it is. - Read surface:
get_dao_canister_registry : () -> (vec ManagedCanister) query, anonymous callers accepted — this is public transparency data rendered before any wallet connects.
Retired ONS categories (rejected at creation)
Section titled “Retired ONS categories (rejected at creation)”UpdateLedger, ListTokenOnLocker, AddTokenController, ModifyLock, and
DeleteLock still exist in the enum but create_proposal rejects them with
“Proposal category … is retired and can no longer be used.” Their old actions
are now performed via ExecuteAdminMethod: token-ledger metadata via the
ledger’s admin_* methods, locker token-list maintenance on the locker backend
(listing a NEW token is disabled: admin_list_token proposals are refused at
creation, see the table below), and locker
lock edits via admin_update_lock_status / admin_delete_lock on the locker
backend (see the ExecuteAdminMethod tables below). Their legacy Campaign /
Token / Lock targets are no-ops in the executor.
ONS ExecuteAdminMethod: allowed method names and targets
Section titled “ONS ExecuteAdminMethod: allowed method names and targets”When the category is ExecuteAdminMethod, the proposal carries AdminMethodCallData: target_canister_id, method_name, method_args, description. In production these actions run only through an approved ONS proposal executed by ohshii_governance; a direct invocation is a transition-phase path that is retired at the DAO handover. The methods below are the ONS ADMIN_ALLOWLIST — the compile-time positive allowlist (src/ohshii_governance/src/types.rs) checked at proposal creation: an AdminMethodCall whose (target_canister_id, method_name) pair is not present is rejected. The allowlist currently holds 86 (target, method) pairs across four fixed ICC targets; the tables group them by target.
Note — the positive allowlist is the authoritative gate. Under
ExecuteAdminMethodthe canister maintains a compile-time positive allowlist (ADMIN_ALLOWLISTinsrc/ohshii_governance/src/types.rs, currently 86(target, method)pairs):create_proposalrejects any pair not on it. There is no free-form / blocklist + redirect passthrough — arbitrary one-off external calls go through CustomCall (module-hash-pinned, guardian-vetoable), and adding a new allowlisted method requires a canister upgrade. Three creation-time behaviours run as pre-gate fast-fails before the allowlist: (1)admin_restore_snapshot_with_workflow,admin_delete_canister_snapshot,set_voting_parameters,set_default_excluded_countries, and the ten voter-verification setters/revocations (set_world_id_config,admin_clear_world_id_verified,set_decideid_config,admin_clear_decideid_verified,set_decideid_mode,set_decideid_oidc_config,set_telegram_config,admin_clear_telegram_verified,set_google_config,admin_clear_google_verified) are redirected to CriticalGovernanceOperation; (2)admin_stop_canister/proposal_stop_canisterare hard-blocked when the decoded target is the governance canister or the OHSHII ledger; (3)admin_add_controller_to_canister/admin_remove_controller_from_canisterare redirected to CriticalGovernanceOperation when the decoded first arg is the governance canister itself. The per-method critical set (admin_method_call_is_critical— withdrawals, liquidity removal, position transfers, lock mutations, campaign deletion) still resolves the tier IN-PLACE underExecuteAdminMethod. Only CriticalGovernanceOperation keeps its own separate 18-method allowlist (the two controller ops + the two snapshot ops +set_voting_parameters+set_default_excluded_countries+ the ten voter-verification setters/revocations listed in (1) + the twoaccepts_new_proofsintake switches, which are on BOTH lists because their tier depends on the argument — see the note below the ONS self table). The tables below illustrate the main methods per target; the authoritative accept-set isADMIN_ALLOWLIST.Non-self canister management runs at the CRITICAL tier. Two management actions are proposable under
ExecuteAdminMethodat the CRITICAL tier (7-day window, guardian-vetoable):proposal_stop_canisteragainst a non-self managed canister — the block keys on the decoded first argument, so stopping the governance canister and the OHSHII ledger stays blocked while a different managed canister can be stopped by an approved proposal; andadmin_add_controller_to_canister/admin_remove_controller_from_canisteragainst a non-self canister — managing the governance canister’s OWN controllers routes toCriticalGovernanceOperation, while changing another managed canister’s controllers is an allowlisted CRITICAL proposal.
Backend (ohshii_launcher_backend):
| method_name | What it does |
|---|---|
admin_set_feature_statuses | Single multi-toggle method. Enable/disable any subset of the 7 platform feature toggles in one call — LGE creation, LGE participation, token creation, index canister creation, the Bitcoin Gateway and Solana Gateway (cross-chain), and OhShii Locker public lock creation — each with an optional popup message. The request carries one optional record per feature; only the provided ones change. The locker_lock_creation toggle is forwarded to the locker backend; it gates only the public create_lock_v2 / create_lock_v3 paths (not the governance treasury lock path, and never SONS-governance locks). This is the method the “Feature Toggles” proposal uses; the proposal UI shows a current→proposed diff. A switch whose stored value the installed module cannot read is reported disabled, and writing it is refused until a module that reads the stored value is installed; an upgrade to a module that cannot read a switch or a fee configuration is refused, and the backend keeps its previous module. |
admin_set_ico_creation_status | (Legacy single-feature setter; retained for compatibility.) Enable/disable LGE creation platform-wide (with optional message). |
admin_set_ico_participation_status | (Legacy.) Enable/disable LGE participation. |
admin_set_token_creation_status | (Legacy.) Enable/disable token creation. |
admin_set_index_creation_status | (Legacy.) Enable/disable index canister creation. |
admin_update_campaign_status | Update campaign state (Active, Finalizing, Completed, Failed, Frozen) and/or tokens_sold. Completed is terminal and must be reached by the finalization flow; Finalizing is set (via an approved ONS proposal executed by ohshii_governance) to start/retry finalization. |
admin_update_ico_params | Update global LGE parameters. |
admin_update_ico_info | Update campaign info (description, website, telegram, etc.); guardians can also hide/unhide and freeze. |
admin_update_excluded_countries | Replace a specific campaign’s excluded-countries list (regional participation eligibility, ISO 3166-1 alpha-2 codes). Takes effect immediately for participants declaring from that point forward; the confirmation is a frontend gate (not recorded per-participation on-chain), and the change is forward-only — earlier participations are never retroactively affected. Platform-baseline entries are always retained — a replacement can add to or modify only the non-baseline portion (the executor unions the current baseline into every applied list). The proposal payload renders the full list inline (codes + names) plus a content hash, so voters verify exactly what they vote on. |
admin_delete_campaign | Delete a campaign (cleanup; in production executed by ohshii_governance via an approved ONS proposal). |
admin_create_pool_early | Create ICPSwap pool for a campaign before normal finalization. |
admin_create_snapshot_with_workflow | Create a canister snapshot (orchestrated via backend). Target: any canister the backend can control. |
admin_restore_snapshot_with_workflow | Restore from snapshot. Not allowed under ExecuteAdminMethod; must use CriticalGovernanceOperation (any target). |
admin_delete_canister_snapshot | Delete a snapshot. Not allowed under ExecuteAdminMethod; must use CriticalGovernanceOperation. |
admin_add_controller_to_canister | Add a controller to a canister. If the decoded first arg is ohshii_governance, must use CriticalGovernanceOperation; against a non-self canister it is an allowlisted CRITICAL ExecuteAdminMethod. |
admin_remove_controller_from_canister | Remove a controller. If the decoded first arg is ohshii_governance, must use CriticalGovernanceOperation; against a non-self canister it is an allowlisted CRITICAL ExecuteAdminMethod. |
admin_set_guest_fee | Set the OHSHII Guest fee (per-LGE first-purchase fee for Guest-tier wallets). The fee is transferred 100% to the treasury — nothing is burned. |
proposal_treasury_create_custom_pool | Critical. Create a custom ICPSwap pool funded from the backend treasury: the backend moves the passcode reserve (and, when add_liquidity = true, the token amounts + fee buffers) to the pool_manager with ledger-deduplicated (pinned created_at_time) funding legs, then the pool_manager creates the pool and mints a full-range position owned by the pool_manager. Amounts always define the initial price; a public front-run of the pool creation degrades deterministically to add-liquidity on the existing pool. Every step is journaled on-chain; a failed op is resumed by proposal_retry_treasury_liquidity. |
proposal_treasury_add_liquidity | Critical. Add treasury liquidity to an EXISTING ICPSwap pool: funds move backend → pool_manager, the pool is verified against the ICPSwap factory (anti-lookalike check), and a new full-range position is minted per proposal and tracked in the pool_manager’s treasury-position registry. |
proposal_retry_treasury_liquidity | Critical. Idempotently resume a failed treasury create/add operation by op id: re-sends only the funding legs not yet confirmed (ledger dedup via the pinned created_at_time), then re-drives the pool_manager saga from its persisted phase journal. A retry against an op that actually completed returns success without moving funds. |
proposal_resume_frozen_governance | Resume a campaign whose creation failed after payment, by installing the governance code into the canister that campaign’s own record already names, instead of buying a new one. Refuses anything else: a campaign that is not frozen, a canister recorded for a different campaign, or one that already holds code. If the canister holds less than a newly created governance canister receives, it tops it up only from that campaign’s own creation payment (the cycles the payment bought that the creation did not spend), never from the platform; any remainder is recorded and left to a manual top-up. It moves no campaign state — it completes the install the failed creation never reached, and records what it reused and what it topped up. |
Backend levers never act on an OhShii infrastructure canister. The backend levers whose target canister is named in the call, or read from a stored campaign or token record, refuse before any call when that target is an OhShii infrastructure canister: the launcher backend and frontend, dao_storage, pool_manager, ohshii_governance, the OhShii Locker backend, frontend, proxy, strongbox and lock_storage, and the OHSHII ledger and index. The refusal text starts with INFRA_TARGET_REFUSED. The levers covered are admin_add_controller_to_canister, update_index_canister_controller, update_token_canisters_settings, upgrade_icrc1_token, proposal_update_token_metadata, the index levers (admin_upgrade_index_canister, proposal_edit_index_canister_frequency), the snapshot / restore workflows on their admin branch (an is_admin caller; a governance canister orchestrating its own snapshot or restore takes a different branch), and the writes that read their target from a stored record (guardian_update_token_logo, update_standalone_token, the imported-DAO controller handover remove_backend_controller_from_imported_governance, the post-finalization steps). Those canisters change only through their own controllers and the ONS proposal categories that upgrade them. Two ONS proposals are the only exceptions, and each is admitted only when ohshii_governance executes it:
proposal_update_token_metadataon the OHSHII token ledger (a CRITICAL proposal);proposal_edit_index_canister_frequencyon the OHSHII index canister, paired with the OHSHII ledger.
Any other caller of these levers on those two canisters is refused. When a campaign completes, the step that hands its ledger, index and archives to the campaign DAO skips, and records in the log, an archive or index id that names an infrastructure canister (the index is never touched, and the step completes); a ledger that names one stops that step.
OhShii Locker backend (ohshii_locker_backend):
| method_name | What it does |
|---|---|
admin_update_lock_status | Update lock status (e.g. Active, Frozen, Cancelled). |
admin_delete_lock | Delete a lock. |
admin_transfer_lock_recipient | Change the beneficiary of a lock. |
admin_list_token | No longer proposal-creatable — hard-blocked at ONS create_proposal, because the Locker’s supported-token list is frozen (the Locker accepts a token that is not already listed only from the launcher backend, which does not call it, so only metadata refreshes of the listed tokens remain possible through a direct guarded call). |
admin_update_token | Update token config (e.g. min_lock_amount, logo). |
remove_supported_token | Remove a token from the supported list (locks become Cancelled; users can reclaim). CRITICAL tier: a token-wide cancellation clears the critical thresholds + guardian veto, symmetric with admin_delete_lock. OHSHII (7rrmx-tyaaa-aaaal-qsraq-cai) can never be removed — it is a permanent supported token; removing it would cancel every active OHSHII lock. The proposal is rejected for the OHSHII ledger at the frontend, at ONS proposal creation, and (authoritatively) in the locker backend. If the removal cannot cancel every lock, it reports how many were cancelled and is not recorded as a success; a new removal proposal for the same token resumes the cancellation of the remaining locks. |
admin_retry_reclaim_fixup | Replay a journaled reclaim fixup entry. Used when reclaim_lp_lock successfully transferred a position but the subsequent storage update failed; this method CAS-updates the on-chain lock record to reconcile the audit trail. Takes a single lock_id: text argument. |
admin_clear_reclaim_fixup | Dismiss a journaled reclaim fixup entry that has already been reconciled offline. Takes a single lock_id: text argument. Does NOT perform any storage update. |
admin_settle_unlock_externally | CRITICAL tier (on the ONS allowlist). Count one unlock tranche whose transfer outcome was unknown as delivered out of band. Takes lock_id: text, unlock_idx: nat32 and evidence_ref: text (the ledger block or transaction that proves the delivery; it must not be empty). Moves no tokens: it advances the lock’s unlock counter, records the tranche as settled by hand, and clears the pending unlock entry, so the tranche is never sent again. If the tranche was not in fact delivered, the recipient loses it. |
admin_retry_unlock_with_new_identity | CRITICAL tier (on the ONS allowlist). Send one unlock tranche whose transfer outcome was unknown again, under a fresh transfer identity, so the ledger’s deduplication cannot swallow it. Takes lock_id: text and unlock_idx: nat32. The call itself moves nothing; it reactivates the lock and the locker’s unlock timer sends the transfer. Use it only when the ledger shows the first transfer did not land: if it did, the recipient is paid twice from tokens that back other locks. A tranche already counted is not sent again. The locker refuses the retry with a named code, and changes nothing, whenever a new send is not provably safe: the tranche is already paid (UNLOCK_RETRY_ALREADY_PAID) or already settled, a send is still in flight (UNLOCK_RETRY_IN_FLIGHT), or its outcome is unknown and the first send could still land (UNLOCK_RETRY_WITHIN_DEDUP_WINDOW, UNLOCK_RETRY_OUTCOME_UNKNOWN). A tranche whose earlier send the ledger answered as a duplicate counts as already on the ledger: the retry is refused with UNLOCK_RETRY_ALREADY_PAID, and the locker’s unlock timer holds that tranche for admin_settle_unlock_externally instead of sending it again. |
guardian_resolve_pending_unlock | STANDARD tier (on the ONS allowlist); the guardian can also call it directly. Decide one unlock tranche whose transfer outcome stayed unknown (its proxy record is still pending). Takes lock_id: text and unlock_idx: nat32; the proposal only names the tranche, the locker decides. It reads the balance of the lock’s own custody subaccount: if exactly this tranche plus one ledger fee is missing, the tranche is counted as delivered and never sent again; if the tranche is still there after the landing horizon (24 h 10 min after the send), it is sent again once under a new transfer identity; any other balance (another debit, a surplus of half a tranche or more, a changed ledger fee, a lock without its own subaccount) is refused and nothing changes (UNLOCK_RESOLVE_* codes). A gift of tokens to the subaccount can only be paid out to that lock’s recipient. The call moves nothing itself; a re-sent tranche is paid by the unlock timer. |
admin_subaccount_transfer | CRITICAL tier (on the ONS allowlist). Move tokens out of one locker custody subaccount to a principal’s default account. Takes subaccount: blob (32 bytes), destination: principal, token_id: principal and amount: nat (raw units; the ledger fee is charged on top). Used to return the tokens of a lock whose creation outcome was unknown and that does not exist. It accepts ANY custody subaccount, including the one backing a live lock, so a proposal must name a subaccount that no lock uses; it refuses only a subaccount that a credit or reclaim is operating on at that moment. When the outcome of the transfer is unknown — the reply to ONS was lost, or the locker reports that its own transfer to the ledger has an unknown outcome — the proposal is recorded as an ambiguous outcome (Executed with an AMBIGUOUS: note), not as a failure; a failure is recorded only when the transfer provably did not happen (for example a ledger refusal). After an ambiguous outcome, check the ledger balance of the subaccount and of the destination before proposing it again: a second proposal is a second transfer. |
The reclaim fixup queue can be inspected via the admin_list_reclaim_fixups : () -> (vec ReclaimFixupEntry) query method on the locker backend. It is a query method, so it is not called via a governance proposal — it can be read directly by any caller in the locker’s is_admin() set, which includes the ONS OHSHII Governance canister.
Pool manager:
| method_name | What it does |
|---|---|
proposal_fee_collect_to_treasury | Collect the ICPSwap trading fees of a position the pool manager owns and forward them to the OhShii backend treasury. The claim is refused before anything moves while a treasury liquidity operation or a liquidity removal on the same pool is unfinished. Each forward waits until the pool’s withdraw queue no longer holds the claimed fees, and sends the payout the pool reported, less its ledger fee, never an amount read from the pool manager’s balance, so value that other operations move into or out of that balance meanwhile is not reported as a payout still pending. If the queue is empty but the pool manager’s balance does not show the payout, the forward still sends it and the event is recorded for review; if the pool’s own payout failed after leaving its queue, the pool manager’s balance has fronted at most that payout. A failure after the claim is reported with the step that failed and where the funds are, and the proposal is recorded as executed with that note, because the claim itself already moved value. A failure before the claim is recorded as a failed execution. An outcome that cannot be determined is reported as unknown, so the proposal is recorded as executed with an ambiguity note rather than as failed, and it is not re-run. |
proposal_fee_collect_and_buyback | Collect the fees of a position the pool manager owns, then swap the ICP that actually arrived for the token the proposal names (slippage-protected). The collected tokens and the bought tokens are forwarded to the treasury. Same guarantees as proposal_fee_collect_to_treasury. |
proposal_pool_manager_withdraw | Withdraw ICP or an ICRC-1 token from the pool manager to a recipient. Supports an optional source subaccount (raw 32-byte) and an optional recipient subaccount. |
proposal_pm_withdraw_account_id | Withdraw ICP held under a legacy ICP AccountIdentifier (e.g. LGE deposit funds) by resolving the AccountIdentifier back to its raw subaccount, then transferring to a recipient. ICP-only; used when the source is a 64-hex AccountIdentifier rather than a raw 32-byte subaccount. The AccountIdentifier is checked when the proposal is created, through the pool manager’s resolve_subaccount_for_account_id query: an identifier the pool manager cannot resolve is refused before any fee, and if the pool manager cannot be reached the creation is refused with a message to try again. |
proposal_remove_liquidity_to_treasury | Critical. Remove liquidity from a pool_manager-owned position (treasury-registry positions, or campaign positions only when the campaign is terminal) and forward both withdrawn assets to the backend treasury in the same operation. Withdraws move the exact recorded amounts (never a balance-driven sweep) and forward legs are fee-netted with pinned created_at_time. ICPSwap also pays removed amounts out on its own; when that payout reaches the pool manager first, the removal waits for it and forwards what arrived less the ledger fee, so the pool manager’s own funds never cover a fee. A failure after value moved is recorded as executed with a note saying where the funds are; an undeterminable outcome is recorded as unknown and not re-run. |
proposal_retry_treasury_forward | Critical. Idempotently re-drain the pending withdraw/forward legs of a removal operation by op id (same pinned-leg dedup model). |
admin_resolve_ambiguous_transfer | Critical. Record the outcome of a ledger reconciliation for one entry of the transfer journal (see the note below). did_land = false retires the record and a retry becomes possible again; did_land = true converts it to committed — never retires it, which would permit the double payment — and the block index is required. Evidence is mandatory. |
admin_authorize_repeat_transfer | Critical. Authorise a second, genuinely identical payment for a committed journal record. A new authorisation, not a correction; the cited block index must MATCH the recorded one. |
admin_force_clear_ambiguous_transfer | Critical. Retire a journal record regardless of state — the recovery path for a stuck state machine. Emits a distinct audit event from both levers above. |
admin_reconcile_campaign_pool | Standard. Backfill pool_manager’s campaign-keyed pool records (POOLS / POSITION_IDS / POOL_CANISTER_IDS / POOL_TOKENS / TOKEN_LEDGER_IDS / TOKEN_SYMBOLS) for a campaign whose ICPSwap pool + position exist on-chain but were never recorded (e.g. a legacy/externally-seeded pool like the OHSHII root pool). It discovers the pool from the ICPSwap factory (getPool on the campaign-token/ICP pair, 0.3% tier) and proves ownership via getUserPositionIdsByPrincipal before writing — it moves no funds and can only adopt a position pool_manager actually owns. After it runs, every campaign-keyed query (get_pool_state / get_position_id / get_pool_details / has_pool_position / campaign_has_liquidity_position) and every UI surface reading them become truthful. Also directly callable by the transition-phase deploying principals. |
Token standard requirement (pool create / add liquidity / swap). Every pool-creation, add-liquidity, and treasury-swap proposal — on both ONS and SONS — accepts only tokens that genuinely implement the ICRC standard they are used as. Each non-ICP token ledger is checked against its advertised
icrc1_supported_standards: ICRC-1 is required for every leg, and ICRC-2 is additionally required whenever that leg uses the approve/transferFromdeposit path (i.e. its standard is ICRC-2), so an ICRC-1-only token is still usable via the transfer+deposit path. The check runs fail-closed at execution before any funds move (backend / pool_manager / SONS) and is mirrored as a hard block at proposal creation in the Create Proposal form (the ledger inputs show a live metadata panel — symbol, decimals, fee, and ICRC-1/ICRC-2 support — and submission is blocked for a non-conforming token). The ICP ledger is exempt (ICPSwap always labels itICP). A swap whose pool-metadata input standard normalizes to neither ICRC-1 nor ICRC-2 is rejected rather than silently defaulting to the ICRC-2 path.Treasury swap outcomes (
proposal_treasury_swap). On the ICRC-1 path the treasury transfers the swap amount plus the pool’s own input fee (read from the pool’s fee cache) into its deposit account in the pool, which is what the pool’sdepositAndSwapdraws; both pool fee arguments come from that cache. A refusal by the pool is reported as a refusal that names the pool’s error, never as an unknown outcome, and nothing is swapped. When the input had already been transferred to the pool, the backend returns it to the treasury main account in the same call and says where it went; if it cannot do so, the reply says that nothing was swapped and where the input is, and an identical swap is refused until the input is recovered withsweep_pool_unused_balanceand the journal record is retired withadmin_resolve_ambiguous_transfer(did_land = false).Pool initialization uses the ledger’s own standard (ICRC-2 preferred). When a proposal creates a pool (as opposed to adding to an existing one), the standard the pool is initialized with for each non-ICP token is taken from the ledger’s advertised
icrc1_supported_standards, not from the proposal: a ledger that advertises both ICRC-1 and ICRC-2 initializes the pool as ICRC-2, one that advertises only ICRC-1 initializes as ICRC-1, and one that advertises neither is rejected. The same resolved standard drives both the pool’s registered standard and the deposit path, so they cannot disagree. Adding liquidity to an already-existing pool does not change its standard.
Withdrawal proposals — memo and source format. The withdrawal proposals (Treasury Token Withdrawal, Pool Manager Withdrawal, OHSHII Governance Withdrawal, and the SONS TreasuryWithdraw
AdminMethod) accept an optional memo. The memo is validated against the target ledger’s advertised maximum memo length (icrc1:max_memo_length) — in the Create Proposal form and again before the on-chain transfer; when the ledger does not advertise a maximum, the memo field is disabled. The Treasury Token Withdrawal debits only the backend’s main account: a source other than the main account (empty, or 32 zero bytes) is refused at execution and nothing moves; the same holds for every payment of the multi-recipientproposal_treasury_transfer_batch, and ONS refuses such a backend proposal at creation. The Pool Manager, OHSHII Governance and SONS withdrawals accept the source as a raw 32-byte subaccount. A source given as a legacy ICP AccountIdentifier is reachable only viaproposal_pm_withdraw_account_idon the pool manager, which resolves it to the underlying subaccount; the Pool Manager Withdrawal proposal routes to it automatically when the source is an AccountIdentifier.
Note for proposers and voters — a failed withdrawal is not automatically a safe re-proposal. A ledger transfer is committed the moment the ledger processes the message, whatever the response turns out to be, so “the proposal ended in an error” and “the funds did not move” are not the same statement. Each withdrawal therefore records its own outcome in the canister’s log, and the marker says which case you are in:
Marker What it means Re-proposing TREASURY_TRANSFER_COMMITTEDthe ledger accepted it; the entry carries the block index already paid — do not re-propose TREASURY_TRANSFER_DEFINITE_FAILthe ledger refused it, or the call was cleanly rejected — no funds moved safe TREASURY_TRANSFER_AMBIGUOUSthe call timed out or the reply could not be decoded — the transfer may have landed stop: confirm against the ledger’s own transaction history first TREASURY_TRANSFER_RAW_OKa checkpoint written the instant the ledger replied, before the outcome is known not a verdict — read the resolution marker that follows it A re-proposal carries a new proposal id and is not deduplicated by the ledger, so an unverified retry on an ambiguous outcome pays twice. The canister log is a rotating buffer and is the pointer — the ledger’s own transaction history is the authority on whether the block exists. This mirrors the same discipline the
CustomCallproposals already follow (see WORKFLOWS.md, “Note (ambiguous outcomes)”).
The canister ENFORCES that, rather than relying on the marker being read. Every treasury withdrawal (backend, pool manager, OHSHII governance, and each campaign DAO) claims a record in a durable transfer journal — keyed on the withdrawal’s semantics
(ledger, source, recipient, recipient subaccount, amount)— before dispatching the transfer, and refuses while a prior identical attempt is unresolved or committed. The refusal names the journal key and which state it is in. Reading the log is not required for safety: the party that re-proposes is an electorate, so the guarantee is enforced by the canister itself.The journal never expires and is never trimmed; a record leaves it only when the ledger provably refused the transfer (nothing landed, so a retry is safe and the block lifts automatically) or through one of three governance proposals, which mean deliberately different things:
Proposal Meaning Effect Resolve Ambiguous Transfer ( did_land = false)“I checked the ledger; it did not land” record retired, the withdrawal may be re-proposed Resolve Ambiguous Transfer ( did_land = true, block index)“I checked the ledger; it did land at block N” record converted to committed — the withdrawal stays blocked Authorize Repeat Transfer (prior block index) “it landed at block N and a second identical payment is intended” record retired, a second payment permitted Force Clear Ambiguous Transfer (justification) recovery path for a stuck record record retired, logged as a distinct event Treasury cycles top-ups follow a narrower rule. A top-up (
proposal_treasury_cycles_topup) claims a record in the same journal, keyed on the target canister and the amount, but an identical top-up is refused only while the previous identical one has an uncertain outcome (still in flight, or its transfer reply was lost). After a top-up whose cycles were credited, a new identical top-up runs normally, with no repeat authorisation: a periodic top-up of the same canister by the same amount is routine. A top-up whose ICP reached the cycles minting canister without the cycles being credited, and a record resolved as landed after a reconciliation, still block until Authorize Repeat Transfer. Withdrawals and swaps keep the rule above.The two Resolve outcomes are not interchangeable with Authorize Repeat: “it did not happen” and “it happened and I want it again” are opposite findings and only one of them authorises a second payment. All four are Critical — a clear on the standard lane would let a proposal unblock and then re-propose the payment at a lower bar than the withdrawal itself. Authorize Repeat additionally verifies its citation: the block index it names must match the one recorded. Each canister exposes the journal read-only as
list_ambiguous_transfers.
OHSHII governance (guardian):
| method_name | What it does |
|---|---|
admin_set_guardian_principal | Set or remove the guardian principal (also available as category SetGuardian). |
OHSHII governance (voter verification):
ohshii_governance ships with four independently-togglable verification methods, and they are not interchangeable: each carries a strength, and some actions require the stronger one.
| Provider | Strength | What it proves | Status |
|---|---|---|---|
| Standard | one Google account, one wallet | live | |
| Telegram | Strong | control of a phone-verified Telegram account | live — opened 2026-09-05 |
| DecideID | Strong | DecideAI proof of personhood | legacy — closed to new proofs since 2026-09-05; existing verifications keep counting |
| World ID | Strong | World ID proof of personhood | legacy — closed to new proofs since 2026-09-05; existing verifications keep counting |
Strong is a superset of Standard. A wallet holding any Strong method satisfies every Standard gate. A wallet holding only Google does not reach a Strong gate, and the refusal it gets says which method is missing rather than “not verified” — telling a verified user they are unverified would be wrong.
Each provider carries two independent flags. enabled answers “does this provider make the gate mandatory?”; accepts_new_proofs answers “will this provider still register a new proof?”. They are orthogonal, which is what makes enabled = false, accepts_new_proofs = true — verify for the benefits, not required to vote — a state the DAO can choose, and what makes retiring a provider possible without revoking anyone.
Verification, once granted, is never revoked by configuration. Neither flipping enabled nor closing a provider to new proofs removes anyone from a verified set. Only an explicit governance escape hatch does.
| method_name | What it does | Threshold |
|---|---|---|
set_world_id_config | Update the World ID gate (rp_id, action, required_identifier, enabled, rp_key_name). | Critical (→ CriticalGovernanceOperation) |
set_world_id_request_preset | Toggle what the World ID widget requests at proof time (V4Capable vs V3Legacy, stored in WorldIdConfig.request_preset, default V4Capable). Controls only the widget request shape — never what the canister accepts. | Standard |
set_world_id_daily_cap | Cap on register_world_id_verification calls per rolling 24h. 0 pauses. | Standard |
set_decideid_config | Update the DecideID gate (issuer canister, origin, credential_type, derivation_origin, enabled, presentation_max_age_seconds). Cannot enable while issuer is anonymous (Not Configured). | Critical (→ CriticalGovernanceOperation) |
set_decideid_mode | Switch the DecideID active mode (Vc on-canister VC popup vs Oidc OAuth redirect). | Critical (→ CriticalGovernanceOperation) |
set_decideid_oidc_config | Persist the public OIDC config (client_id, endpoint URLs, redirect URI, aud/iss). The client_secret has its own separate setter, deliberately outside the proposal lane because a proposal payload is public and permanent on-chain. | Critical (→ CriticalGovernanceOperation) |
set_decideid_daily_cap | Cap on register_decideid_verification calls per rolling 24h. 0 pauses. | Standard |
admin_clear_world_id_verified | Remove a principal’s World ID flag (escape hatch). | Critical (→ CriticalGovernanceOperation) |
admin_clear_world_id_nullifier | Free a spent World ID nullifier (incident response only — weakens sybil resistance if misused). | Standard |
admin_clear_decideid_verified | Remove a principal’s DecideID flag (escape hatch). | Critical (→ CriticalGovernanceOperation) |
admin_clear_decideid_presentation | Free a recorded JWT-VP digest (incident response only — weakens replay protection if misused). | Standard |
admin_reset_decideid_rate_limit | Drop the per-caller 5/hour register-attempt counter. opt principal clears one caller; null clears all. | Standard |
set_google_config | Update the Google provider (trusted signer, accepted frontend origins, attribute key, bundle max age, enabled, accepts_new_proofs). | Critical (→ CriticalGovernanceOperation) |
set_telegram_config | Update the Telegram provider (bot id, bot username, Mini App short name, session max age, onboarding value lifetime, enabled, accepts_new_proofs). | Critical (→ CriticalGovernanceOperation) |
admin_clear_google_verified | Remove a principal’s Google flag (escape hatch). Keeps the account binding, so revoking a wallet does not return its Google account to circulation. | Critical (→ CriticalGovernanceOperation) |
admin_clear_telegram_verified | Remove a principal’s Telegram flag (escape hatch). Keeps the account fingerprint, for the same reason. | Critical (→ CriticalGovernanceOperation) |
admin_clear_google_email_hash | Release a Google account’s binding so it can be bound again (lost-wallet recovery). Refuses while the wallet is still Google-verified — revoke first, then release. | Standard |
admin_clear_telegram_nullifier | Release a Telegram account’s fingerprint so it can be bound again (lost-wallet recovery). Refuses while the wallet is still Telegram-verified — revoke first, then release. | Standard |
set_world_id_accepts_new_proofs | Open or close World ID to new proofs, without touching whether it makes the gate mandatory. Closing it revokes nobody. | Critical when reopening (accepts_new_proofs = true → CriticalGovernanceOperation); Standard when closing |
set_decideid_accepts_new_proofs | Same, for DecideID. | Critical when reopening; Standard when closing |
The two accepts_new_proofs setters are the one value-conditional pair on this list: their
tier is decided by the argument, not by the method name. Reopening a provider to new proofs
(accepts_new_proofs = true) is redirected to CriticalGovernanceOperation — higher thresholds,
a fixed 7-day window, guardian-vetoable — because every open provider is another way for one
person to enter the electorate again on a second wallet, and the per-provider uniqueness checks
cannot see across providers. Closing a provider (false) narrows that surface, revokes nobody,
and stays on the Standard route on purpose: a defensive lever must never be harder to pull than
the one that undoes it. Every provider configuration setter and every flag-clearing
escape hatch is Critical in both directions, and therefore guardian-vetoable.
The two account-binding releases stay Standard, and instead carry a precondition: they refuse while the wallet is still verified on that provider. A tier could not have closed that gap — an approved critical proposal would free the slot just the same — and the legitimate use is lost-wallet recovery, which by construction follows a revocation. The enforced order is therefore revoke (Critical), then release (Standard).
No provider on this list requires a DAO-held secret. Telegram is a signature check against a published key; Google is an Internet-Identity-signed bundle. Only the legacy DecideID OIDC path has a client secret, which is why that one field keeps its own setter and stays out of proposal bodies. The one secret the new providers use is a canister-generated salt that no setter writes and no query reads, so their configuration setters can be full, public proposal bodies.
Governed v3/v4 request preset. set_world_id_request_preset flips a stored
WorldIdConfig.request_preset (default V4Capable) that controls only what the
World ID widget asks the user to produce — v4-capable proofs (World ID 4.0) or
the legacy v3 request shape. It never changes what the canister accepts:
the v4 /verify endpoint accepts legacy v3 proofs natively (the canister sends the
matching v3 wire shape), so v3 World ID holders keep verifying regardless of the
preset. The widget-side allow_legacy_proofs IDKit flag — a client widget flag,
not a verify-request-body field — stays on, which is what keeps v3 holders working.
The preset is a governance-controllable config: the setter is gated by
is_admin_or_self_guard, so it is flipped by a NORMAL-lane
ExecuteAdminMethod proposal. During the transition phase it is additionally
reachable by the deploying principals, which are removed at the DAO handover;
after that a governance proposal is the only route. V4Capable is the steady state;
V3Legacy is a deliberate emergency narrowing-rollback only — it would otherwise
lock out new World App users who can produce only v4 proofs. The preset never
gates the canister’s accept path, so toggling it can never turn away an already-valid
verification.
The four methods are independent: enabling one does not affect the others; disabling one does not clear existing verifications via that method (verifications persist across method-disable, by design).
Universal Participation Gate — combined behavior
The same gate applies to BOTH vote()
AND create_proposal() on ONS (with a different action verb in the
error message). In outline:
// One place composes the four providers' flags. Nothing else inlines an OR.strong_gate_required() = world_id.enabled || decideid.enabled || telegram.enabledany_gate_required() = strong_gate_required() || google.enabled
required_strength(is_critical) = if is_critical && strong_gate_required() { Some(Strong) } else if any_gate_required() { Some(Standard) } else { None } // nothing is mandatory
// The gate itself, at every site:if verification_gate_rejects(required_strength(is_critical), strength_of(caller)) { return Err(<a refusal naming what is missing>);}Whether the action is critical is decided by the same predicate the voting thresholds use, read once and used for both, so the tier a proposal is gated at and the window it gets can never disagree.
create_proposal() refuses with a proposer-shaped message instead of a
voter-shaped one — same gate, different action verb so the frontend can
disambiguate. Both messages match the regex verification required that the
frontend uses to mount the in-form <VerificationCTA> banner, and a
Standard-only caller refused on a critical action gets a distinct message
naming the Strong method rather than the generic one.
For SONS DAOs the equivalent gate covers vote(), create_proposal(),
follow(), and set_accepts_followers(true), governed by the per-DAO
requires_verification flag (a CriticalGovernanceOperation
proposal can flip it). Verification storage is shared with ONS —
one verification unlocks every OhShii DAO. The SONS-side check uses a
bounded-wait (10s) call to ONS behind a circuit breaker; the per-DAO flag is
exposed via requires_verification : () -> (bool) query.
The per-DAO verification cache, and what its tiers mean
Section titled “The per-DAO verification cache, and what its tiers mean”Each campaign DAO keeps a small permanent record of who it has seen verified, so that a wallet does not pay a cross-canister call on every action. That record now stores which tier was confirmed, not merely “verified”:
| Cached tier | Meaning |
|---|---|
| Strong (confirmed) | ONS answered Strong for this wallet. Every gate on this DAO is open to it, with no further call. |
| Standard (confirmed) | ONS answered Standard. Standard gates are open; a critical action falls through to ONS, because only ONS can see a Strong method added since. |
| Legacy | A record written before tiers existed, which says only “verified”. Read as Strong-unconfirmed, i.e. treated as Standard until ONS confirms it once. |
A legacy record is never read as Strong on its own, because a bare “verified” cannot tell a Google-only wallet from a strongly verified one; reading it as Standard means the worst case is one extra confirming call, never a promotion.
Reading a legacy record as Strong-unconfirmed costs a
legacy holder one cross-canister call per DAO, once, on their first
Strong-gated action — or one call to that DAO’s refresh_my_verification, which
settles it deliberately. After that the record carries the real tier and the call
is never paid again. A record never moves down: confirming a legacy row can only
date it or raise it.
One consequence is worth stating plainly rather than leaving to be discovered:
while a legacy record is still unconfirmed, a delegated ballot cast on that
holder’s behalf on a critical proposal is skipped, because the delegation
cascade is synchronous and cannot make the confirming call. That is the fail-safe
direction, and refresh_my_verification is the one-call fix.
The record is also pre-populated, so a wallet’s first action here does not depend on ONS answering. The platform pushes the verified set into a campaign DAO from the moment its canister exists: once at creation, then a few pages per housekeeping pass across the whole launch window, and a last bounded top-up just before the DAO becomes autonomous. Only the verification class crosses — never which provider a person used, which is more identifying than the class and which no gate on a campaign DAO reads.
Two consequences worth knowing:
- The seed stops at the seal. Once a campaign finalizes its governance, the platform can no longer write that record — a write into a sovereign DAO’s voting gate would be a lever its own members could not revoke. A DAO sealed before its seed finished keeps what it got; everyone else is still served by the per-wallet call on first action, exactly as before.
- A partial seed is readable, not silent.
get_verified_seed_statusreports how many rows landed and whether the last pass ran out of room, so an incomplete record is a fact anyone can check rather than something discovered during an outage. - An imported DAO is the narrow case, and it is handled separately. It is created and becomes autonomous in the same operation, so there is no launch window for the paced passes to work in: its record is filled in one go, during creation, with a larger allowance than the last-top-up a launching campaign gets. That pass is bounded by a wall-clock budget rather than only by a page count, because the person creating the DAO is waiting on the call — and a creation that outran the wait would leave a DAO that exists while its creator is told it failed.
The DAO can always vote its own verification-off rescue
Section titled “The DAO can always vote its own verification-off rescue”A campaign DAO can vote to turn its verification requirement off. That proposal is critical, so it is gated at Strong — which would be a trap if the ecosystem canister were unreachable at exactly the moment the DAO needs the rescue, since confirming an unconfirmed record needs the very call that is failing.
So the rescue proposal, and only the rescue proposal, admits a cached legacy or confirmed-Strong record when the confirming call fails. The requirement stays Strong; what the outage changes is whether an unconfirmed record may stand in while nobody can be asked. A record ONS has already answered Standard is still refused — an outage does not make that answer stale. Nothing is written to the cache on this path, so the real confirmation still happens the moment the ecosystem canister answers again, and the exemption shrinks to nothing as the electorate confirms.
The predicate that answers “is this caller verified?” reads the verified-user sets directly — one per provider — never the per-method enabled flags. This makes the gate satisfy two invariants at the same time:
- Union satisfaction — a verification via any method satisfies the standard gate, regardless of which method’s flag is currently
true. A user who verified via World ID two months ago still passes a gate that today only has DecideID enabled. - Persistence on disable — turning a method off never clears existing verifications, and neither does closing it to new proofs. Only the explicit governance escape hatches remove an individual flag.
Strength is derived, never stored. A wallet’s strength is computed from which per-provider sets it is in. That is why the principals verified before strength levels existed are Strong by construction: every one of them was verified through World ID or DecideID.
What happens when EVERY method is disabled: nothing is mandatory, and the gate does not fire. ONS voting becomes open to any authenticated wallet — no verification required, no per-method check. Anonymous principals are still rejected by the reject_anonymous guard on vote(). All other voting rules (proposal status, vote rate limit, voting power) still apply.
The gate matrix, for a caller holding nothing, Google only, or any Strong method:
| Action | none | Google only (Standard) | any Strong method |
|---|---|---|---|
| ONS / SONS standard proposal — vote or create | refused while the gate is active | admitted | admitted |
| ONS / SONS critical proposal — vote or create | refused | refused, and the refusal names the Strong method | admitted |
Delegation (follow, set_accepts_followers) and hotkey registration | refused while the gate is active | admitted | admitted |
| A delegated ballot landing on a critical proposal | — | skipped for that follower; other Strong followers of the same delegate are still applied | applied |
| Token / LGE / index-canister / imported-DAO creation | refused | admitted | admitted |
| Sponsor attribution — does a referral count | no | yes | yes |
| LGE purchase tier above Guest, guest-fee waiver, exemption from the Guest cap | no | no | yes |
| Veto-override ballot | refused | refused | admitted only with a fresh attestation for that round |
Two rows deserve their reason stated rather than left to be inferred.
- The LGE tier ladder needs Strong, and Google is deliberately not enough. LGE buyers become the electorate of the campaign DAO that must pass critical quorums after the launch completes; an electorate that could not vote on a critical proposal would stall every campaign DAO it created.
- Creation flows need only Standard. Creating a token, an LGE or an index canister is a paid action where the firewall exists as cycle-drain defence, not as electorate integrity.
Guest participation requires no verification of any kind. An unverified wallet keeps buying into an LGE as a Guest — the 1,000,000-token cap, the guest fee, and the 50-Guest cap per campaign — exactly as before. Google only adds capabilities; it never becomes a precondition for something an unverified wallet can already do.
If only Google is enabled, a critical proposal needs only Google. No Strong provider is available to demand, and demanding one anyway would make the critical tier unreachable — the same reasoning that makes the all-disabled row open.
Note for voters: a DAO that wants to halt voting WITHOUT lifting the Sybil filter must leave at least one method enabled = true and close the providers to new proofs instead. Setting every method to enabled = false removes the Sybil gate entirely; it does not pause voting.
Restore/delete snapshot, add/remove controller targeting the governance canister, set_voting_parameters, and set_default_excluded_countries are only allowed under CriticalGovernanceOperation. Stopping the governance canister or the OHSHII ledger is never allowed.
Voting parameters and fee methods on ohshii_governance (ONS):
| Method | Category | Threshold | What it changes |
|---|---|---|---|
set_voting_parameters | CriticalGovernanceOperation (target = ohshii_governance) | Critical (see Two-axis voting model) | Quorum (standard/critical), approval %, min votes, voting durations, max VP per user, Guest-fee and purchase-limit params. All optional fields in VotingParamsUpdate; only provided fields are updated. |
set_proposal_creation_fee | ExecuteAdminMethod (target = ohshii_governance) | Standard | Fee in OHSHII tokens to create a proposal. Backend enforces ±50% change limit per execution. |
set_rejection_cost | ExecuteAdminMethod (target = ohshii_governance) | Standard | Tokens deducted from refund when a proposal is rejected/expired. Must be ≤ proposal_creation_fee; ±50% change limit. |
Current values are exposed via get_voting_parameters() (includes fees) and get_proposal_creation_fee() / get_rejection_cost().
Proposal deposit — per-proposal escrow + outcome policy. The creation fee is pulled DIRECTLY into a dedicated escrow subaccount of the governance canister, derived injectively from the proposal id (SHA-256("ohshii-proposal-fee:v1:" ‖ proposal_id_be8)) and snapshotted as fee_paid (+ fee_subaccount, fee_policy_v2 = true). The escrow is a single-proposal watertight compartment: its balance IS that proposal’s remaining obligation, no settlement can ever draw on another proposal’s deposit or on the treasury float. Session-paid proposals (upgrade/asset flows whose fee was pulled at prepare_* time, before a proposal id exists) record the escrow subaccount of the upload session that paid, so the same compartment rule and outcome policy apply. One deposit per proposal, batches included: the first paid upload staged for a batch is its deposit and every other staged step attaches to it and pays nothing; a batch with nothing staged pays one fee at creation into the proposal’s escrow. A batch that carries two paid uploads is refused (BATCH_MULTIPLE_DEPOSITS) before any fee is taken, and a follower whose anchor deposit is missing, was staged by another uploader or has expired is refused (FOLLOWER_WITHOUT_ITS_DEPOSIT). The batch’s single deposit follows the outcome table below exactly like any other deposit: a full refund when the batch is adopted, a partial refund when it is rejected. One deposit admits at most nine followers over its whole life (a batch has at most ten steps): removing a follower, or letting it expire, does not free its place. A follower’s asset lease ends no later than its deposit. Order for command-line clients: on SONS a single Wasm upload session lives 180 seconds from its prepare, and a follower is refused once its deposit has expired, so prepare the deposit and every follower first and upload the chunks afterwards; otherwise the batch must be staged again. The frontend stages Wasm steps inside the proposal and anchors only on 30-minute asset leases, so it is not affected. The resolution timer settles every deposit by terminal outcome (amounts net of the ledger transfer fee, tracking whatever fee / rejection cost the DAO had voted at creation):
| Terminal outcome | Deposit settlement |
|---|---|
| Executed · Approved (a signal-only Motion — its terminal success) | Full refund to the proposer (fee − ledger fee), paid from that proposal’s own escrow subaccount. A proposal the DAO adopts costs its proposer nothing: the cost of governance is borne by the proposals the DAO refused. |
ExecutionFailed (incl. a stuck-Executing proposal reaped after the TTL) | Full refund (fee_paid − ledger_fee) from the escrow — a trapped execution is not the proposer’s fault |
Rejected · Expired (an Open proposal that missed quorum) | Partial refund (fee_paid − rejection_cost − ledger_fee) from the escrow; the withheld rejection_cost residue settles to the treasury (same destinations as above) |
Abandoned paid upload (a PendingUpload proposal whose upload never completes and expires, or an expired paid upload session) | Forfeit — no refund; the deposit is swept from its escrow to the treasury by the timer and a FeeForfeited audit event is emitted |
An Approved executable proposal settles nothing yet — Approved is transient there (execution pending); settling early would make a later ExecutionFailed refund unpayable, so the deposit stays in escrow until the terminal Executed/ExecutionFailed. Vetoed / VetoOverrideExpired are non-terminal: the timer finalizes a vetoed proposal and the deposit follows the final outcome. Legacy proposals (created before the escrow upgrade, fee_policy_v2 absent) keep the pre-escrow policy unchanged (Approved/Executed/ExecutionFailed → full refund from the main account).
Custody invariant. Every refund is paid from that proposal’s own escrow subaccount; no settlement leg ever draws on a governance canister’s main account. settle_refund_item fails CLOSED (REFUND_WITHOUT_ESCROW) on a fee_policy_v2 proposal owed a refund that records no escrow — the refusal names the proposal, leaves the deposit where it is, and marks a platform defect rather than a user error — and no fee collection in either canister pulls into the main account. This matters most on SONS, where the main account IS the DAO treasury: without it, a proposer’s refund would be drawn from collective funds.
A deposit that never reached a proposal. If the canister traps between the ledger accepting the fee and the proposal or session that owns that escrow being stored, nothing points at the money. A marker written before the ledger call survives that trap, and there are three ways out of it:
| route | ONS | SONS |
|---|---|---|
| the timer, once the marker has outlived every legitimate window | automatic | automatic |
the manual lever guardian_sweep_orphan_fee_escrow(key) | the guardian (and, during the transition phase, the deploying principals) | the campaign’s guardian (and, during the transition phase, the deploying principals) |
| an approved proposal | ExecuteAdminMethod on guardian_sweep_orphan_fee_escrow | ExecuteAdminMethod with AdminMethod = SweepOrphanFeeEscrow and orphan_fee_escrow_key |
The proposal route exists so the recovery stays available whether or not a guardian is set and after the transition-phase principals are removed.
No route can choose a recipient: the destination is the payer recorded when the fee was taken, and no argument or proposal field names one. All three run the same code, are idempotent, and refuse if any stored row still claims that escrow — an approved recovery that arrives after the deposit is already home executes and moves nothing.
The refund reuses the timestamp pinned when the fee was taken, so a retry is a ledger Duplicate rather than a second payment. Past the ledger’s 24-hour transaction window that pinned timestamp would reject every attempt forever — which is every proposal-driven recovery, since a vote cannot close in less — so a refused attempt re-reads the escrow, and if it still holds the whole deposit (proof that nothing left it) the recovery re-pins to the present and retries once. A ledger it cannot read, or an escrow short by any amount, keeps the original pin and fails.
get_orphan_fee_escrow_status() is the public read: pending counts the markers that exist right now (one is written before the ledger call of every fee pull, so a non-zero value is normally an in-flight charge), overdue counts those past ttl_ns, and only that second number means a deposit is stranded.
Every settlement leg is idempotent (pinned created_at_time per leg — refund_created_at_time / treasury_created_at_time — plus a pinned ledger fee on the amount-driven legs — refund_fee_pinned / treasury_fee_pinned — so a retry is an exact ledger Duplicate, never a second payment, even across a ledger fee change; a stale pin fails closed and self-recovers on escrow-custody legs after verifying the deposit is intact) and evented (FeeCollected, RefundProcessed/RefundFailed, FeeForwardedToTreasury/FeeForwardFailed, RejectionCostForwarded/…Failed, FeeForfeited). Refunds are paid automatically by the timer; two recovery levers exist:
claim_proposal_refund(proposal_id)— a PUBLIC, proposer-only fallback: if the automatic settlement fails repeatedly (ledger outage, trapped tick) the proposer drains their own owed refund out-of-band, from the same escrow, with the same pinned timestamp (a re-claim of an already-committed refund dedups at the ledger) and the same one-shotrefund_processedflag. Anonymous and non-proposer callers are rejected by cheap synchronous checks; a per-proposer 60 s cooldown and the global refund guard bound the honest path. The deposit lifecycle is queryable viaget_proposal_fee_status(proposal_id).- The guardian can drain all owed settlements via
force_process_refunds(see What the Guardian CAN do).
Full flow + diagram: WORKFLOWS.md §5.5.
Where the withheld rejection_cost goes. On a rejected or expired proposal the proposer is refunded fee_paid − rejection_cost − ledger_fee; the rejection_cost portion is withheld. The destination differs by DAO:
- ONS (
ohshii_governance) forwards the withheldrejection_costto the OhShii backend treasury. - SONS (
sons_governance) has a single governance canister and no separate treasury, so the withheldrejection_costis retained in the governance canister — settled from the proposal’s escrow into the main account (the SONS treasury). It is not refunded and not forwarded anywhere else. (Thefee_recipientconfig field exists but is not consumed by any SONS transfer path.)
SONS (LGE Governance): all proposal categories and AdminMethods
Section titled “SONS (LGE Governance): all proposal categories and AdminMethods”Each LGE has one sons_governance canister. Proposals are either a category (e.g. UpgradeCanister, ExecuteAdminMethod) or a ConfigChange. For ExecuteAdminMethod, the target is an AdminMethod variant and parameters are in AdminMethodParams.
The 13 SONS categories each get a subsection below. For how to read “Threshold”, see Reading the per-category Threshold field. Unlike ONS, no SONS category is retired — all 13 are live. CustomCall and HttpsOutcall (both always Critical, guardian-vetoable) mirror the ONS arbitrary-dispatch lanes for a single campaign DAO. The newest, SetDaoCanisterRegistry (also always Critical), mirrors the ONS lane of the same name.
Motion (SONS)
Section titled “Motion (SONS)”- Scope: community signal / discussion for one LGE campaign.
- Target:
None. - Threshold: Standard.
- What it does: records the vote; no execution.
UpdateCampaign (SONS)
Section titled “UpdateCampaign (SONS)”- Scope: a motion about changing campaign info (description, website, socials).
- Target:
None. - Threshold: Standard.
- What it does: signal-only — no execution. The actual on-chain write is the
UpdateCampaignInfoaction (under ExecuteAdminMethod).
ExecuteAdminMethod (SONS)
Section titled “ExecuteAdminMethod (SONS)”- Scope: run one of the 30
AdminMethodactions (grouped by area below). - Target:
AdminMethod(AdminMethodParams)— the variant selects the action; the flatAdminMethodParamscarries its fields. - Threshold: Standard, except the methods marked ★ (and self-targeting controller ops / snapshot restore-delete) which escalate to Critical.
- What it does: dispatches to the executor for the chosen
AdminMethod(30-arm match; all implemented).
UpgradeCanister (SONS)
Section titled “UpgradeCanister (SONS)”- Scope: upgrade a child canister this DAO controls (ledger, index, …). Must not target the governance canister — self-upgrades go through
UpgradeGovernanceCanister. - Target:
CanisterUpgrade(CanisterUpgradeData). - Threshold: Critical. Guardian-vetoable. (Every canister upgrade is a code change to a trusted component; the category is kept distinct from
UpgradeGovernanceCanisteronly for the self-target validation, not for a threshold difference.) - What it does: validates the WASM hash (re-checked post-install) then
install_code.
ConfigChange (SONS)
Section titled “ConfigChange (SONS)”- Scope: the non-critical config lane — fee fields (
proposal_creation_feeand/orrejection_cost, ±50% per proposal) and/or the campaign’s excluded-countries list (new_excluded_countries, regional participation eligibility); at least one of the three required. - Target:
ConfigUpdate(ConfigUpdateData)carrying fee fields and/ornew_excluded_countries. - Threshold: Standard.
- What it does: applies the fee deltas; for
new_excluded_countries, pushes the replacement list to the launcher backend (which accepts only this campaign’s own governance canister), where the platform-baseline entries are unioned in (a replacement can never drop them) and the canonical record is version-bumped, then refreshes the local mirror from the canonical read-back. The change is forward-only — earlier participations are never retroactively affected. The proposal payload renders the full list inline (codes + names) plus a content hash.fee_recipientis not proposal-configurable (would break refund flows). Voting / runtime / delegation params are NOT allowed here — they go through CriticalGovernanceOperation. - Applies only its own fields: when the proposal executes, the configuration is read again after the ledger-fee query and only the fields the proposal names are changed, so a configuration change that lands while the proposal waits on the ledger (for example the admin and guardian changes made at finalization) is kept, not overwritten.
UpgradeGovernanceCanister (SONS)
Section titled “UpgradeGovernanceCanister (SONS)”- Scope: self-upgrade this
sons_governanceWASM (target MUST be itself). - Target:
CanisterUpgrade(CanisterUpgradeData). - Threshold: Critical. Guardian-vetoable.
- Refused when a critical setting cannot be read: this upgrade, like any other upgrade of a campaign governance canister, fails if the new code cannot read the stored configuration, campaign data or pool information — a stored value that does not decode, or a storage header that is not recognised. The previous code keeps running with the stored bytes untouched, and the error names each unreadable value. No default is ever written in their place; a fixed module is needed to upgrade. While one of them cannot be read, every write to it is refused.
- What it does: self-upgrade through the
post_upgradehook. When the proposal executes, the governance canister first pauses: until the upgrade is applied it refuses every operation that would contact another canister — votes, proposal execution, verification, refunds, the guardian’s resolution drain — with an error that starts withSELF_UPGRADE_QUIET, and it waits for the calls it had already started to finish. Queries and purely local operations keep working, and a refused operation has done nothing, so it can simply be retried after the upgrade. The install is then sent without waiting for its reply, and the upgraded canister marks the proposalExecuted. With no call in progress the pause lasts a few seconds; it ends as soon as the last call already in progress returns. If those calls have not finished after 10 minutes, nothing is installed and the proposal is markedExecutionFailed(SELF_UPGRADE_QUIET_TIMEOUT). If the install is refused, the previous code keeps running, stays paused, and marks the proposalExecutionFailed(SELF_INSTALL_REFUSED) after 30 minutes. The calls the pause waits for include every outbound call the canister makes, among them the signing calls of identity verification on ONS and the treasury-withdraw transfer on SONS; a call whose reply has already arrived no longer holds the pause when its continuation fails, while a call that is really still open keeps holding it.
CriticalGovernanceOperation (SONS)
Section titled “CriticalGovernanceOperation (SONS)”- Scope: the high-risk lane — voting / runtime / delegation config changes, plus the
AdminMethods that must be critical (AddController / RemoveController targeting the governance canister, RestoreSnapshot, DeleteSnapshot). Carries the Democratic Emergency Exemption (below). - Target:
AdminMethod(..)orConfigUpdate(..)carrying the voting / runtime / delegation fields (incl.requires_verification). - Threshold: Critical. Guardian-vetoable.
- What it does: runs the critical
AdminMethod, or applies the critical config update.
UpgradeAssetCanister (SONS)
Section titled “UpgradeAssetCanister (SONS)”- Scope: commit a frontend (asset canister) batch for the campaign frontend.
- Target:
AssetUpgrade(AssetUpgradeData). - Threshold: Critical. Guardian-vetoable. (A frontend upgrade is a code/content change to a trusted surface, so it clears the same critical bar as every other upgrade.)
- What it does:
validate_commit_proposed_batch(create + execute) thencommit_proposed_batch. At creation the asset-lease check runs before the validation call, and a target that does not answer within 15 seconds gets the proposal refused with nothing created and no fee taken. - Automatic batch cleanup: an asset canister allows at most ONE proposed batch, and proposed batches never expire upstream — so a failed proposal would otherwise block every future campaign-frontend upgrade. Exactly as on ONS (see the note under UpgradeAssetCanister (ONS)), every non-executed terminal transition (rejected, expired, veto-upheld, execution-failed) schedules an automatic
delete_batchon the target; the governance timer retries it with exponential backoff and, after repeated failures, emits a visibleasset_batch_cleanup_gave_upevent calling for manual cleanup (guardian fallback, also reachable by the transition-phase deploying principals:guardian_delete_asset_batch). Batches staged on the asset canister but never attached to any proposal are discovered and removed by an orphan probe (triggered by the abandoned-upload-session sweep and by the nextprepare_asset_upload). A batch referenced by a still-live proposal is never deleted, and creating a proposal against a batch already scheduled for cleanup is refused (re-stage a fresh batch). Pending work is public via theget_pending_asset_cleanupsquery. The same cleanup applies to everyAssetCommitstep of anUpgradeDappBatchwhose step did not commit. Because SONS controls its campaign frontends (but holds no asset permission by default), the drain grants itselfCommiton the target the first time it needs it (controller self-grant).
UpgradeCanisterBatch (SONS)
Section titled “UpgradeCanisterBatch (SONS)”- Scope: upgrade this campaign token’s ledger suite (index + ledger + archives) in ONE proposal, in the canonical safe order Index → Ledger → Archive(s). Every step is validated against the DAO’s managed canisters: the Ledger step (mandatory) must be the campaign ledger, the Index step (optional) must be the configured index canister, and archive steps must appear in the ledger’s own
archives()list — campaign ledgers are launcher-created stock ICRC ledgers, so a failing archive probe is treated as a real anomaly and rejects the proposal (stricter than the ONS verify-when-verifiable stance). Must not target the governance canister. - Target:
CanisterUpgradeBatch(CanisterUpgradeBatchData)— an ordered list of 1–5CanisterUpgradeSteps, same shape as ONS (target + role + WASM hash/size + optional upgrade arg + upload session). - Threshold: Critical. Guardian-vetoable.
- What it does: same execution model as the ONS block — sequential, stop-on-first-error, all-or-nothing controller pre-flight, per-step idempotent skip, TOCTOU hash revalidation, post-install verify, progress persisted in
batch_step_results, and fix-forward recovery via a fresh batch (no automatic rollback). The single-indexUpgradeIndexCanisteraction remains available for index-only maintenance. See WORKFLOWS.md §5.3.
UpgradeDappBatch (SONS)
Section titled “UpgradeDappBatch (SONS)”- Scope: upgrade an arbitrary ordered set of canisters — 1–10 steps — in ONE proposal, executed verbatim in proposer order (shape validated, not release semantics). Each step carries one target + one kind (
codeWASM install /assetfrontend-asset commit) + one category + its own git provenance (git_repo_url+git_commit_hash+build_instructions, all required non-empty). The SONS delta is the Core definition: Core is the campaign’ssons_governancecanister itself ONLY — every other target is Custom (rendered with a warning). The governance canister itself may be upgraded only as the FINAL step. The ledger-suite batch (UpgradeCanisterBatch) remains the specialized suite path. - Target:
DappUpgradeBatch(DappUpgradeBatchData)— an ordered list ofDappUpgradeSteps (target + category + typed payload + per-step provenance). The SONS code payload omits the ONS-only chunk-store fields (the chunk store is always the target itself). - Threshold: Critical. Guardian-vetoable.
- What it does: identical to the ONS block — validates the shape at creation (step count 1–10, distinct targets, self-step-last, per-payload shape, Core = self-only, per-step provenance, per-asset-step
validate_commit_proposed_batch); stages one upload session per code step (30-minute window; asset steps consume none); executes with an all-or-nothing controller pre-flight over EVERY target — asset targets included, then sequential stop-on-first-error: code steps re-validate + install + verify the module hash, asset steps re-validate +commit_proposed_batch, per-step progress inbatch_step_results. Fix-forward recovery (no rollback): committed code steps auto-skip by live module hash; asset steps never auto-skip. See WORKFLOWS.md §5.4.
CustomCall (SONS)
Section titled “CustomCall (SONS)”- Scope: the ONS
CustomCalllane, scoped to one LGE DAO — dispatch ONE arbitrary Candid call to a named method on a canister thissons_governancecontrols, with the argument supplied as human-readable Candid text that the governance canister encodes on-chain, never the frontend. - Target: target canister id + method name +
candid_text_args. - Threshold: Always Critical. Guardian-vetoable.
- On-chain encoding + re-verification. Identical model to CustomCall (ONS): at creation the canister fetches the target’s
candid:servicemetadata, parses it, and encodes the text against the realmethod_namesignature; it pins the target module_hash, the payload sha256, and a normalized render. At execution it re-verifies the pinned module_hash (fail-closed if the target’s code changed since the vote) and that governance is still a controller, then dispatches with a bounded wait. - Reserved targets.
aaaaa-aa, the governance canister itself, and the ICP / cycles ledgers are structurally reserved — and a SONS DAO additionally reserves its own campaign ledger and index canister (is_reserved_custom_target), so a CustomCall can never bypass the typed token/treasury paths for its own money rails. - Preview + rate limit.
custom_call_preview(...)returns the render + sha256 + module_hash without creating a proposal; rate-limited by a cycle-reserve floor + a global cap (SONS 60/hour). - Outcome: the same “Executed unless provably no-op” policy — see the CustomCall workflow.
HttpsOutcall (SONS)
Section titled “HttpsOutcall (SONS)”- Scope: the ONS
HttpsOutcalllane for one campaign DAO — a typed, DAO-composed canister HTTPS outcall (not a bridge, not an oracle) to an external server, voted before it is sent. - Target:
url,method(Get/Post/Head),headers, optionalbody, a mandatorymax_response_bytes(1..=2 MiB), amax_cyclescap,is_replicated, and a fixed wasm-exported transform chosen by id (StripHeaders/StripHeadersAndBody) — a proposal can never supply its own transform. - Threshold: Always Critical. Guardian-vetoable.
- Cost gate + outcome: the executor computes the real cost and aborts if cost >
max_cycles; a 2xx →Executed, a non-2xx → stillExecutedwith the status in the event, a transport-level failure →ExecutionFailed. See the HttpsOutcall workflow.
SetDaoCanisterRegistry (SONS)
Section titled “SetDaoCanisterRegistry (SONS)”- Scope: the ONS
SetDaoCanisterRegistrylane for one campaign DAO — the same record, the same method name, the same whole-list replacement semantics and the same validation. The frontend panel uses one IDL and one code path for both DAOs, and the two lanes are identical. - No platform seed. Unlike ONS, a SONS registry starts empty: the platform knows nothing about a campaign DAO’s canisters beyond the intrinsic trio the panel already reads certified.
- Threshold: Always Critical. Guardian-vetoable.
- Two “managed canister” surfaces coexist on purpose.
get_managed_canisters(+ManagedCanisterInfo) is dynamic discovery and feeds a cycle-balance membership check;get_dao_canister_registry(+ManagedCanister) is the DAO-curated, display-only attestation. They are independent: the registry never feeds the cycle-balance check. unsupportedis a designed answer. Each campaign DAO is upgraded by its own proposals, so a DAO running an older module answersIC0302and the frontend renders “does not expose a canister registry yet”.
SONS AdminMethod (under ExecuteAdminMethod) — by area
Section titled “SONS AdminMethod (under ExecuteAdminMethod) — by area”All 30 AdminMethod actions run through the ExecuteAdminMethod category
(target AdminMethod(AdminMethodParams)), except where the Tier column says
Critical — those are forced through CriticalGovernanceOperation. ★ = critical
via is_critical_admin_method.
LP, pool & treasury
| AdminMethod | What it does | Tier |
|---|---|---|
| TransferLiquidityPosition ★ | Transfer an ICPSwap liquidity position to a new owner (transferPosition). Targets the legacy campaign position by default, or a specific registry position via the optional pool_canister_id + pool_position_id pair (ownership re-verified at execution against the registry ∪ the pool’s live position list). On the legacy path a successful transfer clears the stored position_id. | Critical |
| UpdatePositionId | Update the stored position_id (e.g. after migration). | Standard |
| CollectFees | Collect ICPSwap trading fees into the treasury (100%). Supports the same optional registry-position targeting. When the pool has run the claim (or its reply was lost) but the collection does not complete, the proposal ends Executed with a note saying so: the pool pays claimed fees to the treasury on its own. A claim the pool refused ends it as failed. | Standard |
| CollectFeesAndBuyback | Collect fees, then swap the ICP that actually arrived from the fee claim for the token the proposal names (slippage-protected). The swap starts only after the pool has paid the collected ICP, and spends exactly the amount the pool reports it paid: ICP that other operations move into or out of the DAO treasury meanwhile is never spent, and is never reported as a payout still pending. If the treasury balance does not show the reported payout, the reported amount is still spent and the shortfall is recorded for review. A failure after the fees were collected ends the proposal as Executed with a note (the fees did move into the treasury), and the note says the buyback did not complete and where the funds are. A failure before anything was collected ends it as failed. | Standard |
| CreatePoolAndAddLiquidity ★ | Create a custom ICPSwap pool and add full-range liquidity, funded from the SONS treasury (main account). Fee tier is restricted to 3000 (0.3%); deposits use exact approvals and the mint consumes the actually-credited amounts; the new position is recorded in the DAO’s pool-position registry (get_pool_positions), and the primary PoolInfo slot is only set when it was empty (the LGE pool reference is never repointed). | Critical |
| AddLiquidityToPool ★ | Add treasury liquidity to an EXISTING ICPSwap pool: the pool is verified against the ICPSwap factory (anti-lookalike check), amounts are given per token ledger and mapped to the pool’s token order, and a new full-range position is minted per proposal and recorded in the pool-position registry. The spendable-balance check nets out pending per-lock custody funding on the campaign token so lock-earmarked funds are never consumed. | Critical |
| SweepPoolUnusedBalance | Recover stranded deposits (a failed pool operation’s unused balance) from an ICPSwap pool back to the SONS treasury (main account). Idempotent; funds can only land on the DAO’s own main account. | Standard |
| RemoveLiquidity ★ | Remove liquidity from the position (tokens return to treasury; None = remove all). Targets the legacy campaign position by default, or a specific registry position via pool_canister_id + pool_position_id. | Critical |
| TreasurySwap | Swap treasury tokens via ICPSwap with slippage protection. When the pool records the input token as ICRC-1, the transfer into the pool carries the pool’s own input fee. If the pool refuses the swap, the input is brought back and its location is read before the proposal answers: the answer says nothing was swapped and where the input is. While any of it is still in the pool, a new TreasurySwap on that pool is refused, with nothing moved, until the input is recovered (SweepPoolUnusedBalance, or the guardian’s pool claim). | Standard |
| TreasuryWithdraw ★ | Withdraw ICP / ICRC-1 from the SONS treasury to a recipient — from the main account or an optional raw 32-byte source subaccount (and an optional recipient subaccount). Custody isolation: a source_subaccount that names any per-lock custody subaccount (append-only registry) is rejected at proposal creation AND at execution — lock-backing funds can never be withdrawn as treasury. An optional memo is validated against the target ledger’s advertised maximum memo length (icrc1:max_memo_length) before the transfer; disabled when the ledger does not advertise one. | Critical |
| CyclesTopup | Top up a canister with cycles using treasury ICP (via the CMC). Each top-up is recorded in the transfer journal, keyed on the target canister and the amount. An identical top-up is refused only while the previous identical one has an uncertain outcome (its transfer reply was lost, or its ICP reached the CMC without the cycles being credited); after a top-up whose cycles were credited, or one the CMC refunded, an identical one runs normally. When the ICP of a refused top-up has since been converted, Authorize Repeat Transfer citing its block lifts the refusal. | Standard |
| ResolveAmbiguousTransfer ★ | Record the outcome of a ledger reconciliation for one entry of the transfer journal. TreasuryWithdraw claims a journal record keyed on the withdrawal’s semantics (ledger, source, recipient, recipient subaccount, amount) before dispatching, and refuses while a prior identical attempt is unresolved or committed — so a withdrawal whose outcome was lost (a SysUnknown reply, an undecodable response) cannot be silently re-issued and paid twice. did_land = false retires the record and the retry becomes possible again; did_land = true converts it to committed (it is never retired, which would permit the double payment) and the block index is required. Evidence is mandatory. | Critical |
| AuthorizeRepeatTransfer ★ | Authorise a second, genuinely identical payment for a committed journal record. This is a new authorisation, not a correction, and its audit event says so; the cited block index must MATCH the one recorded, so the clearing act names what was reconciled rather than merely asserting it. | Critical |
| ForceClearAmbiguousTransfer ★ | Retire a journal record regardless of state — the recovery path for a stuck state machine (e.g. a committed record whose block index was lost). Emits a distinct audit event from both levers above, so which route was used is answerable from the event log alone. | Critical |
Vesting locks
| AdminMethod | What it does | Tier |
|---|---|---|
| ModifyLock ★ | Change a vesting lock’s status (valid transitions only). For isolated locks: a forced Completed queues a balance-driven sweep of the lock’s custody subaccount back to the treasury; Cancelled leaves the residual IN the subaccount, claimable by the lock’s creator via claim_cancelled_lock (locker-product cancel-then-reclaim parity). | Critical |
| DeleteLock ★ | Remove a vesting lock from governance storage. For isolated locks, the un-vested remainder in the lock’s custody subaccount is swept back to the treasury (main account) by a queued custody op; for legacy locks the tokens simply remain commingled in the main account (unchanged). To let the user reclaim instead, Cancel (ModifyLock) — a Delete after a Cancel forfeits the pending claim to the treasury. | Critical |
| TransferLockBeneficiary ★ | Change who receives future unlocks (beneficiary). State-only — the lock’s custody subaccount is derived from the lock_id and never moves. Stamps the lock’s recipient_change_count / last_recipient_change_at metadata and emits the same stable events (LockStateChanged / ErrorOccurred) as the creator-facing change_lock_recipient endpoint. As the DAO override path (e.g. lost-key recovery) it is NOT bound by the creator’s 24h cooldown or the one-way recipient_permanent latch; a governance change re-arms the creator’s cooldown. See WORKFLOWS.md § “Changing a SONS lock’s recipient” for the creator self-service flow (change_lock_recipient / set_lock_recipient_permanent, Shield-protected). | Critical |
| TransferLockCreator ★ | Batch-reassign the lock CREATOR (voting-power holder); reads creator_transfers. Already-cast votes are NOT retroactively updated; new creators do NOT inherit follow/delegation state; verification is action-time. Batch capped at MAX_BATCH_CREATOR_TRANSFERS; 2-pass validate-then-apply. State-only — the lock’s custody subaccount is derived from the lock_id and never moves. | Critical |
Per-lock custody isolation
Section titled “Per-lock custody isolation”Every non-LP lock created since per-lock isolation was introduced custodies its tokens in a
dedicated 32-byte subaccount of the SONS canister, derived as
SHA-256("ohshii-sons-lock:v1:" ‖ len(campaign_id) ‖ campaign_id ‖ len(lock_id) ‖ lock_id)
and stored on the record (lock_subaccount). The main account is treasury-only:
- Vesting locks (LGE finalize):
batch_create_vesting_locksreservesfee × (number_of_unlocks + 1)per contributor (N unlock legs + 1 funding leg) and queues one idempotent funding op per lock (main → subaccount,gross − fee, pinnedcreated_at_time, balance-gated, Duplicate→Ok). A timer drains the queue (kick at batch time + every 300 s); unlock payouts are gated oncustody_funded == Some(true)and never fall back to the main account. Progress is observable via the publicget_custody_ops_statusquery. - Manual locks (
create_additional_lock): the ICRC-2 pull deposits DIRECTLY into the lock’s subaccount (funded from birth; fee math unchanged). If the record write fails after the deposit, a balance-driven refund op returns the funds to the caller automatically. - Unlocks: each tranche is paid
from_subaccount = lock_subaccount. The fee reserve is sized at creation-time fee, so if the ledger fee later RISES the reserve can be overdrawn on ANY tranche: a definiteInsufficientFundson a funded isolated lock triggers a final settlement — everything still deliverable (balance − fee) is sent in one transfer and the lock completes (custody_final_settlementevent); the beneficiary absorbs the accumulated fee delta instead of the lock freezing with the residual stranded. - Treasury integrity: an append-only registry (subaccount → lock_id) is
consulted by
TreasuryWithdrawat creation and execution; no treasury operation can ever draw from lock custody, and no lock operation can draw from the main account or another lock’s subaccount. - Legacy locks (created before per-lock isolation,
lock_subaccount = None) keep the historical main-account behavior unchanged. - LP locks custody an ICPSwap position (NFT-like), not a fungible balance — out of scope for subaccount isolation.
Token & canister management
| AdminMethod | What it does | Tier |
|---|---|---|
| UpdateTokenMetadata | Update token metadata (name, symbol, fee, logo) on the ledger. | Standard |
| AddController | Add a controller to a controlled canister. | Standard (Critical if target = self) |
| RemoveController | Remove a controller (never the governance canister itself). | Standard (Critical if target = self) |
| CreateSnapshot | Snapshot a controlled canister. | Standard |
| RestoreSnapshot | Restore from snapshot (destructive — overwrites state). | Critical (always) |
| DeleteSnapshot | Delete a snapshot (irreversible). | Critical (always) |
| StopCanister | Stop a controlled canister. Cannot stop self. | Standard |
| StartCanister | Start a previously stopped canister. | Standard |
| UpgradeIndexCanister ★ | Upgrade the index canister (WASM from an upload session). Cannot target the governance canister. Single-index maintenance only — whole-suite upgrades use the UpgradeCanisterBatch category. Critical because it installs code on a managed canister. | Critical |
Governance, campaign, verification & delegation
| AdminMethod | What it does | Tier |
|---|---|---|
| SetGuardian | Set / replace / remove the guardian principal. | Standard (add/replace reaches Critical via the guardian-target classification; removal keeps the 7-day hardening) |
| UpdateCampaignInfo | Write campaign description / website / socials (sanitized) into this governance canister’s own record, readable via get_campaign_info. The executable counterpart of the UpdateCampaign category. Scope: it does not change the campaign text shown on the OhShii campaign/DAO pages — see the note below. | Standard |
| ToggleFeatureFlag | Toggle a governance feature flag (e.g. allow_new_locks, allow_fee_collection). | Standard |
| RemovePermanentlyVerifiedUser | Forever Verified escape hatch. Evict one principal from the per-DAO permanent-verified cache. Param target_user_principal (non-anonymous). Idempotent. Companion to the direct call admin_remove_permanently_verified_user. | Standard |
| BatchSetFollow ★ | DAO-driven batch follow assignment: one followee for N followers, bypassing per-caller follow-consent (the proposal IS the authorization). Skips the per-caller follow rate limits and the per-follower verification calls. Still enforces: followee accepts_followers=true (re-checked at execution), bipartite/no-cycle invariant, no self-follow, MAX_FOLLOWERS_PER_FOLLOWEE, override semantics, MAX_BATCH_SET_FOLLOW. | Critical |
Where the campaign description actually lives (two records, on purpose)
Section titled “Where the campaign description actually lives (two records, on purpose)”UpdateCampaignInfo is frequently misread, so it is worth being explicit. There are two
independent copies of a campaign’s descriptive text, and they are not synchronised:
| Record | Written by | Read by |
|---|---|---|
campaign_data on the campaign’s own governance canister | the DAO, via an UpdateCampaignInfo proposal | the get_campaign_info query (used for on-chain campaign-state checks) |
the Campaign record in dao_storage | the launcher backend admin_update_ico_info — an approved ONS proposal, the guardian, or, during the transition phase, the deploying principals | the OhShii campaign and DAO pages |
The OhShii pages render the dao_storage copy. A passed UpdateCampaignInfo proposal
therefore updates the DAO’s own on-chain record but does not change the text displayed
on OhShii. That is intentional: the displayed campaign metadata is platform-curated, which
is what allows OhShii to moderate its own surface (including takedowns) without needing a
DAO vote. It is also why the guardian’s admin_update_ico_info writes only to dao_storage
and never propagates into a campaign governance canister — a campaign DAO’s own state is
never rewritten by the platform. The two directions are independent by design.
A DAO that wants its description changed on the OhShii pages needs the dao_storage copy
updated (through admin_update_ico_info, executed by an approved ONS proposal) rather than an
UpdateCampaignInfo proposal; the proposal form states this at creation time.
SONS Democratic Emergency Exemption
Section titled “SONS Democratic Emergency Exemption”create_proposal() skips the async verify_caller_for_governance verification
gate when ALL the following hold:
- proposal category is
CriticalGovernanceOperation, - proposal target is
ConfigUpdate(data), data.is_emergency_verification_off() == true, i.e.new_requires_verification == Some(false)AND all other ConfigUpdate fields areNone.
The exemption exists because without it the DAO can soft-lock: with the
verification gate ON, a proposer needs BOTH stake AND verification to submit any
proposal — including the one that would flip the flag OFF. The Democratic
Emergency Exemption lets ANY staked principal submit the narrow rescue payload;
cached verified users (PERMANENT_VERIFIED_USERS) then vote on it
without ICCs to ONS. No privileged backdoor; the DAO rescues itself.
Each invocation emits SystemLifecycle { event_type = "verification_gate_emergency_exemption_invoked", campaign_id = "caller=<p>" }.
The rejected alternatives were a privileged backdoor and a timer-based auto-disable on ONS unreachability — both rejected because they break the DAO model or create DDoS incentives respectively.
SONS Forever Verified — direct-call methods (non-proposal)
Section titled “SONS Forever Verified — direct-call methods (non-proposal)”| Method | What it does |
|---|---|
admin_remove_permanently_verified_user | Direct-call eviction by the authorized caller (is_admin_guard); the proposal route is the RemovePermanentlyVerifiedUser AdminMethod above. Removes a principal from PERMANENT_VERIFIED_USERS; returns was_present. Rejects the anonymous principal. Emits SystemLifecycle{event_type="permanent_verified_user_evicted_via_admin"}. |
admin_get_permanent_verified_user_count | Cardinality metric for the authorized caller (is_admin_guard) — returns the cache size only (no public query exposes the principals themselves). |
SONS LP Lock methods (non-proposal, direct calls)
Section titled “SONS LP Lock methods (non-proposal, direct calls)”| Method | What it does |
|---|---|
create_lp_lock | Create an LP lock by pairing governance token with ICP on ICPSwap. Mints a full-range position and locks it. VP calculated from governance token amount. |
reclaim_lp_lock | Reclaim an expired LP lock (transfer position back to user). Rate-limited. |
get_lp_lock_fees | Query pending trading fees for a locked LP position. |
See LP_LOCK_LIQUIDITY.md for the full deposit-to-lock flow and token ratio calculation.
Voting parameters and config via proposals
Section titled “Voting parameters and config via proposals”Both ONS (ohshii_governance) and SONS (sons_governance) allow the DAO to change voting and fee configuration via proposals, so the community can tune quorum, approval thresholds, durations, and fees without code upgrades.
ONS (OHSHII Governance)
Section titled “ONS (OHSHII Governance)”- Voting parameters (quorum, approval %, durations, max VP per user, Guest-fee and purchase-limit params) are stored in stable memory and exposed via
get_voting_parameters(). They are not hardcoded. - To change them, create a proposal with category CriticalGovernanceOperation, target canister = ohshii_governance, method
set_voting_parameters, and Candid-encodedVotingParamsUpdate(all fields optional; only provided fields are updated). Threshold: Critical (see Two-axis voting model). - Proposal creation fee and rejection cost can be changed via ExecuteAdminMethod (target = ohshii_governance), methods
set_proposal_creation_feeandset_rejection_cost, with Standard threshold. The canister enforces a ±50% change limit per execution to prevent governance attacks.
SONS (LGE Governance)
Section titled “SONS (LGE Governance)”- ConfigChange proposals carry ConfigUpdateData for non-critical fields only:
new_proposal_creation_feeandnew_rejection_cost(Standard threshold). new_fee_recipientis deliberately blocked in proposal flows because changing fee destination can break proposal refund behavior.- Voting/runtime parameters in
ConfigUpdateData(new_voting_duration_ns, quorum/approval thresholds, min votes, min VP to propose, min lock days, min/max/default voting durations, max VP per user, min lock amount, etc.) must use CriticalGovernanceOperation and therefore the critical tier (see Two-axis voting model). - In
CreateProposalForm(SONS path),AddControllerandRemoveControllerare configured under ExecuteAdminMethod but are auto-submitted as CriticalGovernanceOperation whentarget_canister_idequals the selected governance canister (self-target). - Fee and rejection cost changes remain limited to ±50% of the current value per proposal to prevent a single proposal from making fees unreachable or removing spam protection. Larger changes require multiple sequential proposals.
- The Create Proposal form (SONS path) offers standard Config Change methods for fee settings and a critical method for voting parameter changes.
Parameter reference — votable defaults vs hardcoded
Section titled “Parameter reference — votable defaults vs hardcoded”This is the authoritative list of what the DAO can change by vote and what is hardcoded (changeable only by a canister upgrade). Defaults are the values a freshly-deployed canister starts with.
Votable (DAO-tunable) — voting thresholds. ONS changes all of these in one
set_voting_parameters call (CriticalGovernanceOperation); on ONS they are
snapshotted per-proposal (grandfathering). SONS splits them: the standard-tier
ones via ConfigChange, the critical-tier ones (marked †) via
CriticalGovernanceOperation.
| Parameter | Default | Floor / bound |
|---|---|---|
standard_quorum_vp | 250,000 VP | ≥ 1 |
min_quorum_votes | 15 voters | ≥ 1 |
critical_quorum_vp | 350,000 VP | ≥ 1 |
critical_min_quorum_votes † | 20 voters | ≥ 1 |
critical_approval_pct | 65 % | 1–99 |
critical_immediate_vp_threshold † | 450,000 VP | ≥ 1 |
critical_immediate_min_votes | 30 voters | ≥ 1 |
critical_immediate_approval_pct † | 75 % | 1–99 |
immediate_approval_pct / immediate_min_votes / immediate_approval_vp_threshold | 90 % / 20 / 500,000 | vestigial — NORMAL immediate close is disabled; kept for stable-storage compat |
veto_override_approval_pct | 80 % | 1–99 |
veto_override_quorum_vp | 700,000 VP | ≥ 1 (never 0) |
veto_override_min_votes | 50 voters | ≥ 1 (never 0) |
veto_override_duration_ns | 1 day | ≥ 1 day (hard floor) |
Votable — durations, proposing & economics. (ONS: set_voting_parameters,
Critical. SONS: the critical-tier durations marked † via
CriticalGovernanceOperation, the rest via ConfigChange.)
| Parameter | Default | Floor / bound |
|---|---|---|
normal_voting_duration_ns † (SONS) | 3 days | guardian_min ≤ normal ≤ critical |
critical_voting_duration_ns † (SONS) | 7 days | ≥ normal |
guardian_min_voting_duration_ns † (SONS) | 1 day | ≤ normal |
min / max / default_voting_duration_ns | 3 d / 7 d / 7 d | min ≤ max |
min_vp_to_propose | 10,000 VP | ≥ 1 |
min_lock_duration_days | 45 days | ≥ 1 |
max_voting_power_per_user (the VP cap) | 36,000 | > 0 |
days_in_month | 30 | > 0 |
min_lock_amount (SONS) | configurable | ≥ 1 |
proposal_creation_fee | 30,000 OHSHII / 30k token | ±50 % per proposal; ≥ rejection_cost + 3×ledger_fee. ONS via set_proposal_creation_fee (ExecuteAdminMethod, Standard); SONS via ConfigChange |
rejection_cost | 25,000 OHSHII / 25k token | ±50 % per proposal; ≤ creation_fee − 3×ledger_fee. Same routes as the fee |
requires_verification (SONS only) | true | boolean. Set at creation: LGE DAOs are always true; an imported DAO may be created false by the importer. Changed afterwards via CriticalGovernanceOperation only |
| LGE tier limits + VP thresholds (ONS) | see Voter Benefits Protocol | monotonic; via set_voting_parameters |
Hardcoded (only changeable by a canister upgrade).
| Thing | Value / rule | Why not votable |
|---|---|---|
| Tier classification of each category / method | is_critical_category / proposal_uses_critical_thresholds / admin_method_call_is_critical (ONS); is_critical_proposal + is_critical_admin_method (SONS) | which proposals are Critical is a policy invariant, not a number |
| Guardian-removal hardening | hard 7-day window, never immediate, guardian can’t vote on its own removal | stops a guardian from fast-tracking or rushing its own removal; removal is vetoable-but-overridable like any Critical proposal |
| Veto-override floors | window ≥ 1 day; quorum & min-voters never 0 | guarantees a veto can only delay, never permanently block |
fee_recipient | not proposal-configurable | changing the fee destination can break proposal-refund flows |
| The ±50%-per-proposal fee clamp itself | fixed guard | prevents one proposal from making fees unreachable or removing spam protection |
| Quadratic VP formula | sqrt( Σ token_amount × duration_months² ) over a principal’s eligible locks, capped at max_voting_power_per_user | formula is fixed; only the cap value is votable |
| Delegation / batch caps | MAX_FOLLOWERS_PER_FOLLOWEE 500, MAX_BATCH_SET_FOLLOW 300, MAX_BATCH_CREATOR_TRANSFERS 300, MAX_DELEGATIONS_PER_TICK 100 | instruction-budget safety |
| Guardian eligibility ban list | infra canisters / management canister / governance-self rejected | privilege-escalation safety |
ExecuteAdminMethod positive allowlist (ADMIN_ALLOWLIST, 86 (target, method) pairs) + per-method critical set + CriticalGovernanceOperation 18-method allowlist | fixed method sets | which methods exist and which are dangerous is a policy invariant (adding one needs a canister upgrade) |
| Shield kill-switch targets | the 5 core canisters (SONS excluded) | a fixed, non-caller-supplied set |
Voting Parameters Grandfathering (Snapshot at Creation)
Section titled “Voting Parameters Grandfathering (Snapshot at Creation)”When a proposal is created, the decision-relevant voting parameters are snapshotted into a voting_params_snapshot field on the proposal record. All subsequent quorum and threshold decisions — immediate approval, immediate rejection, and expiry evaluation — use the snapshotted values rather than the current configuration.
This ensures proposals are always judged by the rules active when they were created (grandfathering rule). If a governance proposal later changes voting thresholds, already-open proposals continue to be evaluated against the parameters they were created under.
Snapshotted parameters:
standard_quorum_vp— VP quorum for the NORMAL standard closemin_quorum_votes— minimum voter count for the NORMAL standard closeimmediate_approval_vp_threshold/immediate_min_votes/immediate_approval_pct— legacy NORMAL-path immediate fields (immediate approval is disabled for NORMAL proposals; kept for backward compatibility)critical_quorum_vp— VP quorum for the CRITICAL standard closecritical_approval_pct— approval % for the CRITICAL standard closecritical_min_quorum_votes— minimum voters for the CRITICAL standard close (opt — two-axis model)critical_immediate_min_votes— minimum voters for CRITICAL immediate approvalcritical_immediate_approval_pct— approval % for CRITICAL immediate approval (opt — two-axis model)critical_immediate_vp_threshold— VP threshold for CRITICAL immediate approval (opt — two-axis model)veto_override_*— veto-override round params (opt)
Backward compatibility: Proposals created before a given field existed have None for that field (or voting_params_snapshot = None entirely) and fall back to the current configuration / default constants. Per-type voting durations are NOT snapshotted — the expiry_timestamp is computed once at creation and stored on the proposal.
This applies to both ONS (ohshii_governance) and SONS (sons_governance) canisters.
Voter Benefits Protocol (LGE Participation Tiers)
Section titled “Voter Benefits Protocol (LGE Participation Tiers)”LGE participation is gated by the Voter Benefits Protocol, a 5-tier system
built on two independent, orthogonal axes composed only at eligibility-check
time. Authoritative call: ohshii_governance.get_lge_eligibility(principal).
Axis 1 — Tier (capital)
Section titled “Axis 1 — Tier (capital)”Pure function of the caller’s strong verification (Telegram, World ID or DecideID) + current VP. Never
modified by governance behavior. The backend reads effective_tier from
get_lge_eligibility and enforces max_purchase_limit_e8s.
The tier ladder reads STRONG, not “verified”. A wallet whose only method is
Google (Standard) sits at Guest exactly as an unverified wallet does — see the
gate matrix above for why. Read strong in the table below as “holds Telegram,
DecideID or World ID”.
| Tier | Condition | Limit (tokens) | Fee |
|---|---|---|---|
Guest | !strong | 1,000,000 | 30,000 OHSHII once per LGE, 100% to treasury |
Human | strong && VP < 2,000 | 4,000,000 | free |
Fish | strong && 2,000 ≤ VP < 10,000 | 12,000,000 | free |
Shark | strong && 10,000 ≤ VP < 30,000 | 18,000,000 | free |
Whale | strong && VP ≥ 30,000 | 24,000,000 | free |
A campaign’s creator is bounded by a ceiling of their own instead of by the tiers above: 80,000,000 tokens of their own campaign. It is a single total, not an extra budget — the creator’s initial allocation and any later purchases count against the same number, so a creator who takes the full allocation at creation can buy nothing more, and one who takes 10,000,000 may buy the remaining 70,000,000.
This holds because the initial allocation is recorded as an ordinary contribution
row, so the same per-user sum that meters purchases already includes it. The
ceiling is applied in dao_storage, in the same synchronous gate that meters
every other buyer, reading the creator’s identity from the stored campaign
record. It applies to no one else.
Axis 2 — Active voter (behavior)
Section titled “Axis 2 — Active voter (behavior)”Pure function of the caller’s vote history on the last 3 ONS proposals that
are countable for that caller. Never persisted; recomputed on every call.
The canister scans newest-to-oldest with headroom (3 + 12 proposals, so up to
15 recent IDs) and keeps walking backward until it has collected 3 countable
proposals or the scan window is exhausted.
This is intentionally per-wallet. A young proposal can count for Alice but not Bob: if Alice already voted on it, it enters Alice’s denominator and numerator; if Bob did not vote and the proposal has not provided a fair 24-hour window, it is forgiven and skipped for Bob.
scanned = memory::get_latest_n_proposals(3 + 12)
for p in scanned newest-to-oldest: user_voted = round-1 vote exists OR override-round vote exists
if p.status == PendingUpload: skip else if user_voted: include p in denominator and numerator else if p.status == Open and age < 24h: skip else if p.status == Open and age >= 24h: include p as missed else if p.status in {Approved, Executing, Rejected, Executed, ExecutionFailed}: include p as missed else if p.status in {Vetoed, VetoOverrideExpired} and vetoed_at - created_at < 24h: skip else: include p as missed
stop once 3 proposals have been included
votes = included proposals where user_votedrequired = min(2, recent.len())is_active_voter = votes >= requiredAny direct vote counts — no VP threshold on the vote itself. A veto-override vote also counts, so users who participate in the second round are not scored as missing the proposal.
Fast democratic resolution is not punished: Approved, Rejected,
Executed, ExecutionFailed (and the transient Executing) count even if the
DAO reached a legitimate decision in less than 24 hours. Fast Guardian vetoes
are different: a Vetoed proposal that was killed before 24 hours is skipped
for non-voters, but still counts for voters who actually participated.
If fewer than 3 countable proposals are found in the scan window, the denominator
is smaller. Example: if only 2 proposals are countable, required = min(2, 2) = 2; if 1 is countable, required = 1; if 0 are countable, required = 0 so no
one is demoted just because governance has no fair sample yet.
Composition (demotion)
Section titled “Composition (demotion)”effective_tier = if tier ∈ {Fish, Shark, Whale} && !is_active_voter then Human else tierA strongly-verified Fish/Shark/Whale that misses the 2-of-3 rule participates as Human (4M limit) until they vote on the next ONS proposal — at which point the full tier is restored on the very next purchase. The underlying verification, lock ownership, and VP are all untouched; the demotion is a pure derived value returned alongside the capital tier.
LgeEligibility additionally carries the caller’s verification_strength, so an
interface can tell a Google-verified wallet that it needs a strong method rather
than telling it that it is unverified.
Fail-safe posture
Section titled “Fail-safe posture”If the backend’s ICC to ohshii_governance.get_lge_eligibility fails for any
reason (network, decode, governance canister stopped), the backend falls
back to Human limit (4M) + Guest fee required. This is the strictest
realistic tier and prevents an outage from being exploited to exceed
limits.
Governance-tunable parameters
Section titled “Governance-tunable parameters”All tier limits and VP thresholds live in VotingParamsConfig and are
editable via set_voting_parameters (ONS CriticalGovernanceOperation
proposal). Monotonicity is enforced:
purchase_limit_guest ≤ purchase_limit_human ≤ purchase_limit_fish ≤ purchase_limit_shark ≤ purchase_limit_whale
and min_vp_fish ≤ min_vp_shark ≤ min_vp_whale. The Guest fee amount
is BurningFeeConfig.burn_amount_e8s (repurposed field name, preserved for
stable-storage compatibility).
API surface
Section titled “API surface”ohshii_governance:
get_lge_eligibility(principal) -> variant { Ok: LgeEligibility; Err: text }— update (does ICC to lock_storage for VP). Backend-authoritative path.get_lge_eligibility_for_vp(principal, nat64) -> LgeEligibility— query. Frontend fast path after fetching VP viaget_voting_power.get_active_voter_status(principal) -> ActiveVoterStatus— query (axis 2 only).tier_for_vp(principal, nat64) -> ParticipationTier— query (axis 1 only).
backend:
purchase_tokens_icrc2callsget_lge_eligibility, useseffective_tier+max_purchase_limit_e8s+guest_fee_e8s.transfer_ohshii_guest_fee— single atomic ICRC-2transfer_fromtoOHSHII_TREASURY_ADDRESS(memoGUEST_FEE), triggered only wheneffective_tier == Guestand the user has not yet contributed in this LGE.
Sponsor Reward Tiers
Section titled “Sponsor Reward Tiers”The sponsor reward tiers used in LGE creation and token purchases are hardcoded in the launcher backend; changing them requires a canister upgrade, which is itself an ONS proposal. Full mechanics: Sponsor System.
Current thresholds
Section titled “Current thresholds”Every fee-bearing transaction has a fixed 10% total fee (100/1000 of the token cost): 5% OhShii platform fee (always to treasury) + 5% referral band. The sponsor’s share of the referral band depends on the sponsor’s OHSHII quadratic voting power (VP) and on the sponsor being an active voter:
- < 2,000 VP (or no sponsor): 0% to sponsor, 10% to treasury (5% platform + 5% unearned referral)
- 2,000 VP: 2% to sponsor, 8% to treasury (5% platform + 3% unearned referral)
- 3,000 VP: 3% to sponsor, 7% to treasury (5% platform + 2% unearned referral)
- 5,000 VP: 5% to sponsor, 5% to treasury (platform fee only)
On every current campaign the referral band is held per campaign and settled at the LGE outcome: on success the sponsor claims its share from the campaign’s own SONS DAO; on failure the whole band is refunded to buyers. The campaign always receives 90% of the token cost regardless of sponsor tier.
How VP is checked (per-transaction, not per-campaign)
Section titled “How VP is checked (per-transaction, not per-campaign)”VP is not checked once at campaign creation and cached. It is resolved independently for each fee-bearing transaction at the time the transaction occurs:
- At LGE creation: the sponsor is the LGE creator’s referrer (the person whose sponsor code the creator used). VP of that referrer is checked once when the creation fee is paid.
- At each token purchase: the sponsor is the buyer’s individual referrer. VP of that referrer is checked at the time of each purchase, independently, and the resulting tier is snapshotted into the contribution.
Because VP is resolved per-transaction, different buyers in the same campaign can have different sponsors with different VP tiers. A sponsor whose VP changes (e.g. by locking or unlocking OHSHII tokens) sees the updated tier applied to all future transactions where they are the referrer; already-snapshotted contributions keep their tier.
OHSHII Governance (ONS) – summary
Section titled “OHSHII Governance (ONS) – summary”- Canister: Single
ohshii_governancecanister. Indfx.json; deployed with the launcher. - Voting power: OHSHII token quadratic voting power (based on locked tokens). Supports three lock types: OneTime (single unlock date), Linear (vesting schedule), and LPLock (ICPSwap liquidity position lock — VP based on governance token amount and lock duration, same formula as OneTime). Lock points are not used for voting. Lock points are a separate linear scoring system for tokens listed on OhShii Locker, used by third-party dapps for utility locks and gating (no quadratic cap). Frontend uses
ohshii_governance.get_voting_parameters()andget_voting_power()(or lock_storage for quadratic VP). - Cap application rule: the VP cap is applied on the sum of all eligible locks for the same principal (aggregated per user, not per single lock only). Example with cap
36,000:36,000 + 4,000 => 36,000votable/displayed VP. - Voting parameters: Stored in stable memory; updatable via CriticalGovernanceOperation (
set_voting_parameterson self). Fee and rejection cost updatable via ExecuteAdminMethod (Standard threshold). See Voting parameters and config via proposals. - Guardian: Optional guardian principal; for a NORMAL proposal it may choose a voting period between 1 day and the normal 3 days. Critical proposals keep their fixed 7 days, and a guardian-removal proposal runs at least 7 days. Frontend shows “Guardian” badge and “Resign as Guardian” when applicable.
- Vote delegation (Liquid Democracy): ONS supports per-principal vote delegation via the
follow/unfollow/set_accepts_followers/dismiss_followerendpoints. A verified voter with non-zero VP can delegate to a single explicitly opted-in delegate; when the delegate votes on an ONS proposal, every follower’s vote is automatically applied within one timer tick using each follower’s own snapshot voting power. Bipartite graph by construction (a principal cannot be both a delegate and a follower simultaneously). Full lifecycle, security model, and API reference: LIQUID_DEMOCRACY.md. - Timer: ohshii_governance runs a timer (e.g. 5 min) for proposal expiry and auto-execution. The same timer also drives the delegation cascade (
process_pending_delegations) which applies up to 100 queued delegated votes per tick synchronously. Status and public restart are shown on System Status and OnsVotingPage. - Where it appears: System Status page (OHSHII Governance card), OnsVotingPage when “OHSHII” is selected (Following tab + Voting Power section show delegation state), CreateProposalForm ONS path.
LGE Governance (SONS) – summary
Section titled “LGE Governance (SONS) – summary”- Canister: One
sons_governanceper LGE campaign. Not indfx.json; created at LGE creation by the backend (create_empty_governance_canister+ install WASM in src/backend/src/lib.rs). - Voting power: The LGE campaign token (and locks/vesting on that governance). Supports three lock types: OneTime, Linear, and LPLock (ICPSwap LP position lock — VP based on governance token amount and lock duration). Frontend uses the selected token’s governance canister for
get_voting_parameters()andget_voting_power(). - Cap application rule: same aggregation logic applies in SONS governance: cap is enforced on the principal aggregated VP across eligible locks.
- Config and voting parameters: Split by risk level:
proposal_creation_fee/rejection_costvia ConfigChange (Standard), voting/runtime parameters via CriticalGovernanceOperation (Critical).fee_recipientis not proposal-configurable. See Voting parameters and config via proposals. - Timer: Each sons_governance has its own timer (e.g. 1 min for unlocks, 5 min for proposals). OnsVotingPage shows the selected governance canister’s timer status and module hash.
- Where it appears: OnsVotingPage when a SONS token is selected; CreateProposalForm SONS path.
Snapshot and restore
Section titled “Snapshot and restore”Both ohshii_governance and sons_governance support create/restore/delete snapshot proposals. When the target is the governance canister itself, snapshot/restore is delegated to the ohshii_launcher_backend as orchestrator, because a canister cannot stop itself (the call would never return).
- OHSHII Governance (ONS) self-snapshot/restore: The backend is already a controller of ohshii_governance. The ohshii_governance notifies the backend to run stop → snapshot/restore → start; no controller change.
- LGE Governance (SONS) self-snapshot/restore: The governance canister is not created with the backend as controller. So the governance canister adds the OhShii backend as a temporary controller, notifies the backend to run the workflow with cleanup_backend_controller_after_start = true, and the backend removes itself from the governance controllers after start. This keeps the backend from retaining control and is required so the backend can call stop/start on the target. The Create Proposal form shows the target canister when creating a snapshot proposal; when the target is the current (SONS) governance canister, this temporary-controller flow is used.
Backend methods: backend_orchestrate_snapshot_workflow, backend_orchestrate_restore_workflow. Full workflow diagrams: WORKFLOWS.md.
Guardian role — capabilities and limits
Section titled “Guardian role — capabilities and limits”The Guardian is a governance role held by a single principal, for emergency recovery, stored in GUARDIAN_PRINCIPAL. It is set via the SetGuardian proposal category (Critical threshold). The Guardian exists for scenarios where the proposal pipeline itself is degraded and a Critical proposal cannot be executed — typically because the index that backs get_proposals_paginated is corrupted or because a freshly-deployed canister needs an immediate emergency action before the DAO can be assembled.
Guardian principal — validity constraints
Section titled “Guardian principal — validity constraints”The guardian is a governance role held by one principal. Every guardian-set entry point — the SetGuardian proposal (validated at creation AND re-checked at execution), the transition-phase admin_set_guardian_principal (ONS — reachable by the deploying principals until the DAO handover retires them), and the SONS update_config full-config override — runs validate_guardian_choice, which enforces:
- Never the anonymous principal.
NoneandSome(anonymous)both mean “remove the guardian” and normalise to “no guardian” — the guardian is never stored as anonymous. Were it stored as anonymous, any unauthenticated caller would satisfy theguardian == callercheck. - Never an OhShii infrastructure canister (launcher backend/frontend, dao_storage, pool_manager, ohshii_governance, proxy, strongbox, locker backend/frontend, lock_storage, OHSHII ledger), never the management canister, and never the governance canister itself.
None(no guardian) is valid and means nobody holds the role — it never means “everyone”. All caller checks treatNoneas “no guardian”:is_guardian()returnsfalse,guardian_vetorejects withNotAuthorized, and the immediate-approval guardian-yes gate is simply skipped (no guardian → no veto window to protect).
Bad choices are rejected at proposal creation (fail fast, before a vote is spent) and re-checked at execution (defence in depth). The constraint is identical on ONS and SONS.
Where a campaign DAO’s guardian comes from
Section titled “Where a campaign DAO’s guardian comes from”- At install. A campaign’s governance canister is installed with the campaign’s post-LGE guardian as its
initial_guardianinit argument: the guardian chosen at creation, else the creator. The init argument runs the samevalidate_guardian_choice. - While the LGE runs, the emergency role on that canister is held by the ecosystem DAO’s guardian; the campaign’s own guardian takes effect when the campaign completes.
- At completion, if the field is empty. A campaign governance canister installed before the init argument existed can reach completion with no guardian. The backend therefore offers the same principal on
finalize_governance : (opt principal), and the canister writes it only into an empty guardian field, throughvalidate_guardian_choice, in the same message that setsfinalized = true. A guardian that is already set is never replaced; a refused offer leaves the field empty, does not stop finalization, and is recorded in the canister’s event log with the offered principal and the reason. - After completion, only an approved
SetGuardianproposal changes it.
What the Guardian CAN do
Section titled “What the Guardian CAN do”The Guardian cannot impact decentralization. None of the actions below can change a vote outcome or rewrite governance state. The guardian’s only governance-affecting action is the overridable veto — and a veto is overridable by the community (see Guardian Overridable Veto), so the community always keeps the final word. Every other capability is maintenance, disaster recovery, or emergency DoS defence: it rebuilds a derivable secondary index from its source-of-truth, sets or refreshes the Shield firewall, hides a description on the frontend, hands the role back, or drives an idempotent recovery of owed funds to a fixed treasury destination (the refund drain and the stuck-pool-funds claim) — never naming a recipient, spending the treasury at the guardian’s discretion, or touching proposals, votes, anti-replay sets, or DAO-voted parameters (see What the Guardian CANNOT do).
ONS vs SONS guardian. The ONS guardian (ohshii_governance) holds the full toolkit
below. A SONS (per-campaign sons_governance) guardian holds a deliberately narrower
set — only the rows marked ONS + SONS: the overridable veto, its own canister-local
Shield pre-auth mode and cache refresh, the refund drain (force_process_refunds), the
asset-batch cleanup (guardian_delete_asset_batch), the chunk-store cleanup
(guardian_clear_chunk_store), and the stuck-pool-funds recovery
(guardian_claim_pool_funds, which on SONS sweeps the campaign governance canister’s own
pool position back to its treasury), plus the orphan fee-escrow sweep described under the
proposal deposit. The cross-canister kill switch, description moderation, index rebuilds and
resign are ONS-only; a SONS guardian cannot rebuild indices, moderate, run the
kill switch, or resign (its role is
set/replaced/removed through the campaign DAO’s own SetGuardian proposal). Every one of
these — including the funds recovery — either rebuilds a derivable index, drives an
idempotent drain/recovery to a fixed treasury destination, or opens a community-override
veto; none can name a recipient, change a tally, or spend the treasury at the guardian’s
discretion.
A campaign DAO has exactly ONE guardian at any moment, and which one it is changes once —
at launch completion. While the launch is still running, the campaign’s guardian is the
OhShii guardian: the campaign is not yet autonomous, and the emergency brake has to exist
before its DAO does. When the launch completes, the campaign’s own guardian — the one its
DAO sets, replaces or removes through its SetGuardian proposal — becomes the only one the
campaign accepts, and the OhShii guardian’s powers over that campaign end there. The change is
one-way: there is no path back. So on a completed campaign, no guardian action is available to
OhShii at all, and every one listed above belongs to the campaign’s DAO.
Anyone can read which guardian a campaign currently accepts: get_effective_guardian() on the
campaign’s governance canister answers with that one principal. A campaign that has not set a
guardian for its current phase has none, and no guardian action is available on it.
One capability a campaign’s guardian holds on the launcher backend rather than on its own
canister. The two relaunch recovery entry points — clearing a stuck relaunch marker, and
retrying a relaunch step — accept the campaign’s guardian directly, so a campaign wedged
mid-relaunch can be unwedged by the party whose campaign it is, without waiting for a vote.
The backend resolves which principal that is by asking the campaign’s own governance canister
the same get_effective_guardian() question above; it never accepts both of a campaign’s
configured guardian principals, and it never substitutes the OhShii guardian. The capability is
rate-limited per campaign so one campaign’s recovery can never throttle another’s, and a refusal
names the limit and when to retry; the same two entry points are also reachable by an approved
OHSHII proposal, which remains available after the transition phase. Neither moves any value, and
neither can pay an address a caller chooses. Full flow: WORKFLOWS.md §“If a relaunch gets
stuck”.
| Endpoint | DAO | Kind | Purpose | Limits |
|---|---|---|---|---|
guardian_veto(proposal_id) | ONS + SONS | Governance brake (overridable) | Cast a blocking-but-overridable veto on a vetoable Open proposal: moves it to Vetoed and opens a veto-override round. Never a permanent kill — see Guardian Overridable Veto. | none — least-gated path by design |
shield_set_preauth_mode(mode) | ONS + SONS | Operational (firewall) | Set this canister’s Shield pre-auth mode (Disabled / Enforce / TrustedOnly). DoS-defence control; does not alter any tally. | emergency control: authorization check only, no rate limit |
guardian_refresh_preauth_cache(context) | ONS + SONS | Operational (maintenance) | Re-derive a Shield pre-auth cache context’s membership without a canister upgrade. | — |
coordinate_shield_kill_switch(mode, justification) | ONS | Operational (firewall, emergency) | Fan one pre-auth mode across the 5 core canisters at once (launcher backend, dao_storage, ohshii_governance, locker backend, lock_storage — SONS excluded). | sync gate only; 10-s bounded-wait ICC per target |
update_proposal_description_moderation(gov_id, proposal_id, moderated) | ONS | Moderation (cosmetic, FE-only) | Mask / unmask a proposal description on the frontend only — a view control. Never changes proposal state, eligibility, or the vote. Applies to any target governance canister (incl. SONS proposals); the flag lives in ONS memory and no SONS state is ever mutated. | guardian-exclusive (is_guardian_guard — no other role required) |
guardian_set_surface_moderation(subject, record) / guardian_clear_surface_moderation(subject) | ONS | Moderation (cosmetic, FE-only) | Choose which fields OhShii’s own interfaces display for a token (by ledger canister id) or an LGE / imported-DAO campaign (by campaign id): logo / name / symbol, plus description and website+social links for a campaign, and whether the subject is omitted from public listings. Works whoever controls the ledger and whatever state the campaign is in (a completed LGE and an autonomous DAO included). The subject is never touched — no ledger metadata is rewritten, no campaign record is mutated, no state of the subject is read or written; the on-chain data stays readable by anyone directly from the canister. Display-only: it never gates a purchase, claim, refund, transfer, update or withdrawal, and a delisted asset stays visible and fully manageable in its holder’s own wallet. A record with every flag cleared removes the row. | guardian-exclusive (is_guardian_guard — no other role required) |
guardian_rebuild_indexes(which) | ONS | Disaster recovery | Clear and timer-rebuild a derivable secondary index from its source-of-truth. Targets are the explicit GovRebuildableIndex enum: All, FollowersByFollowee, FollowersCount, VotersByProposal, RpKeysCache, JwksCache, AnyMethodVerifiedUsers. | 10-min cooldown + 50B cycles floor; 5-fail circuit-breaker; 4-h watchdog |
guardian_abort_rebuild() | ONS | Disaster recovery | Cancel an in-flight rebuild cascade. Resets stable state and heap re-entrancy flag; partial state on the rebuilt index is preserved as-is. | — |
guardian_resign() | ONS | Role lifecycle | Hand back the role by setting GUARDIAN_PRINCIPAL to the management canister (aaaaa-aa), a principal no caller can present — the role becomes unexercisable; only a Critical SetGuardian vote can re-elect one. | — |
force_process_refunds() | ONS + SONS | Disaster recovery (maintenance) | Force a full proposal-resolution drain (expire → execute approved → reap stuck-Executing → process refunds) out-of-band, without waiting for the resolution timer. It only drains owed refunds/execution and is idempotent — it cannot pay a refund twice and cannot change a vote, a proposal outcome, or a DAO-voted parameter (so it does not affect decentralization). | sync guardian gate only; idempotent (per-proposal refund_processed flag + the refund processing guard + deterministic refund timestamp) |
guardian_delete_asset_batch(target, batch_id) | ONS + SONS | Operational (asset cleanup) | Manual fallback for the automatic delete_batch drain when it has repeatedly failed (asset_batch_cleanup_gave_up). Fires the same guarded path as the automatic drain — including the refusal to delete a batch referenced by a still-live proposal, and the controller-permission self-heal. Frees a blocked frontend-upgrade slot; touches no proposal, tally, or fund. | guardian or the transition-phase deploying principals; reject_anonymous guard + body re-check; live-proposal refusal |
guardian_clear_chunk_store(target) | ONS + SONS | Operational (upgrade hygiene) | Empty the management-canister chunk store of a canister the governance canister controls, so a chunked upgrade can stage into it. Refused while an upgrade executed by that governance canister is staging into the same store, and refused (by the management canister) for a canister it does not control. A chunked upgrade already empties a controlled target’s store by itself before staging; this is the manual route. | Deletes staged upgrade chunks only — never code, state or cycles. |
guardian_claim_pool_funds(...) | ONS + SONS | Disaster recovery (funds) | Trigger the on-chain recovery of tokens stranded in an ICPSwap pool after a failed treasury swap or add-liquidity proposal — sweeps the pool’s unused balance and any transferred-but-not-deposited deposit-subaccount (the two official ICPSwap reclaim scenarios), both tokens, back to the executing canister’s own treasury. The destination is fixed to that treasury account — the guardian cannot name a recipient — so it moves owed funds home without touching a vote, an outcome, or a DAO-voted parameter (it does not affect decentralization). Idempotent (re-running on an already-swept pool is a no-op) and per-side non-fatal. On ONS the guardian picks which canister holds the funds (Backend for a failed swap, PoolManager for failed liquidity), which then sweeps its own pool position; on SONS the funds are always on the campaign governance canister itself. | Shield-wrapped (trusted callers — guardian, governance-self, transition-phase deploying principals — pass the firewall); per-pool single-flight guard; fails closed while a treasury op is mid-flight on the pool (so it can never steal an in-flight deposit) |
admin_clear_oidc_pending_states() / admin_clear_oidc_jwks_cache() | ONS | Operational (verification maintenance) | Invalidate in-flight DecideID OIDC handshakes, and force a refetch of DecideAI’s signing keys when they rotate faster than the 1h cache TTL. | Cannot remove anyone’s verification. The worst case is that a user part-way through a verification must start it again. Callable by the guardian, or by the DAO through an approved proposal. |
The two Shield pre-auth endpoints (shield_set_preauth_mode, guardian_refresh_preauth_cache),
the kill switch, and guardian_claim_pool_funds are also callable by
governance-self and, during the transition phase, by the deploying principals
(is_admin_guardian_or_governance_for_preauth) — they are not guardian-exclusive.
update_proposal_description_moderation is the one guardian-exclusive operational
endpoint: it is gated by is_guardian_guard, so the guardian exercises it on the guardian
role alone, both during the transition phase and after it.
Scope is deliberate: this endpoint accepts any target governance canister, including a
SONS, and that does not weaken DAO autonomy. The moderation flag is stored in
ohshii_governance’s own memory, keyed by (governance_canister_id, proposal_id), and is
read back by the OhShii frontend. It performs no inter-canister call and mutates no
SONS state: the campaign DAO’s proposals, descriptions, eligibility and tallies are
untouched, and a masked proposal continues to exist, execute and settle exactly as before.
What the guardian controls here is what OhShii’s own frontend renders — the platform’s
moderation of its own surface — not the DAO’s governance. A DAO that disagrees keeps its
proposal, its vote and its outcome; only OhShii’s presentation of the description changes.
Equivalent storage-side rebuilds (disaster recovery, reached from the same Guardian role
via a 10-s bounded-wait is_guardian ICC to ohshii_governance):
dao_storage.admin_rebuild_indexes(which)(launcher) — accepts anohshii_governanceself-call OR the Guardian OR, during the transition phase, the deploying principals.lock_storage.guardian_rebuild_indices(which)(OhShii Locker) — acceptsohshii_governanceORlocker_backend(or, during the transition phase, the deploying principals) synchronously, or any other principal via the Guardian ICC. Anti-raceRebuildGuard; 30/hour authorized-rebuild quota + 90-s per-run cooldown; a probe-rate cap bounds ICC burn from non-guardian spam.
Launcher-backend endpoints reachable by the same role. A small set of
launcher-backend operations accept ONS governance (an ExecuteAdminMethod
proposal — decisions of record belong to the DAO) and the Guardian for a faster
first response (the guardian check is a bounded is_guardian ICC to
ohshii_governance): the LGE recovery retries (guardian_retry_pool_creation,
guardian_retry_finalization, guardian_retry_campaign_icp_return — see WORKFLOWS.md), and
guardian_update_standalone_token_logo, which updates a standalone token’s
icrc1:logo while the OhShii backend is among that ledger’s controllers. That
controllership is never part of a token’s standing controllers: the backend holds
it only transiently, during creation and during an update the creator signs for,
and removes itself at the end; it remains only when that removal did not complete
or a controller added the backend, and it is creator-removable at any time (see Creating a Standalone Token); the call
preserves name/symbol/fee and the controllers list exactly, and fails closed
with platform holds no control over this ledger when the backend is not a
controller — so the reachable surface is always the one the token’s own
controllers list grants. None of these endpoints can change a vote, a tally, or a
DAO-voted parameter.
Stuck-pool-funds recovery — the execution arm. guardian_claim_pool_funds on ONS is a
thin proxy: it resolves the fixed target canister (backend for a failed treasury swap,
pool_manager for failed treasury liquidity — never an arbitrary principal) and calls that
canister’s sweep_pool_unused_balance(pool). On SONS the guardian method does the recovery
in-canister (the campaign governance canister owns the position). Each sweep_pool_unused_balance
runs the two official ICPSwap reclaim steps for both tokens — deposit any
transferred-but-not-deposited subaccount balance, then withdraw the pool’s unused balance —
with live per-token fees (never hardcoded), all-Nat math, per-side non-fatal, funds
landing only on that canister’s own treasury (MAIN) account. It acquires the canister’s
per-pool single-flight guard and refuses (fails closed) while a treasury op is mid-flight
on the pool, so a recovery can never race and consume an in-flight deposit. The receivers
are gated on ohshii_governance (plus the transition-phase deploying principals) and are not
directly guardian-callable — the guardian reaches them only through the ONS proxy, while an
approved ONS proposal calls them directly.
Guardian Overridable Veto
Section titled “Guardian Overridable Veto”guardian_veto is a blocking but overridable veto — never a permanent kill. It exists so that a captured or mistaken guardian can only delay a critical proposal by one override window, never block it. Both ohshii_governance (ONS) and every per-campaign sons_governance (SONS) instance expose guardian_veto with identical semantics.
Vetoable proposals. The guardian may veto a proposal that is still Open and whose target is a critical operation. The vetoable set is exactly the critical set (is_vetoable_proposal on ONS, is_vetoable_proposal on SONS): the critical categories — SetGuardian (add/replace and removal), UpgradeCanister, UpgradeGovernanceCanister, UpgradeAssetCanister, CriticalGovernanceOperation, UpgradeCanisterBatch, UpgradeDappBatch, CustomCall, HttpsOutcall, SetDaoCanisterRegistry — plus the per-method critical ExecuteAdminMethod calls (ONS admin_method_call_is_critical: treasury/pool withdrawals, liquidity removal, position transfers, lock mutations, campaign deletion, and the OHSHII-ledger / root-campaign conditionals) and the critical SONS AdminMethods (is_critical_admin_method: TreasuryWithdraw, RemoveLiquidity, TransferLiquidityPosition, ModifyLock, DeleteLock, TransferLockBeneficiary, TransferLockCreator, BatchSetFollow, UpgradeIndexCanister, CreatePoolAndAddLiquidity, AddLiquidityToPool, AddController, RemoveController, StopCanister, and the three transfer-journal levers ResolveAmbiguousTransfer / AuthorizeRepeatTransfer / ForceClearAmbiguousTransfer). Vetoability is decided from the proposal target payload (method_name / method_args), never the category label. A guardian-removal proposal is also vetoable, with a deliberate safeguard: the guardian may not vote in either round on its own removal, the veto only opens the community override round (it never kills the removal), and the guardian cannot block that override — so the community always completes the removal at the override supermajority. Making removal vetoable raises the bar to remove the guardian from a simple critical majority to the fresh-personhood override supermajority, so a stockpile of bought verified identities cannot remove the watchdog without live human proofs. The one residual is bounded and availability-only: a contested removal (one the guardian vetoes) runs through the fresh-personhood override, so a sustained attestation outage can delay it — a liveness bound, never a capture, since the guardian’s only power is the overridable veto.
Veto-override round. A veto moves the proposal Open → Vetoed (a non-terminal status) and opens a second voting round:
- Fresh personhood re-attestation required (caller-driven). Casting an
override-round vote requires a fresh personhood re-attestation bound
to that specific override — the standing verification that admitted the voter in
round 1 is not enough. The human attests directly on the ecosystem canister
(ONS), with the calling principal as the subject (
attest_fresh_override_personhood). For a SONS override the human attests their own personhood on ONS as well (attest_fresh_override_personhood_for_sons): ONS trusts no SONS assertion — the campaign-DAO id is inert key material, the verified caller is the subject — and the campaign DAO only reads whether the human attested (a query), checking per-DAO eligibility locally in SONS. One verified human spends one per-override nullifier (keyed to that exact(DAO, proposal)round), so personhood cannot be reused to multiply override weight. The gate reads an immutable per-proposal snapshot (override_requires_fresh_attest), so it cannot be flipped mid-round; an override that did not opt in at veto time is exempt. On a SONS override, a sustained outage of the attestation dependency auto-extends the window (+24h) so an outage cannot silently uphold a veto. - Direct-only — single votes, no liquid-democracy cascade. An override-round vote is recorded for the caller alone; it does not enqueue delegated follower votes even when the caller is a delegate with followers. Only ROUND 1 votes trigger the cascade. The override quorum (≥50 unique voters by default) is therefore a count of real, direct voters — a popular delegate cannot clear the override bar alone. Identical on ONS and SONS (
vote()callscascade_delegated_votesstrictly inside theOpenarm, never theVetoedarm). - Separate vote tally and separate vote map, so a round-1 voter may vote again.
- No immediate decision — the timer finalizes it at
override_expiry_timestamp(default window: 24 hours from the veto). - An UPHELD veto blocks its proposer from creating new proposals for 30 days — on that DAO alone. The block is written on one transition only: the moment the override round closes against the proposer. It is not written when the DAO overrides the guardian (a proposal the electorate adopts costs its author nothing), and not when a proposal is rejected by an ordinary vote (there the cost is economic — the withheld rejection cost). The refusal names the date the block lifts, and the form reads it before a proposer approves any fee. The registers are per-DAO: a block on ONS does not touch any campaign DAO, and a block on one campaign does not touch another. It can be lifted early: on ONS by the guardian or by an approved proposal (
guardian_clear_veto_proposer_block, also reachable by the transition-phase deploying principals), on a SONS by an approved proposal (AdminMethod = ClearVetoProposerBlock) — a penalty the body that imposed it cannot withdraw would be permanent the first time it fired wrongly. - The override succeeds (→
Approved, executes) only if it clears the override bar; otherwise the veto stands (→Rejected, refund minusrejection_cost, paid exactly once).
Why the override needs a per-account anchor. The override exists to annul a take-over mounted with acquired or farmed verified identities — a stockpile of already-verified wallets aimed at a Critical proposal — and only a proof whose anchor survives a change of wallet defeats that. Forcing a live proof collapses a farm of N wallets onto the real accounts behind them, because one account produces one attestation per round. A per-wallet verification cannot do that: re-verifying with it would simply re-confirm each bought wallet one more time, leaving the bloc intact. That is why the override has never accepted DecideID and does not accept Google — Google is Standard and never reaches an override ballot at all.
The anchor is a Telegram account. The (DAO, proposal) round is folded into the fingerprint’s own preimage, so one Telegram account yields one ballot per round however many verified wallets its holder controls, and a person’s attestations in two rounds cannot be matched to each other. The check needs no outbound call, so the availability residual described below does not apply to it.
The legacy anchor. A World ID attestation is also accepted for as long as that legacy provider accepts new proofs. Its nullifier is deterministic in a per-
(DAO, proposal)action and the World account, so it also de-duplicates one human to one ballot; it needs one outbound call per attestation. See Telegram Verification.
This raises the cost of a bought-identity override capture and keeps it community-annullable; it does not eliminate a coalition of enough distinct real humans, and Telegram accounts are cheaper to accumulate than World IDs. See Governance & Capture Resistance.
Override bar — DAO-tunable, snapshotted per-proposal at creation: ≥ 80% adopt, ≥ 700,000 VP quorum, ≥ 50 unique voters, within a 24-hour window. All four are DAO-tunable via proposal and are surfaced live on the Governance Parameters panel (ONS and SONS). The canister floors these values — a parameter change can never set the window below 1 day or the quorum / minimum-voters to zero, so the override always stays reachable (no vote can turn the veto into a permanent kill). If the override does not pass within the window, the veto stands and the proposal is Rejected.
Display status — VetoOverrideExpired. Once the override window closes but before the timer finalizes, query getters surface a derived VetoOverrideExpired status. It is never persisted — the stored status stays Vetoed.
Guardian-removal hardening. A proposal to remove the guardian gets a hard 7-day minimum voting window, can never pass immediately, and the guardian may not vote on its own removal. Removal is vetoable-but-overridable like any Critical proposal: the guardian may veto it, but cannot vote in the override round and cannot block the community completing the removal at the override supermajority.
Immediate-approval gate. A vetoable proposal cannot pass immediately (before its deadline) unless the guardian has also cast a positive vote — preserving a real window for the guardian to veto instead of being raced by a fast quorum.
guardian_veto is an emergency defensive action and is deliberately the least-gated path in the canister, so an attacker cannot lock the guardian out of the brake. Its gate is the guardian authorization check, enforced under full consensus.
Payment-custody levers on the launcher backend (guardian ∪ ONS)
Section titled “Payment-custody levers on the launcher backend (guardian ∪ ONS)”Every paid launcher flow (token creation, LGE creation, imported-DAO creation, index
canister, cycles top-up, discounted cycles) debits the payer into a per-payment
subaccount and records a durable journal row before any work runs. When the work
fails after the debit, the flow refunds the payer from that subaccount; when the refund
itself cannot complete (a ledger reject, an ambiguous transfer, a stopped callee), the row
stays in the journal and housekeeping retries it. The payer can always trigger the retry
with reclaim_payment(memo), and read every row with get_my_payment_custody().
The backend also exposes a small set of recovery levers gated on ohshii_governance ∪
guardian ∪ the transition-phase deploying principals — the same “drive an idempotent recovery to a fixed
destination” class as the table above, and subject to the same hard wall:
| Endpoint | Purpose | Limits |
|---|---|---|
guardian_resolve_payment_custody(memo, action) | Re-drive one journal row that housekeeping could not settle: RetryRefund re-sends the refund to the payer, RetrySweep re-sends the success sweep to the fixed treasury / campaign destinations frozen at debit time, RetryCyclesLeg re-sends the pinned ICP→cycles conversion (the ledger deduplicates it, so it can only land once), MarkResolved(reason) records a reconciliation against the ledger for an ambiguous row. | Cannot name a recipient, change an amount, or move funds anywhere but the payer or the destinations the flow itself would have used. A row whose subaccount is still owed to a campaign, a sponsor or the reserve funder refuses the refund action. Every action is idempotent and journaled. RetrySweep, like every other sweep, admits only a completed payment (or a cycles discount already delivered) and otherwise answers PAYMENT_SWEEP_STATE_REFUSED and moves nothing; RetryRefund on an empty payment subaccount records the row as refunded only when the row itself explains why it is empty (the payment was below one ledger fee, its own cycles leg spent it, or this row already sent a refund whose reply was lost, recorded in the refund_attempt_e8s field before the transfer), and otherwise leaves it RefundFailed with PAYMENT_REFUND_SUBACCOUNT_EMPTY_UNEXPLAINED. |
guardian_resolve_discount_legacy(principal, action) | Settle a discounted-cycles purchase made before the custody journal existed, whose ICP is still parked in the old caller-keyed subaccount: RefundToCaller or ForwardToFunder. | Two fixed destinations only; a stranger cannot trigger it and a non-guardian call is refused before any transfer. |
guardian_list_payment_custody(state?, limit) (query) | List journal rows by state for reconciliation. | Read-only. |
None of these can alter a vote, a proposal, a DAO-voted parameter, or a campaign’s outcome; they exist so that a payer’s stuck refund or a stuck treasury sweep can be completed without a canister upgrade.
What the Guardian CANNOT do
Section titled “What the Guardian CANNOT do”Three limits hold across the whole toolkit above, not just the rebuild surface, and they are the ones to check first:
- It cannot name a recipient. No Guardian lever accepts a destination. Every value-moving recovery settles to a fixed destination the caller cannot influence.
- It cannot change a tally. No Guardian action writes votes, voting power or verification records. Its only power over an outcome is the overridable veto, and the community can override it.
- It cannot spend at discretion. Every value-moving lever is an idempotent recovery, not a payment.
A SONS guardian additionally cannot rebuild indices: index rebuild is an ONS-only capability and is not part of the per-campaign Guardian toolkit.
What the Guardian CANNOT REBUILD (HARD WALL)
Section titled “What the Guardian CANNOT REBUILD (HARD WALL)”The GovRebuildableIndex enum is the structural allowlist. Any index NOT on that enum is permanently outside Guardian reach. Explicitly hard-walled (cross-referenced in code as HARD_WALLED_LABELS):
USED_NULLIFIERS,USED_DECIDEID_PRESENTATIONS,USED_TELEGRAM_NULLIFIERSandUSED_GOOGLE_EMAIL_HASHES— the four anti-replay sets, one per provider. Wiping any of them lets one account verify more than one wallet.PROPOSALSandEXECUTED_PROPOSALS— primary governance state. Not derivable.VOTING_PARAMS_CONFIG— DAO-voted parameters. Reset would lose every value the community voted in.GUARDIAN_PRINCIPAL— the auth root of the Guardian role itself.VERIFIED_USERS,DECIDEID_VERIFIED_USERS,TELEGRAM_VERIFIED_USERSandGOOGLE_VERIFIED_USERS— the four primary verification records, one per provider. The derivable unionANY_METHOD_VERIFIED_USERSCAN be rebuilt from them — the rebuild now walks all four in sequence — but they themselves cannot.FOLLOWS— primary delegation state.PENDING_DELEGATIONS/PENDING_DELEGATIONS_INDEX— in-flight delegation queue applied by the cascade timer.
Operational safety primitives
Section titled “Operational safety primitives”- Write-freeze: while a cascade is active, every mutator of the targeted index returns
Err("maintenance_in_progress: index <X> rebuild active, ETA ~Ns"). The frontend surfaces this as a maintenance banner (no Unhandled Promise Rejection). - Failure circuit-breaker: a stable
consecutive_failurescounter on the rebuild state, bumped before each chunk and reset on success, auto-aborts the cascade after 5 trap-faulted ticks. - Wall-clock watchdog: cascade auto-aborts after 4 hours regardless of progress.
Full engineering reference: DISASTER_RECOVERY.md.
Voting Hotkey (ONS + SONS)
Section titled “Voting Hotkey (ONS + SONS)”A voting hotkey lets a native (stake-holding) principal authorise a second principal it controls — the hotkey — to cast votes that resolve to, and count exactly as, the native’s: same Voting Power, same ballot key, same tally effect, same delegation-cascade effect. It is the NNS-neuron-hotkey analog and exists so a holder can vote from any dApp / interface / signer without exposing the native key, and so per-campaign (SONS) DAOs become frontend-independent.
Hotkeys are voting-only — a hotkey gets no other power as the native (no
follow/propose/config/attest, no lock/treasury/fund methods). The registry is
per governance canister: a hotkey registered on ONS is not a hotkey on any SONS,
and each SONS instance keeps its own independent registry (mirroring the delegation
model). Up to MAX_HOTKEYS_PER_NATIVE = 3 hotkeys per native, per DAO.
Method inventory (identical surface on ONS and SONS)
Section titled “Method inventory (identical surface on ONS and SONS)”Both ohshii_governance (ONS) and every sons_governance (SONS) expose the same
8-method surface. register_hotkey / accept_hotkey are Shield-wrapped, so
pow_solution is the last argument (opt blob); the sync de-escalation methods
are not Shield-wrapped.
| Method | Signature | Caller | What it does |
|---|---|---|---|
register_hotkey | (principal, opt blob) -> (variant { Ok; Err : text }) | the native | Step 1 of the consent handshake. Runs the structural / mutual-exclusion / native-verification / native-VP gates, then writes a pending request (does not activate resolution). |
accept_hotkey | (principal, opt blob) -> (variant { Ok; Err : text }) | the hotkey | Step 2. msg_caller == hotkey proves control; re-checks structure + the per-native cap, then atomically finalizes the registry so the hotkey resolves to the native. |
cancel_hotkey_request | (principal) -> (variant { Ok; Err : text }) | native (or the named principal declining) | Point-delete a pending request. Sync, not Shield-wrapped. |
remove_hotkey | (principal) -> (variant { Ok; Err : text }) | native removing, or hotkey resigning | Sync de-escalation; authorization keys on the reverse authority map. |
get_my_hotkeys | () -> (vec principal) query | any | The caller’s bound hotkeys (forward-index prefix scan). |
get_hotkey_owner | (principal) -> (opt principal) query | any | Reverse map: hotkey → native (its owner), if any. |
is_hotkey | (principal) -> (bool) query | any | Whether the principal is a registered hotkey. |
get_pending_hotkey_request | (principal) -> (opt principal) query | any | Pending map: hotkey → the native awaiting its acceptance (drives the accept UI). |
The wire error type is text on both canisters; internally SONS returns a
domain error that the frontend unwraps to the underlying message.
Resolution model
Section titled “Resolution model”Inside vote(), immediately after the Shield early-return, the raw msg_caller
is resolved through the reverse map: effective_voter = NATIVE_BY_HOTKEY.get(caller).unwrap_or(caller). This is a single synchronous
stable-map read (no inter-canister call). The raw
wire caller is kept for the Shield/PoW binding, provenance, and the guardian check;
every other site in vote() (dedup, placeholder, VP lookup, Vote.voter, verification
subject, cascade root) keys on the resolved native. Consequences:
- Voting Power is the native’s quadratic VP (from the native’s locks), capped at
max_voting_power_per_user. A hotkey’s own locks are dormant while registered — resolution is total and one-directional, so a hotkey can never vote its own stake or double-count. To use that stake, the principal must be removed as a hotkey and vote directly (which then requires its own verification). Counting a hotkey’s own locks toward the native’s VP under the native’s verification is not currently available; the DAO could vote to add such a feature in the future. - Verification subject = the native: the native’s verification satisfies the gate, so the hotkey never verifies to cast the native’s ballot. Registration additionally requires the native to be verified while a gate is active, at Standard — a hotkey inherits the native’s rights and the per-proposal tier is re-derived from the resolved native at vote time, so registration does not need to pre-empt it. On an override round the fresh attestation keys on the native: a hotkey may cast the override ballot only if the native personally attested, because the proof is bound to the attesting caller and cannot be presented by anyone else.
Group cap-invariant. Because resolution runs before dedup / placeholder / VP
on every path, the group {native} ∪ {all its hotkeys} collapses onto one ballot
per proposal per round, keyed on the native and capped at the native’s VP — in both the
round-1 map and the veto-override map. Owner + hotkey, or several hotkeys, on the same
proposal: the first ballot wins and the rest are deduped (Already voted). This is an
invariant of “resolution-first + native-keyed dedup + native VP + dormant hotkey VP”,
not a separate gate.
Two-step consent handshake
Section titled “Two-step consent handshake”Registration is a mandatory two-signature handshake (native proposes → hotkey
accepts), so a principal can never be bound as a hotkey without both parties
consenting. This closes the no-consent hijack (binding an honest voter as your hotkey to
redirect/suppress their ballots) and the symmetric VP-theft (binding a whale as your
hotkey to borrow its VP). register_hotkey writes only a pending row; accept_hotkey
(signed by the hotkey) finalizes the authority map. Pending rows carry a
requested_at_ns timestamp and are stale after PENDING_HOTKEY_TTL_NS = 24h
(rejected by accept_hotkey, GC’d by the post-upgrade reconciliation sweep). Removal /
resignation / cancellation are all sync, cheap, and never harder than registration.
Delegation exclusion
Section titled “Delegation exclusion”A registered hotkey may not participate in Liquid Democracy, and a delegation participant
may not become a hotkey. This mutual exclusion is enforced atomically at the mutators
— register_follow (follower side; covers follow() and the SONS
execute_batch_set_follow proposal executor) and set_accepts_followers_flag (delegate
side) — with a friendly pre-flight in verify_follow_invariants and, on SONS, in the
BatchSetFollow Pass-1 validation (rejecting a registered hotkey as follower or
followee before any apply). A rejected follow/accept returns
FollowError::PrincipalIsRegisteredHotkey (reason code principal_is_registered_hotkey).
The native is unrestricted — it may follow / delegate as usual, and a hotkey-cast vote
still triggers the native’s delegation cascade (cascade root = resolved native). See
LIQUID_DEMOCRACY.md.
Guardian & veto-override interaction
Section titled “Guardian & veto-override interaction”- Guardian removal-vote ban — both-check. The rule that the guardian cannot vote on
its own removal is checked against both the resolved native and the raw wire
caller (
is_guardian(native) || is_guardian(hotkey_caller)on ONS; the equivalent per-arm check on SONS), so a guardian can neither be secretly bound as a hotkey nor route its own removal ballot through a consensual one. - Override round. An override-round vote requires a fresh personhood
re-attestation that is cryptographically bound to the attesting caller — a hotkey can
never present the native’s proof. With resolution,
vote()’s override gate checks the attestation marker for the native: a hotkey may cast the override ballot only if the native personally attested for that round (attest endpoints are unchanged and stay keyed on their ownmsg_caller). On SONS the override-attest gate is unconditional even whenrequires_verification == false.
Provenance & storage
Section titled “Provenance & storage”Every ballot carries an appended Vote.cast_via_hotkey : opt principal, set to the
hotkey when the vote was cast through one (None for a direct native vote and for all
pre-feature ballots, which decode cleanly as None). A HotkeyVoteCast event is emitted
alongside the usual VoteCast (which keeps voter = native), and HotkeyRegistered /
HotkeyRemoved events record registry changes. The registry is held in three stable maps,
with the same shape on both canisters:
| Map | Purpose |
|---|---|
NATIVE_BY_HOTKEY | Reverse map (hotkey → native): the uniqueness + resolution authority. |
HOTKEYS_BY_NATIVE | Forward index (native, hotkey): listing, removal, per-native cap. |
PENDING_HOTKEY | Two-step-consent pending requests ({ native, requested_at_ns }), keyed on the hotkey; TTL-bounded. |
They are primary state, not derivable indexes, so the guardian rebuild cannot touch them.
See also
Section titled “See also”- DISASTER_RECOVERY.md — Guardian rebuild endpoints, derivable indices, write-freeze contract.
- 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.
- LP_LOCK_LIQUIDITY.md — LP Lock mechanics: ICPSwap V3 liquidity provision, sqrtPriceX96, token ratio calculation, deposit-to-lock flow.
- LOGGING_SYSTEM.md — Stable memory event logging for both ONS and SONS governance canisters, event types, and monitoring.
- GOOGLE_VERIFICATION.md — Google verification (Standard): the Internet-Identity attribute-bundle mechanism, the Internet-Identity-session-only limitation, one Google account per wallet, the address-normalisation rules, privacy model, governance controls and escape hatches.
- TELEGRAM_VERIFICATION.md — Telegram verification (Strong): the flow, the privacy model, and the veto-override attestation it now carries.
- WORLD_ID_VERIFICATION.md — World ID voter verification (Strong, legacy): flow, privacy model, configuration states including the
accepts_new_proofsretirement flag, threshold-ECDSA RP signatures, rate limits and governance controls. - DECIDEID_VERIFICATION.md — DecideID voter verification (Strong, legacy): configuration states including the
accepts_new_proofsretirement flag, the VC and OIDC paths, privacy model, rate limits and pause levers. - LIQUID_DEMOCRACY.md — Vote delegation on ONS: follow / unfollow / accept-followers toggle / dismiss-follower flows, the snapshot-based cascade model, four-layer rate limits, the cross-canister batch VP fetch contract, event log reference, and FAQ.
- 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.