Skip to content

Importing a DAO

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 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 (optional — empty/absent normalizes to None), description (required, non-empty), and the optional social URLs (sanitized);
  • requires creator_principal to equal the caller;
  • 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 (2T) 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 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;
  • 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, 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)

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_hashno 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 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). A provided pool 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.)

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 2T cycles (2_000_000_000_000 — the platform-wide NEW_CANISTER_CYCLES budget applied to every canister it deploys). 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.

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 (admin methods)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).

Method names, struct names, proposal categories, Campaign fields, and the approved WASM hashes above (two builds, three hex encodings) 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.