Importing a DAO
Overview
Section titled “Overview”An imported DAO is how you give on-chain governance to a token that already exists, without running a Liquidity Generation Event (LGE). Instead of bootstrapping a token and its treasury through a fundraise, you wrap an existing ICRC ledger in its own dedicated governance canister (a SONS governance canister), and holders begin governing with that token immediately.
There are two supported token origins:
- Externally launched / deployed tokens — any standard ICRC-1 + ICRC-2 ledger that meets the compatibility gate (see below).
- OhShii standalone-minter tokens — tokens minted with OhShii’s standalone minter, whose ledger runs the OhShii minter ledger build.
Both origins flow through the same import endpoints. The distinguishing characteristic of an imported DAO versus a native LGE is that no fundraise happens: there is no sale supply, no contribution accounting, and no liquidity bootstrap. The governance canister is created, wired to the existing ledger, and handed to the community.
Once imported, the DAO runs the full, byte-identical SONS governance WASM — the same proposal set and quadratic-voting model as any native mini-DAO. Crucially, a SONS DAO can be made a controller of arbitrary dapp canisters, so the community can upgrade those dapps by vote. That capability is the headline reason to import a token into a DAO.
The two-phase creation flow
Section titled “The two-phase creation flow”Importing is a two-phase, ICRC-2 pull-payment flow. Both phases are protected by the in-canister firewall and reject the anonymous principal.
Phase 1 — prepare_imported_dao_creation (quote)
Section titled “Phase 1 — prepare_imported_dao_creation (quote)”prepare_imported_dao_creation(init: ImportedDaoInit, pow_solution: opt blob) -> Result<PrepareResult, String>This phase validates, sanitizes, prices, and freezes the import request. It is the KYC-gated phase: verification (DecideID or World ID) is required here, with strict per-caller and global rate limits. Concretely it:
- requires the global creation toggle to be enabled (same gate as native LGE creation);
- enforces a cycle floor and a local rate limit;
- parses and validates
ledger_canister_id,index_canister_id(optional),pool_id(required),description(required, non-empty), and the optional social URLs (sanitized); - requires
creator_principalto equal the caller; - runs the full ledger compatibility check (see next section), which overwrites
token_name/token_symbolfrom the ledger’s own metadata; - computes the price and writes a pending-payment record keyed by a generated
memo, with a 5-minute TTL.
PrepareResult returns the memo, the total price_e8s, the recipient account (the backend canister’s default-subaccount account identifier, hex-encoded), the dynamic cycles_cost_e8s, the fixed platform_fee_e8s, the current cycles_per_icp rate, and the required_cycles (1T) for the single governance canister.
The pending-payment record lives in a heap map, not stable storage — pending quotes do not survive a canister upgrade. Given the 5-minute TTL, this is intentional.
Phase 2 — create_imported_dao_with_icrc2_payment (pay + deploy)
Section titled “Phase 2 — create_imported_dao_with_icrc2_payment (pay + deploy)”create_imported_dao_with_icrc2_payment(args: ImportedDaoWithIcrc2PaymentArgs, pow_solution: opt blob) -> Result<ImportedDaoCreationResult, ImportedDaoCreationError>ImportedDaoWithIcrc2PaymentArgs carries { payment_memo, amount_e8s, imported_init }. This phase does not require fresh verification (KYC was completed at prepare); it enforces the tightest rate limits (1/hr per caller, 2/hr global). It:
- looks up the pending payment by
memoand requires the caller to be the original payer; - performs an anti-TOCTOU check: the
ledger_canister_idinargs.imported_initmust match the frozen prepared payload. The rest ofimported_initinargsis otherwise ignored — deployment rebuilds entirely from the frozen prepared copy; - requires
amount_e8s >= price_e8s, re-quotes the live cycles cost (rejecting onCMC_DRIFTif cost has risen beyond the embedded buffer, and failing closed asCMC_UNAVAILABLEif the rate cannot be obtained — before any funds move), and checks the quote has not expired; - marks the payment
Processed, verifies the ICRC-2 allowance, then pulls payment viaicrc2_transfer_fromfrom the caller to the backend canister; - allocates the real
dao-…campaign id and its subaccount, converts the cycles portion of the payment to cycles via the CMC, and deploys the governance canister.
During deployment the backend re-runs ledger sanitization and re-overwrites token_name / token_symbol from ledger metadata, re-checks the ledger-not-already-used gate, then creates and installs the SONS governance canister. On success the campaign transitions to Completed and ImportedDaoCreationResult { campaign_id, governance_canister_id } is returned. Failures are classified (e.g. CYCLES_LEDGER_INSUFFICIENT_FUNDS, CREATION_FAILED) and logged.
Per-payer progress logs
Section titled “Per-payer progress logs”get_imported_dao_creation_logs(payment_memo: nat64) -> vec IcoLogEntry (query)This query returns the per-campaign creation log entries for the given memo. Authorization is enforced in-body: it returns an empty vector unless the caller is the original payer of that memo. The frontend polls it during deployment to surface progress to the importer. (It reuses the same per-campaign log heap used by native LGE campaigns.)
ImportedDaoInit: carried vs overwritten
Section titled “ImportedDaoInit: carried vs overwritten”| Field | Origin / treatment |
|---|---|
ledger_canister_id | Caller-provided, validated, frozen at prepare (authoritative) |
index_canister_id (opt) | Caller-provided, validated |
pool_id | Caller-provided, required, parsed as a principal (see compatibility note) |
description | Caller-provided, required non-empty |
creator_principal | Caller-provided but forced to equal the caller |
website / telegram / openchat / twitter (opt) | Caller-provided, sanitized |
subnet_selection (opt) | Caller-provided |
post_lge_guardian (opt) | Caller-provided; defaults to the creator if absent |
token_name / token_symbol | Ignored from the caller — overwritten from the ledger’s icrc1_metadata at both prepare and deploy |
The entire prepared imported_init is frozen at phase 1; phase 2 reads the frozen copy. The caller’s args.imported_init is consulted at phase 2 only for the ledger-id TOCTOU equality check.
Ledger compatibility
Section titled “Ledger compatibility”A ledger may only be imported if it passes every check below. The checks run during prepare_imported_dao_creation and are re-run during deployment, so a ledger that mutates between the two phases is caught.
| Check | Requirement |
|---|---|
| WASM module hash | The ledger’s installed module hash must be on the approved allowlist (see below) |
| Decimals | icrc1:decimals must equal 8 (anything else, including missing, is rejected) |
| Standards | icrc1_supported_standards must advertise both ICRC-1 and ICRC-2 (case-insensitive match) |
| Name / symbol | Derived from icrc1:name / icrc1:symbol metadata, sanitized (name ≤ 50, symbol ≤ 10); rejected if either trims empty |
| Ledger not already used | The ledger must not already be associated with an existing campaign (storage-side dedup index) |
| Existing ICPSwap pool | pool_id must be a valid principal and is required (see note) |
The WASM module-hash allowlist gate
Section titled “The WASM module-hash allowlist gate”To prevent wrapping a malicious or non-standard look-alike ledger, importing is allowed only if the token’s ledger runs one of the approved ICRC ledger builds, identified by its WASM module hash. The allowlist currently holds two approved builds:
# Approved imported-ledger WASM module hashes (sha256 of the installed,# gzip-compressed module — i.e. the value the IC reports as module_hash)
# OhShii standalone-minter ledger build# (gzip digest of the bundled ic-icrc1-ledger.wasm.gz)f042ac293df25134660990dddd5d4c0525687fa3e1e69868bb42b2a9fca2d749
# Standard external ICRC-1 ledger buildb60487873e58beda2fe7a02cfdf3c741bc48a30d3485aeec8a69a991d7807d4bHow the hash is read. The backend reads the ledger’s installed module hash via the management canister’s canister_info method. Per the IC interface specification, canister_info is callable canister-to-canister by any canister on any other canister (it cannot be called by an ingress message, but the backend is a canister, so it qualifies) and it returns the target’s current module_hash — no controller relationship with the target is required. This is what makes the gate work for externally deployed ledgers the backend does not control, as well as for OhShii standalone-minter ledgers (which additionally carry NNS Root as a controller). The returned module_hash is compared (hex) against the allowlist; a ledger whose hash is not on the allowlist is rejected.
This gate sits alongside the decimals, standards, name/symbol, and dedup checks. Together they ensure that the wrapped ledger is a known-good ICRC build (not a look-alike whose methods could behave maliciously), uses the expected 8-decimal precision, and exposes the ICRC-1/ICRC-2 surface the governance canister and the wider tooling assume.
The approved ledger hashes are maintained as part of the project’s reproducible-build / WASM hash-verification process. Treat that process as the canonical source when adding or rotating an approved build; the allowlist is the on-chain mirror of those verified hashes. The hash of a build is the SHA-256 of the gzip-compressed module exactly as installed — the same value the IC returns from
canister_info/canister_statusasmodule_hash.
Note on the required ICPSwap pool
Section titled “Note on the required ICPSwap pool”pool_id is mandatory and is parsed as a principal, then persisted on the campaign and pushed to the governance canister. It is not otherwise validated: the backend does not currently confirm that the pool actually exists, that it is a real ICPSwap pool, or that it matches the ledger. The push to the governance canister is best-effort — a failure logs a warning and continues. (This contrasts with native LGE finalization, which queries the ICPSwap factory directly.)
Cost & gating
Section titled “Cost & gating”The total price for importing a DAO is:
price_e8s = PLATFORM_FEE_DAO_E8S (20 ICP, fixed) + dynamic cycles cost for one governance canister- Platform fee — 20 ICP, fixed.
PLATFORM_FEE_DAO_E8S = 2_000_000_000e8s (20 ICP). This is a hardcoded constant, not a DAO-votable parameter and not read from the LGE fee configuration (IcoFeeConfighas no imported-DAO fee field). The frontend mirrors this constant so the cost preview matches the backend. - Cycles cost — dynamic, one governance canister. Importing provisions exactly one canister (the governance canister), budgeted at 1T cycles (
1_000_000_000_000). The ICP equivalent is priced through the CMC rate at prepare time and re-quoted at create time with a drift buffer. At payment, the cycles portion (amount − 20 ICP) is converted to cycles via the CMC; the 20 ICP platform fee is retained.
Global enablement gate. Both phases honor the same global creation toggle used for native LGE creation (get_ico_creation_status().enabled). When disabled, prepare returns an error and create returns CREATION_DISABLED. Beyond that toggle, rejecting the anonymous principal, and the firewall rate limits / verification at prepare, any caller may import — there is no voting-power or tier gate on this path.
Controllers & autonomy
Section titled “Controllers & autonomy”An imported governance canister becomes autonomous immediately at creation; it is not held under launcher control pending a fundraise.
The controller set evolves as follows:
| Stage | Controllers |
|---|---|
At creation (create_empty_governance_canister_imported) | [backend (self), CycleOps, NNS Root], then governance-self appended → [backend, CycleOps, NNS Root, governance] |
| After backend-controller cleanup (during finalize) | [CycleOps, NNS Root, governance (self)] — autonomous |
Key points:
- ONS (the OHSHII ecosystem governance) is never a controller of an imported governance canister. There is no step that adds it, and therefore none that removes it.
- The backend installs and wires the canister, then removes itself as a controller as part of finalization, leaving the DAO self-governing.
- CycleOps and NNS Root remain as infrastructure controllers (cycle management and platform recovery), the same as for any SONS canister.
Contrast with a finalized native LGE. Native LGE governance canisters add ONS as a controller during creation, and native finalization removes both the backend and ONS as controllers. An imported DAO has no ONS-removal step because ONS was never added — the imported path reaches the same [CycleOps, NNS Root, governance] end-state by a shorter route, and reaches it immediately rather than after a fundraise completes.
The campaign record
Section titled “The campaign record”An imported DAO is represented internally by a SONS campaign row, but it is a degenerate / terminal one — all fundraising machinery is zeroed because there is no sale. The salient fields:
| Field | Value |
|---|---|
imported | Some(true) |
ico_type | Some(SONS) |
governance_canister_id | Some(<new governance canister>) |
ledger_canister_id | Some(<imported ledger>) |
pool_id | Some(<pool principal text>) |
index_canister_id | optional |
sale_supply, tokens_sold, target_icp_for_liquidity, total_contributed_e8s | 0 |
max_tokens_per_user, creator_allocation, liquidity_tokens | 0 |
admin_fee_percentage, sponsor_fee_percentage, network_fee_e8s | 0 |
contributions | empty |
start_timestamp / expiry_timestamp | both set to now (start == expiry) |
governance_vesting_enabled | Some(false) |
is_hidden | Some(false) |
bonding_curve_version, banner_feature_enabled, curve_input_net | None |
referral_deferred | None (never backfilled) |
relaunch_* | None |
campaign_state (initial) | Finalizing |
Finalize state machine
Section titled “Finalize state machine”The imported path reuses the native LGE finalization safety machine, even though there is no escrow to drain. The campaign is first written as Finalizing, then three sequential critical steps run, each of which rolls the campaign to Frozen on failure:
- Governance sync — failure →
GOVERNANCE_SYNC_FAILED, rolled toFrozen. - Backend-controller cleanup (remove backend from controllers) — failure →
GOVERNANCE_CONTROLLER_CLEANUP_FAILED, rolled toFrozen. - Commit completed — failure →
IMPORTED_DAO_COMPLETED_COMMIT_FAILED, rolled toFrozen.
Finalizing ──(sync ok → cleanup ok → commit ok)──▶ Completed │ └──(any step fails)──▶ FrozenThe commit step requires the current state to be Finalizing (it is idempotent if already Completed), and the rollback step flips the campaign to Frozen via storage. These are the same functions native LGE finalization uses; the imported path simply has no active escrow to reconcile.
Governing an imported DAO
Section titled “Governing an imported DAO”An imported DAO runs the full SONS governance WASM — there is no forked or reduced variant. The governance canister, not the backend, is the source of all governance behavior, and it is byte-identical to a native SONS mini-DAO. Holders propose and vote using the imported token under the standard quadratic-voting model.
Dapp upgrades by vote
Section titled “Dapp upgrades by vote”The differentiating capability is that the DAO can become a controller of external dapp canisters and upgrade them by vote. The relevant proposal categories are all CRITICAL-tier (7-day window, high voting-power threshold, minimum voter count, supermajority at close, guardian-vetoable):
| Proposal category | What it does |
|---|---|
AddController / RemoveController (admin methods) | Manage controllership of a target canister (governance must already be a controller of the target to modify its controller set) |
UpgradeCanister | Upgrade a single child canister (ledger / index / archive / other); must not target governance itself |
UpgradeGovernanceCanister | Self-upgrade of the governance canister only |
UpgradeAssetCanister | Upgrade a frontend asset canister via batch commit |
UpgradeCanisterBatch | Ordered ledger-suite upgrade (Index → Ledger → Archives), bounded step count |
UpgradeDappBatch | Ordered multi-canister dapp upgrade — the maximum blast radius; steps execute verbatim in proposer order, a governance self-step is allowed only as the final step |
WASM payloads support chunked installs for large modules (uploaded via a prepare/upload-chunk session, with the hash re-verified at execution time to defend against TOCTOU).
All-or-nothing controller pre-flight. Before any code is installed, the executor verifies via canister_status that the governance canister is actually a controller of every target in the operation. For batch upgrades this is a hard pre-flight loop over all steps (including asset-commit targets in a dapp batch). If governance is not a controller of even one target, the entire operation aborts before any install — there are no partial upgrades. This makes the on-chain controller relationship load-bearing: the DAO can only upgrade a dapp it has been handed (or has voted itself onto via AddController, which itself requires governance to already be a controller of that target).
Guardian model
Section titled “Guardian model”Imported DAOs use the same two-axis lifecycle guardian as native SONS:
- Pre-autonomy ONS guardian (
pre_lge_ons_guardian) — fetched fail-closed from ONS at install time. If ONS has no guardian, installation aborts (preventing an orphaned governance canister). This is neutrality-locked: it cannot be changed through normal config edits, only via SONS init or an ONS guardian sync/recovery path. - Post-autonomy local guardian (
post_lge_guardian) — chosen by the importer (defaulting to the creator). Once the DAO is autonomous, this local guardian becomes the effective emergency authority.
An effective-guardian switch inside the SONS WASM selects which guardian is active based on lifecycle state. The guardian rejects the anonymous principal and gates emergency actions (e.g. veto). After autonomy, the guardian can be rotated by DAO vote via SetGuardian, which validates the choice (rejecting anonymous, infrastructure, and self principals; None removes the guardian).
A note on source-file references
Section titled “A note on source-file references”Method names, struct names, proposal categories, Campaign fields, and the two approved WASM hashes above are accurate against the current implementation, but any specific file or line references that may appear in related internal notes are illustrative and drift over time. Treat the public method names, the canonical .did interface, and the project’s WASM hash-verification process (for the approved ledger hashes) as the authoritative sources; verify line-level anchors against the current code rather than relying on cited line numbers.