Skip to content

Importing a DAO

Current state: Imported DAO creation is currently disabled. Both phases below refuse while the global creation toggle (get_ico_creation_status().enabled, the same toggle that gates LGE creation) is off. Imported DAOs that already exist are unaffected. The rest of this page describes how the flow works when the toggle is on.

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:

  1. Externally launched / deployed tokens — any standard ICRC-1 + ICRC-2 ledger that meets the compatibility gate (see below).
  2. 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.

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 verification-gated phase: a verified identity is required here — any accepted method qualifies, including Google — 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 (optional — empty/absent normalizes to None), description (required, non-empty), and the optional social URLs (sanitized);
  • requires creator_principal to equal the caller;
  • applies the imported-DAO id rules to the declared ledger, index and pool (see Which canisters can be imported), before any ledger read, memo or pending-payment record, and refuses with IMPORTED_ID_IN_FLIGHT: a canister that another import still in progress has declared;
  • runs the full ledger compatibility check (see next section), which overwrites token_name / token_symbol from 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 (3T) 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 (identity verification was checked at prepare); it enforces its own, stricter per-caller and global rate limits. It:

  • looks up the pending payment by memo and requires the caller to be the original payer;
  • performs an anti-TOCTOU check: the ledger_canister_id in args.imported_init must match the frozen prepared payload. The rest of imported_init in args is otherwise ignored — deployment rebuilds entirely from the frozen prepared copy;
  • requires amount_e8s >= price_e8s, re-quotes the live cycles cost (rejecting on CMC_DRIFT if cost has risen beyond the embedded buffer, and failing closed as CMC_UNAVAILABLE if the rate cannot be obtained — before any funds move), and checks the quote has not expired;
  • reserves the declared ledger, index and pool for the length of the call: if another import in progress already holds any of them, the call is refused with IMPORTED_ID_IN_FLIGHT before the payment is pulled, and nothing is debited. The reservation is released when the call ends, whatever its outcome;
  • applies the imported-DAO id rules again to the frozen quote, before the payment is pulled (a refusal returns INVALID_INIT with the rule’s text, and nothing is debited);
  • marks the payment Processed, verifies the ICRC-2 allowance, then pulls payment via icrc2_transfer_from from the caller to the backend canister;
  • allocates the real dao-… campaign id and its subaccount, then applies the imported-DAO id rules a third time, before any part of the payment is converted to cycles: a refusal here refunds the payment less one ledger fee and is returned as CREATION_FAILED;
  • 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.

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

FieldOrigin / treatment
ledger_canister_idCaller-provided, validated, frozen at prepare (authoritative)
index_canister_id (opt)Caller-provided, validated
pool_idCaller-provided, optional (opt text); when present it is parsed as a principal (see compatibility note)
descriptionCaller-provided, required non-empty
creator_principalCaller-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_symbolIgnored 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.

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.

CheckRequirement
WASM module hashThe ledger’s installed module hash must be on the approved allowlist (see below)
Decimalsicrc1:decimals must equal 8 (anything else, including missing, is rejected)
Standardsicrc1_supported_standards must advertise both ICRC-1 and ICRC-2 (case-insensitive match)
Name / symbolDerived from icrc1:name / icrc1:symbol metadata, sanitized (name ≤ 50, symbol ≤ 10); rejected if either trims empty
Ledger not already usedThe ledger must not already be associated with an existing campaign (storage-side dedup index)
Existing ICPSwap poolOptional; when pool_id is provided it must be a valid principal (see note)
Index canisterOptional; when index_canister_id is provided it must not be the anonymous principal, the management canister or an OhShii infrastructure canister (the launcher and OhShii Locker canisters, ohshii_governance, and the OHSHII ledger and index). Refused with IMPORTED_INDEX_REFUSED at prepare, before any payment exists; checked again on the stored quote before the payment is pulled, and once more at deployment

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 (module_hash = sha256 of the
# EXACT module blob passed to install_code — gzipped or raw)
# Standard external ICRC-1 ledger build (dfinity/ic@f58b13bc, Mar 2024)
b60487873e58beda2fe7a02cfdf3c741bc48a30d3485aeec8a69a991d7807d4b
# ledger-suite-icrc-2025-01-21 build, RAW (uncompressed) module bytes
0299be32447ce9880d3a48fa47346532a812685c4e76c96a3cc2e3ea002c6020
# ledger-suite-icrc-2025-01-21 build, GZIPPED as embedded in the backend —
# the module_hash reported by every OhShii standalone-minter ledger
# (create_icrc1_token installs the .wasm.gz bytes verbatim)
f042ac293df25134660990dddd5d4c0525687fa3e1e69868bb42b2a9fca2d749

Three hex entries, two approved builds: the 2025-01-21 build is listed under both its raw and gzipped encodings because module_hash is the SHA-256 of whichever blob was actually installed.

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

Every canister the import declares — the ledger, the optional index and the optional pool — goes through one set of rules, at the three points listed above (the quote, before the payment is pulled, and after it but before any of it is converted to cycles):

RuleRefused
Not an OhShii canisteranonymous, the management canister, and every OhShii infrastructure canister (the canisters ohshii_governance manages, plus the OHSHII index), in any of the three roles
One role eachthe same canister declared in two roles (for example as both the ledger and the index)
Not another campaign’s canisterthe ledger, index or governance canister of any campaign already on the launcher (in any state), and any pool already linked to an imported DAO or held in the launcher’s pool registry, in any of the three roles
The index and the pool belong to the declared ledgeran index whose ledger_id is not the declared ledger, and a pool whose metadata lists the declared ledger as neither of its two tokens
A launcher token is imported by its creatora standalone token created on the launcher can be imported only by the principal that created it, its ledger only as the ledger and its index only as the index, and its index only together with its own ledger

A token the launcher has never seen (an external ledger) passes these rules and goes on to the compatibility checks above. Which text a refusal carries depends on the role and the rule:

  • Ledger, first rule: the ledger check that runs at the quote and again at deployment refuses first, with its own messages — Ledger canister cannot be the anonymous principal, Ledger canister cannot be the management canister, or OhShii infrastructure canisters cannot be imported as a DAO (<id>). An infrastructure canister that this check does not list reaches the id rules and is refused with IMPORTED_ID_REFUSED:.
  • Index, first rule: the index check (the one in the validation table above) refuses first, with IMPORTED_INDEX_REFUSED:.
  • Every other refusal carries IMPORTED_ID_REFUSED: followed by the canister, the role and, where one exists, the campaign or token that already holds it.

When the campaign or pool registries cannot be read completely, or the declared index or pool does not answer the read that says which ledger it serves, the import is refused with IMPORTED_ID_UNVERIFIED: rather than admitted unchecked; trying again later is safe. The index and the pool are both optional, so when the declared one is not a standard ICRC index (one that reports which ledger it indexes) or a pool that answers that read, the import can be started again with that field left empty; the import form says so for each of the two.

While one import is in progress, a second import that declares any of the same canisters (in any role) is refused with IMPORTED_ID_IN_FLIGHT:, at the quote and before the payment is pulled, so nothing is debited. Trying again once the first import has finished is safe.

The approved ledger hashes correspond to published ICRC ledger builds; the allowlist is compiled into the backend, so adding or rotating an approved build is a backend upgrade, which is itself a governance proposal. The hash of a build is the SHA-256 of the module blob exactly as installed (gzipped or raw) — the same value the IC returns from canister_info / canister_status as module_hash.

pool_id is optional: a DAO can be imported before its pool exists, and the community can create or link a pool later through governance. When provided, the value is parsed as a principal, persisted on the campaign, and pushed to the governance canister (set_pool_canister_id); when absent, the campaign stores pool_id = None and the governance pool link is skipped (logged as an info entry in the creation feed). Beyond the id rules (not an OhShii canister, not a pool another campaign already holds, not the ledger or index, and a metadata answer that lists the declared ledger as one of its two tokens), a provided pool is not validated: the backend does not confirm through the ICPSwap factory that it is a genuine ICPSwap pool. 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.)

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_000 e8s (20 ICP). This is a hardcoded constant, not a DAO-votable parameter and not read from the LGE fee configuration (IcoFeeConfig has 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 3T cycles (3_000_000_000_000, GOVERNANCE_CANISTER_CYCLES; ledger and index canisters are funded with 2T). A governance canister starts above the cycles balance it needs to accept votes and other writes (1.5T) after its creation and install costs. 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; a fresh install starts with it on; it is changed through admin_set_ico_creation_status by the authorized caller, e.g. an approved ONS proposal). It is currently off. 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.

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:

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

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:

FieldValue
importedSome(true)
ico_typeSome(SONS)
governance_canister_idSome(<new governance canister>)
ledger_canister_idSome(<imported ledger>)
pool_idSome(<pool principal text>) when provided, else None
index_canister_idoptional
sale_supply, tokens_sold, target_icp_for_liquidity, total_contributed_e8s0
max_tokens_per_user, creator_allocation, liquidity_tokens0
admin_fee_percentage, sponsor_fee_percentage, network_fee_e8s0
contributionsempty
start_timestamp / expiry_timestampboth set to now (start == expiry)
governance_vesting_enabledSome(false)
is_hiddenSome(false)
bonding_curve_version, banner_feature_enabled, curve_input_netNone
referral_deferredNone (never backfilled)
relaunch_*None
campaign_state (initial)Finalizing

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:

  1. Governance sync — failure → GOVERNANCE_SYNC_FAILED, rolled to Frozen.
  2. Backend-controller cleanup (remove backend from controllers) — failure → GOVERNANCE_CONTROLLER_CLEANUP_FAILED, rolled to Frozen.
  3. Commit completed — failure → IMPORTED_DAO_COMPLETED_COMMIT_FAILED, rolled to Frozen.
Finalizing ──(sync ok → cleanup ok → commit ok)──▶ Completed
│
└──(any step fails)──▶ Frozen

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

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.

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 categoryWhat it does
AddController / RemoveController (AdminMethods)Manage controllership of a target canister (governance must already be a controller of the target to modify its controller set)
UpgradeCanisterUpgrade a single child canister (ledger / index / archive / other); must not target governance itself
UpgradeGovernanceCanisterSelf-upgrade of the governance canister only
UpgradeAssetCanisterUpgrade a frontend asset canister via batch commit
UpgradeCanisterBatchOrdered ledger-suite upgrade (Index → Ledger → Archives), bounded step count
UpgradeDappBatchOrdered 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).

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) — the importer can choose the principal ID of this guardian at import (any principal is accepted, including a canister ID; the anonymous principal and OhShii infrastructure canisters are rejected). If left blank it defaults to the creator’s own principal. Once the DAO is autonomous, this local guardian becomes the effective emergency authority, and the DAO can replace it at any time by an on-chain SetGuardian vote.

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