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 (required), 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 (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 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, required, 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 poolpool_id must be a valid principal and is required (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 (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 build
b60487873e58beda2fe7a02cfdf3c741bc48a30d3485aeec8a69a991d7807d4b

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 gzip-compressed module exactly as installed — the same value the IC returns from canister_info / canister_status as module_hash.

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

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

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

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.