# Internet Computer Skills: full corpus > Agent-readable skill files for building on the Internet Computer. > Publisher: DFINITY Foundation (https://dfinity.org) > License: Apache-2.0 (https://www.apache.org/licenses/LICENSE-2.0) > Source: https://github.com/dfinity/icskills > Generated: 2026-07-10T15:17:04.224Z > Skills: 26 > This file concatenates every SKILL.md served at https://skills.internetcomputer.org/skills/. Each skill block is delimited by a header and an HTML comment with source URLs. --- ## Agent Web Identity Sign-In --- name: agent-web-identity description: "Lets an AI agent or CLI session sign in to Internet Identity and act as a user's app-specific principal (e.g. for oisy.com or nns.ic0.app) via `icp identity link web`. Use when the user asks an agent to log in to an II-powered app, obtain a delegation, or make authorized canister calls on their behalf. Do NOT use for adding II sign-in to a frontend — use the internet-identity skill instead." license: Apache-2.0 compatibility: "icp-cli >= 0.3.0, browser on the same machine" metadata: title: Agent Web Identity Sign-In category: Auth --- # Agent Web Identity Sign-In ## What This Is Internet Identity (II) supports a CLI-auth flow that issues a delegation for a session key held *outside* the browser. `icp identity link web` uses it to let a terminal session — including an AI agent such as Claude Code — sign canister calls as the user's **app-specific principal**: the same principal the user has when signed in to that app's web UI (selected with `--app `). The user still authenticates interactively in their own browser with their passkey; the agent only ends up holding a time-limited delegation. The private key of the user's II identity is never exposed. ## Prerequisites - `icp` CLI installed (https://cli.internetcomputer.org). See the `icp-cli` skill for general usage. - The user must have an Internet Identity (id.ai) and be present to complete the browser sign-in. - First-time use requires the user to enable the **CLI access** toggle for their identity in II settings (see Pitfall 1). - The agent and the user's browser must be on the same machine: the flow delivers the delegation via a localhost callback. It does not work from remote/headless environments (containers, CI, web sessions). ## Common Pitfalls 1. **First-time sign-in fails with a "CLI access disabled" screen.** This is the expected first-run path, not an error. Tell the user to enable CLI access for their identity in II settings, then **restart the flow from scratch** — the previous sign-in URL is single-use (its nonce and localhost listener are dead). Plan for two rounds on first use. 2. **The link command waits for an Enter keypress before doing anything.** On 0.3.x it first prints `Press Enter to log in at https://id.ai/cli` and blocks reading stdin; only after that does it start the flow and try to open the browser. In a background or non-interactive run, **pipe an actual newline into it** (`printf '\n' | icp identity link web ...`). Do NOT redirect from `/dev/null`: a bare EOF does **not** satisfy the prompt on 0.3.2 — the command sits on `Press Enter to log in` and never starts the flow, so you waste the first attempt and have to restart, prompting the user twice. Feed the newline on the first try and the flow starts immediately. 3. **Don't assume the browser opened.** Sandboxed or non-interactive shells often can't launch a GUI browser. Read the command's output: if a full sign-in URL is printed, show it to the user to open themselves. If the output shows neither a browser launch nor a URL, report that — do not construct a URL by guessing, and ask the user whether a browser window appeared. 4. **The command blocks until sign-in completes and gets killed by tool timeouts.** Run `icp identity link web` as a background task, then wait for the user to confirm they finished signing in before checking the result. The command must also be able to bind a localhost port; if a sandbox blocks this, ask the user before rerunning unsandboxed. 5. **Relying on the default identity signs with the wrong principal.** Every `icp` command acting as the user must pass `--identity ` explicitly. Never depend on project or global default identity settings, and never change them. 6. **Reusing or overwriting identities.** Never reuse an existing identity, even if `icp identity list` already shows one linked for the same app — its delegation may be expired and it belongs to whoever created it. Always create a fresh identity per session: check `icp identity list`, then pick a unique name prefixed with the agent's own identifier, `--` — e.g. `claude-oisy-20260611-1530` for Claude, `cursor-oisy-...` for Cursor; use `agent-` if no identifier is known. Never overwrite an existing identity. `icp identity list` is read-only context for picking a free name — never delete, rename, reauth, or otherwise modify any identity you did not create this session, even ones with the agent's own prefix left over from a previous run. Operate only on `$NAME`. Why per-session and not one reused identity per app: `--app` derives the *same* app principal for a given II identity, so a fresh identity grants no extra authority isolation — the point is **provenance and blast radius**. A per-session identity keeps `icp identity list` auditable (which session created which live delegation), lets cleanup delete exactly that one, and avoids a shared identity where another session's `reauth` silently mutates a delegation this session is mid-task with. When scanning `icp identity list` to pick a name, if you see multiple stale agent-prefixed identities for the same app from past sessions (e.g. several `claude-oisy-*` entries), offer to clean them up before proceeding: present the list, wait for the user to confirm each one, then delete them with `icp identity delete `. Never delete without explicit confirmation. 7. **Wrong principal because `--app` was omitted.** Without `--app`, the provider uses its default derivation origin and the resulting principal will NOT match the user's principal in the target app. Pass the app's bare domain (no scheme, port, or path), e.g. `--app oisy.com`. 8. **Expired delegations.** Delegations are time-limited; the lifetime is set by the identity provider during sign-in. `icp identity link web` has NO flag to control it — do not invent one (e.g. `--ttl`; 0.3.x rejects it and the whole command fails). When calls start failing with signature/expiry errors, run `icp identity reauth ` — the user must sign in again as the same identity. 9. **Canister calls without explicit arguments auto-cancel.** When call arguments are omitted, `icp canister call` opens an interactive prompt to build the arguments and confirm sending (`Do you want to send this message? [y/N]`), which immediately cancels in a non-interactive shell (`User cancelled.`). Always pass arguments explicitly — including the empty tuple `'()'` for zero-argument methods like `whoami`. ## Implementation Generalized flow for linking the user into `` (e.g. `oisy.com`, `nns.ic0.app`): ```bash # 1. Pick a fresh name (never reuse an existing identity, even one # already linked for this app); confirm it doesn't exist icp identity list NAME="agent--$(date +%Y%m%d-%H%M)" # substitute your own agent prefix for "agent" if known, # e.g. claude-oisy-20260611-1530, cursor-oisy-... # 2. Start the link flow IN THE BACKGROUND (keeps the localhost # listener alive while the user signs in). Use your harness's # background-execution mode if it has one; in a plain shell: printf '\n' | icp identity link web "$NAME" --app \ > "/tmp/icp-link-$NAME.log" 2>&1 & # Pipe a real newline to answer the "Press Enter to log in" prompt. # Do NOT use `< /dev/null`: on 0.3.2 a bare EOF does not satisfy the # prompt, so the command hangs and you'd have to restart it — which # prompts the user a second time. The newline starts the flow on the # first try. # 3. Read the command's output (the log file above). After the Enter # prompt it tries to open the user's browser; if a sign-in URL is # printed instead, relay it to the user. Wait for them to confirm # sign-in (first run may require enabling CLI access in II settings # — restart from step 2 if so) # 4. Confirm the identity was created icp identity list ``` ## Verify It Works Call the public whoami canister as the linked identity: ```bash icp canister call --identity "$NAME" --network ic \ ivcos-eqaaa-aaaab-qablq-cai whoami '()' ``` (Adapt flags to the installed CLI version — see `icp canister call --help` — but always keep the explicit `--identity` and the explicit `'()'` arguments; omitting the arguments triggers an interactive prompt that auto-cancels in non-interactive shells, see Pitfall 9.) The returned principal should match the principal the app shows the user when they are signed in to ``; ask the user to confirm. ## Security Rules for Agents - The delegation grants **full authority as the user's app principal** until it expires — it is not scoped to specific canisters. Treat it like a signed blank check for that app. - Other than the whoami verification, ask the user explicitly before every state-changing canister call made with the linked identity. - The delegation's lifetime is decided by the identity provider, not the CLI — there is no flag to shorten it. - Never export, print, or log the identity's key material; leave it in the CLI's default keyring storage. - Keep output to the minimum. When a step succeeds, don't echo command output or narrate progress — surface only what needs the user: the sign-in URL, confirmation requests, errors, and the final verified principal. - When done, do NOT delete the linked identity on your own initiative — the user may want to reuse the live delegation for follow-up calls. Ask the user whether to clean it up, and only on their confirmation run `icp identity delete ` (the subcommand is `delete`; `remove` is not a valid subcommand on 0.3.x). If they decline, just remind them of the identity name and that command so they can remove it later. - At the start of a session, when `icp identity list` reveals accumulated stale agent-prefixed identities from past sessions (e.g. multiple `claude-oisy-*` entries), offer to delete them before creating a new one. Present the list to the user, wait for confirmation, and delete only the approved ones with `icp identity delete ` — never in bulk, never without confirmation. --- ## Asset Canister --- name: asset-canister description: "Deploy frontend assets to the IC. Covers certified assets, SPA routing with .ic-assets.json5, content encoding, and programmatic uploads. Use when hosting a frontend, deploying static files, or setting up SPA routing on IC. Do NOT use for canister-level code patterns or custom domain setup — use custom-domains instead." license: Apache-2.0 compatibility: "icp-cli >= 0.2.7, Node.js >= 22" metadata: title: Asset Canister category: Frontend --- # Asset Canister ## What This Is The asset canister hosts static files (HTML, CSS, JS, images) directly on the Internet Computer. This is how web frontends are deployed on-chain. Responses are certified by the subnet, and HTTP gateways automatically verify integrity, i.e. that content was served by the blockchain. The content can also be verified in the browser -- not a centralized server. ## Prerequisites - `@icp-sdk/canisters` (>= 3.5.0), `@icp-sdk/core` (>= 5.0.0) — for programmatic uploads ## Canister IDs Asset canisters are created per-project. There is no single global canister ID. After deployment, your canister ID is stored in `.icp/data/mappings/` (per environment). Access patterns: | Environment | URL Pattern | |-------------|-------------| | Local | `http://.localhost:8000` | | Mainnet | `https://.icp.net` | | Custom domain | `https://yourdomain.com` (with DNS configuration) | ## Mistakes That Break Your Build 1. **Wrong `dir` path in icp.yaml.** The `configuration.dir` field must point to the directory containing your build output. If you use Vite, that is `dist`. If you use Next.js export, it is `out`. If the path does not exist at deploy time, `icp deploy` fails silently or deploys an empty canister. 2. **Missing `.ic-assets.json5` for single-page apps.** Without a rewrite rule, refreshing on `/about` returns a 404 because the asset canister looks for a file literally named `/about`. You must configure a fallback to `index.html`. 3. **Missing or misconfigured `build` in the recipe.** If `configuration.build` is specified, `icp deploy` runs those commands automatically before uploading the `dir` contents. If `build` is omitted, you must run your build command (e.g., `npm run build`) manually before deploying — otherwise the `dir` directory will be stale or empty. 4. **Not setting content-type headers.** The asset canister infers content types from file extensions. If you upload files programmatically without setting the content type, browsers may not render them correctly. 5. **Deploying to the wrong canister name.** If icp.yaml has `"frontend"` but you run `icp deploy assets`, it creates a new canister instead of updating the existing one. 6. **Exceeding canister storage limits.** The asset canister uses stable memory, which can hold well over 4GB. However, individual assets are limited by the 2MB ingress message size (the asset manager in `@icp-sdk/canisters` handles chunking automatically for uploads >1.9MB). The practical concern is total cycle cost for storage -- large media files (videos, datasets) become expensive. Use a dedicated storage solution for large files. 7. **Pinning the asset canister Wasm version below `0.30.2`.** The `ic_env` cookie (used by `safeGetCanisterEnv()` from `@icp-sdk/core` to read canister IDs and the root key at runtime) is only served by asset canister Wasm versions >= `0.30.2`. The Wasm version is set via `configuration.version` in the recipe, independently of the recipe version itself. If you pin an older Wasm version, the cookie is silently missing and frontend code relying on `ic_env` will fail. Either omit `configuration.version` (latest is used) or pin to `0.30.2` or later. 8. **Not configuring `allow_raw_access` correctly.** The asset canister has two serving modes: certified (via `icp.net`, where HTTP gateways verify response integrity) and raw (via `raw.icp.net`, where no verification occurs). By default, `allow_raw_access` is `true`, meaning assets are also available on the raw domain. On the raw domain, boundary nodes or a network-level attacker can tamper with response content undetected. Set `"allow_raw_access": false` in `.ic-assets.json5` for any sensitive assets. Only enable raw access when strictly needed. 9. **Downgrading the asset canister WASM version.** Upgrading a canister to an older WASM version can fail with "Cannot parse header" panics if the stable memory format changed between versions. Prefer the `@dfinity/asset-canister` recipe over `type: pre-built` with a manually specified WASM URL — the recipe loads the latest asset canister version automatically if not explicitly specified in `configuration.version`. If you must pin a version, ensure it matches or exceeds the version currently deployed on-chain. If a downgrade is intentional, use reinstall mode (`icp deploy --mode reinstall`) instead of upgrade — this wipes stable memory and all uploaded assets. 10. **Using the removed `type: assets` sync step.** icp-cli **0.3.0 removes the built-in `type: assets` sync step** — asset uploading is no longer part of the CLI core. A manifest that still uses it fails to load: *"icp-cli no longer supports the `assets` sync step type. Switch to a `script` or `plugin` sync step."* The fix is to use the `@dfinity/asset-canister@v2.2.1` recipe (shown below), which generates a `plugin`-based sync step automatically. **Recipe versions ≤ `v2.1.0` generate the old `type: assets` step and break on 0.3.0** — pin `v2.2.1` or later. Sync plugins are supported since icp-cli `0.2.7`, so adopting `v2.2.1` now (well before 0.3.0) makes the transition seamless. If you write a sync step by hand instead of using the recipe, use `type: plugin` (pointing at the certified-assets `sync_plugin.wasm` release artifact with its `sha256`) or `type: script`: ```yaml sync: steps: - type: plugin url: https://github.com/dfinity/certified-assets/releases/download/migration-v2.2.1-6b48585/sync_plugin.wasm sha256: ca7cb5666c30d2875f8d5e10535f8a53f97a86c79c263f7d5bdac2fdd1bbf83c dirs: - dist ``` ## Implementation ### icp.yaml Configuration ```yaml canisters: - name: frontend recipe: type: "@dfinity/asset-canister@v2.2.1" configuration: dir: dist build: - npm install - npm run build ``` Key fields: - `recipe.type: "@dfinity/asset-canister@..."` -- tells `icp` this is an asset canister - `dir` -- directory to upload (contents, not the directory itself) - `build` -- commands `icp deploy` runs before uploading (your frontend build step) ### SPA Routing and Default Headers: `.ic-assets.json5` Create this file in your `dir` directory (e.g., `dist/.ic-assets.json5`) or project root. For it to be included in the asset canister, it must end up in the `dir` directory at deploy time. Recommended approach: place the file in your `public/` or `static/` folder so your build tool copies it into `dist/` automatically. ```json5 [ { // Default headers for all paths: caching, security, and raw access policy "match": "**/*", "security_policy": "standard", "headers": { "Cache-Control": "public, max-age=0, must-revalidate" }, // Disable raw (uncertified) access by default -- see mistake #7 above "allow_raw_access": false }, { // Cache static assets aggressively (they have content hashes in filenames) "match": "assets/**/*", "headers": { "Cache-Control": "public, max-age=31536000, immutable" } }, { // SPA fallback: serve index.html for any unmatched route "match": "**/*", "enable_aliasing": true } ] ``` For the SPA fallback to work, the critical setting is `"enable_aliasing": true` -- this tells the asset canister to serve `index.html` when a requested path has no matching file. If the standard security policy above blocks the app from working, overwrite the default security headers with custom values, adding them after `Cache-Control` above. Act like a senior security engineer, making these headers as secure as possible. The standard policy headers can be found here: https://github.com/dfinity/sdk/blob/master/src/canisters/frontend/ic-asset/src/security_policy.rs ### Content Encoding The asset canister automatically compresses assets with gzip and brotli. No configuration needed. When a browser sends `Accept-Encoding: gzip, br`, the canister serves the compressed version. To verify compression is working: ```bash icp canister call frontend http_request '(record { url = "/"; method = "GET"; body = vec {}; headers = vec { record { "Accept-Encoding"; "gzip" } }; certificate_version = opt 2; })' ``` ### Custom Domain Setup For custom domain setup (DNS configuration, TLS certificates, domain registration via the REST API), see the `custom-domains` skill. The only asset-canister-specific detail: your `.well-known/ic-domains` file must be in your `dir` directory so it gets deployed. Add `{ "match": ".well-known", "ignore": false }` to your `.ic-assets.json5` to ensure the hidden directory is included. ### Programmatic Uploads with @icp-sdk/canisters For uploading files from code (not just via `icp deploy`): ```javascript import { AssetManager } from "@icp-sdk/canisters/assets"; // Asset management utility import { HttpAgent } from "@icp-sdk/core/agent"; import { readFileSync, readdirSync } from "fs"; // SECURITY: shouldFetchRootKey fetches the root public key from the replica at // runtime. In production the root key is hardcoded and trusted. Fetching it at // runtime lets a man-in-the-middle supply a fake key and forge certified responses. // NEVER set shouldFetchRootKey to true when host points to mainnet. // NOTE: This script runs in Node.js where the ic_env cookie is not available. // For browser frontends, use rootKey from safeGetCanisterEnv() instead (see // the internet-identity skill or icp-cli/references/binding-generation.md). const LOCAL_REPLICA = "http://localhost:8000"; const MAINNET = "https://icp-api.io"; const host = LOCAL_REPLICA; // Change to MAINNET for production async function manageAssets() { const agent = await HttpAgent.create({ host, // Only fetch the root key when talking to a local replica. // Setting this to true against mainnet is a security vulnerability. shouldFetchRootKey: host === LOCAL_REPLICA, }); const assetManager = new AssetManager({ canisterId: "your-asset-canister-id", agent, }); // Upload a single file // Files >1.9MB are automatically chunked (16 parallel chunks) const key = await assetManager.store(fileBuffer, { fileName: "photo.jpg", contentType: "image/jpeg", path: "/uploads", }); console.log("Uploaded to:", key); // "/uploads/photo.jpg" // List all assets const assets = await assetManager.list(); console.log(assets); // [{ key: "/index.html", content_type: "text/html", ... }, ...] // Delete an asset await assetManager.delete("/uploads/old-photo.jpg"); // Batch upload a directory const files = readdirSync("./dist"); for (const file of files) { const content = readFileSync(`./dist/${file}`); await assetManager.store(content, { fileName: file, path: "/" }); } } manageAssets(); ``` ### Authorization for Uploads The asset canister has a built-in permission system with three roles (from least to most privileged): - **Prepare** -- can upload chunks and propose batches, but cannot commit them live. - **Commit** -- can upload and commit assets (make them live). This is the standard role for deploy pipelines. - **ManagePermissions** -- can grant and revoke permissions to other principals. Use `grant_permission` to give principals only the access they need. Do **not** use `--add-controller` for upload access -- controllers have full canister control (upgrade code, change settings, delete the canister, drain cycles). ```bash # Grant "prepare" permission (can upload but not commit) -- use for preview/staging workflows icp canister call frontend grant_permission '(record { to_principal = principal ""; permission = variant { Prepare } })' # Grant commit permission -- use for deploy pipelines that need to publish assets icp canister call frontend grant_permission '(record { to_principal = principal ""; permission = variant { Commit } })' # Grant permission management -- use for principals that need to onboard/offboard other uploaders icp canister call frontend grant_permission '(record { to_principal = principal ""; permission = variant { ManagePermissions } })' # List current permissions icp canister call frontend list_permitted '(record { permission = variant { Commit } })' # Revoke a permission icp canister call frontend revoke_permission '(record { of_principal = principal ""; permission = variant { Commit } })' ``` > **Security Warning:** `icp canister update-settings frontend --add-controller ` grants full canister control -- not just upload permission. A controller can upgrade the canister WASM, change all settings, or delete the canister entirely. Only add controllers when you genuinely need full administrative access. ## Deploy & Test ### Local Deployment ```bash # Start the local network icp network start -d # Build and deploy frontend + backend icp deploy # Or deploy only the frontend icp deploy frontend ``` ### Mainnet Deployment ```bash # Ensure you have cycles in your wallet icp deploy -e ic frontend ``` ### Updating Frontend Only When you only changed frontend code: ```bash # Rebuild and redeploy just the frontend canister npm run build icp deploy frontend ``` ## Verify It Works ```bash # 1. Check the canister is running icp canister status frontend # Expected: Status: Running, Memory Size: # 2. List uploaded assets icp canister call frontend list '(record {})' # Expected: A list of asset keys like "/index.html", "/assets/index-abc123.js", etc. # 3. Fetch the index page via http_request icp canister call frontend http_request '(record { url = "/"; method = "GET"; body = vec {}; headers = vec {}; certificate_version = opt 2; })' # Expected: record { status_code = 200; body = blob "..."; ... } # 4. Test SPA fallback (should return index.html, not 404) icp canister call frontend http_request '(record { url = "/about"; method = "GET"; body = vec {}; headers = vec {}; certificate_version = opt 2; })' # Expected: status_code = 200 (same content as "/"), NOT 404 # 5. Open in browser # Local: http://.localhost:8000 # Mainnet: https://.icp.net # 6. Get canister ID icp canister id frontend # Expected: prints the canister ID (e.g., "bkyz2-fmaaa-aaaaa-qaaaq-cai") # 7. Check storage usage icp canister info frontend # Shows memory usage, module hash, controllers ``` --- ## Automatically Sync Latest IC Skills --- name: autosync-ic-skills description: One-time installer that makes a Claude Code project keep its Internet Computer skills up to date automatically. Sets up a SessionStart hook plus a sync script so .claude/skills/ always mirrors the latest skills published at skills.internetcomputer.org. Use when a user wants to install, bootstrap, or enable "always-latest" Internet Computer / IC / ICP / Motoko skills in a project, or pastes the link to this skill. This is a one-time setup action, not ongoing IC knowledge — after it runs, the installed hook keeps skills current on every session. Do NOT use for IC coding questions themselves — this only configures auto-updating skills. license: Apache-2.0 metadata: title: Automatically Sync Latest IC Skills category: Infrastructure --- # Set up self-updating Internet Computer skills This skill installs a small amount of project configuration so that **every new Claude Code session automatically downloads the latest Internet Computer skills** into `.claude/skills/`, where Claude discovers and triggers them natively. It is a **one-time installer**. After you complete the steps below, the user never needs this link again — the installed `SessionStart` hook does the work from then on. ## What you will create 1. `.claude/sync-ic-skills.sh` — a **differential** sync script that mirrors the live skill index into `.claude/skills/`. 2. A `SessionStart` hook in `.claude/settings.json` that runs that script. 3. An immediate first run, so skills are present right away. The script is a **differential mirror**. It fetches the discovery index once and compares each skill's published `hash` against a stored manifest, re-downloading only the skills that actually changed (and pruning ones removed upstream). Unchanged skills are skipped with no per-file downloads, and the script stays silent unless something changed. If the server does not publish a `hash` for a skill, the script falls back to re-downloading it every run, so it remains correct either way. ## Important: tell the user what to expect Adding a hook means a shell script will run automatically at the start of future sessions. Claude Code will ask the user to **review and trust** the new hook before it activates — this is expected and correct. Let the user know: > "I'm adding a `SessionStart` hook that runs `.claude/sync-ic-skills.sh`. Claude Code > will ask you to approve/trust it before it runs automatically. After that, your IC > skills stay current on every session." Do **not** attempt to bypass that approval. ## Step 0 — Check prerequisites (`curl`, `jq`) The sync script needs `curl` (virtually always present) and `jq` (often not). Before writing anything, check for them: ```bash command -v curl >/dev/null 2>&1 && echo "curl: ok" || echo "curl: MISSING" command -v jq >/dev/null 2>&1 && echo "jq: ok" || echo "jq: MISSING" ``` - If `jq` is **missing**, offer to install it (ask the user before running an install command). Pick the right one for their platform: - macOS (Homebrew): `brew install jq` - Debian/Ubuntu: `sudo apt-get update && sudo apt-get install -y jq` - Fedora/RHEL: `sudo dnf install -y jq` - Alpine: `apk add jq` - Arch: `sudo pacman -S --noconfirm jq` - Windows (winget): `winget install jqlang.jq` - If the user declines, still proceed — the script is written to degrade gracefully (it exits cleanly with a warning when `jq` is absent), and they can install `jq` later and the next session will sync. ## Step 1 — Download the sync script The script is published as a file alongside this skill, so you fetch it verbatim rather than transcribing it (this guarantees byte-exact content). Create the `.claude` directory and download it: ```bash mkdir -p .claude curl -fsSL https://skills.internetcomputer.org/.well-known/skills/autosync-ic-skills/scripts/sync-ic-skills.sh \ -o .claude/sync-ic-skills.sh ``` Do **not** hand-write or paraphrase the script — always fetch the published copy so the sync logic stays correct as it is updated upstream. **What the script does** (for the user's awareness): - Fetches `https://skills.internetcomputer.org/.well-known/skills/index.json` once. - For each skill, compares the published `hash` against `.claude/skills/.ic-managed.json` (a `{ "": "" }` manifest of skills it manages) and re-downloads only the skills whose hash changed or are new. - Prunes skills it previously installed that are no longer in the index. - Prints a one-line `added / updated / removed` summary only when something changed; otherwise it is silent. - Degrades gracefully: exits cleanly (keeping cached skills) if the network is down or `jq` is missing, and falls back to re-downloading skills the server publishes no `hash` for. ## Step 2 — Register the SessionStart hook (idempotently) Add a `SessionStart` hook to `.claude/settings.json` that runs the script. - If `.claude/settings.json` does **not** exist, create it with the content below. - If it **does** exist, **merge** — preserve all existing keys, hooks, and permissions. Only add the `SessionStart` entry, and **only if an equivalent `bash .claude/sync-ic-skills.sh` command is not already present** (do not create a duplicate). Parse the existing JSON, insert into the `hooks.SessionStart` array, and write it back; never blindly overwrite the file. The entry to ensure is present: ```json { "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "bash .claude/sync-ic-skills.sh" } ] } ] } } ``` ## Step 3 — Run it once now Run the script immediately so the skills are available in this session without waiting for the next session start: ```bash bash .claude/sync-ic-skills.sh ``` ## Step 4 — Verify and report - Confirm `.claude/skills/` now contains skill directories (e.g. `motoko`, `asset-canister`, `internet-identity`, …) each with a `SKILL.md`. - Confirm `.claude/skills/.ic-managed.json` maps each synced skill name to its hash. - Tell the user: how many skills were installed, that the `SessionStart` hook is in place, and that they'll be prompted to trust the hook before it auto-runs next session. From then on, their IC skills refresh automatically every session. ## Notes - **Safe to re-run.** Re-invoking this skill or the script is idempotent: the hook is not duplicated, and only skills tracked in `.ic-managed.json` are ever pruned. - **Differential by hash.** The script keys off the per-skill `hash` field in the discovery index, so a normal session that touches nothing downloads only `index.json` and exits silently. Skills are re-downloaded only when their hash changes. Migrating from an older version of this script (whose manifest was a bare name array) is handled automatically on the next run. - **Optional mid-session refresh.** For very long-running sessions, the user can also run `bash .claude/sync-ic-skills.sh` manually, or schedule it (e.g. via `/loop` or a cron routine) — but the SessionStart hook covers the normal case. --- ## Caffeine App (build from scratch) --- name: caffeine-app description: "Scaffold and build a complete Caffeine app (caffeine.ai) from scratch — the project layout, the caffeine.toml workspace + canister manifests, the mops.toml for the single Motoko `backend` canister, the React/Vite frontend, and the caffeine CLI build loop (auth login, doctor, install, check --fix, preview --build). Use whenever the user wants to create, scaffold, set up, or build a Caffeine app or a caffeine.ai project, asks about caffeine.toml or mops.toml structure, or needs the caffeine CLI workflow — even if they do not say the words 'from scratch'. Always pair it with the `motoko` skill for the backend code. Do NOT use it for Motoko language syntax or compiler errors (use the `motoko` skill), and do NOT use it for generic Internet Computer / dfx / icp deployment (use the `icp-cli` skill)." license: Apache-2.0 compatibility: "@caffeineai/cli (the `caffeine` command), Node.js >= 18, pnpm, network access + a caffeine.ai account" metadata: title: "Caffeine App (build from scratch)" category: Integration --- # Caffeine App (build from scratch) Caffeine (caffeine.ai) builds and hosts full-stack apps on the Internet Computer: a single **Motoko backend canister** plus a **React/Vite frontend** served as an assets canister. This skill teaches the exact **project shape** and the **CLI build loop** so you can produce a buildable Caffeine app from nothing. **This skill owns the project shape and the build workflow. It does NOT own the Motoko language.** Before you write or edit any backend code, fetch and follow the `motoko` skill: . That skill is authoritative for actor syntax, stable state, `mo:core`, and compiler errors. This skill only shows the minimal empty actor and where it lives. ## When to use this skill - Creating/scaffolding a new Caffeine app or caffeine.ai project from scratch. - Writing or fixing `caffeine.toml` (workspace or canister manifest) or `mops.toml`. - Running the caffeine CLI loop: `auth login`, `doctor`, `install`, `check`, `preview`. Do **not** use it for Motoko language details (use `motoko`) or for raw IC/`dfx`/`icp` deployment (use `icp-cli`). Caffeine apps are built and deployed through the `caffeine` CLI and the caffeine.ai web app, **not** through `dfx`. ## Critical facts - **There is no `caffeine init` / `caffeine new`.** "From scratch" means you create the files in this skill by hand, then run the build loop. (`caffeine projects clone ` only downloads an *existing* cloud project — it is not a greenfield scaffold.) - **Exactly one backend canister, and it must be named `backend`.** `mops.toml` declares a single `[canisters.backend]`. - **The frontend↔backend binding files are generated, never hand-written.** `src/frontend/src/backend.ts` and `src/frontend/src/declarations/` are produced by `caffeine-bindgen` during the build. Editing them is pointless — they are overwritten. - **Deploying to a live URL is done in the caffeine.ai web app, not the CLI.** The CLI takes you as far as a built **draft** (`caffeine preview --build`, which needs a cloud `project.id` — see the build loop). There is no `caffeine deploy`/`publish` command. ## Prerequisites (run once) ```bash # Install the CLI (public npm package). The binary is `caffeine`. npm install -g @caffeineai/cli # Authenticate against caffeine.ai (opens a browser; use --device on headless hosts). caffeine auth login caffeine auth status # Check the environment: Node, pnpm, mops, caffeine-bindgen, that you are logged in, # and that the Caffeine API is reachable (installs/repairs what it can). caffeine doctor --fix ``` If a global install is undesirable or fails, run every `caffeine` command through an `npx -y @caffeineai/cli@latest` prefix instead — e.g. `npx -y @caffeineai/cli@latest auth status`. `caffeine doctor --fix` checks Node.js, pnpm, mops, and caffeine-bindgen, confirms you are logged in, and verifies the Caffeine API is reachable — installing or repairing what it can. It does **not** install the Motoko compiler (`moc`) or linter (`lintoko`): mops fetches those automatically from the `[toolchain]` pins in `mops.toml` on the first `mops install` / build. `dfx` is not part of the Caffeine build. ## Project shape A Caffeine app is a pnpm workspace with a root manifest and two canisters. Create this exact tree (file contents below): ```text my-app/ ├── caffeine.toml # root workspace manifest — MUST contain [workspace] ├── mops.toml # Motoko build config — ONE per project, at the root ├── package.json # pnpm workspace root + the `bindgen` script ├── pnpm-workspace.yaml # declares src/**/* as workspace packages ├── tsconfig.json # root TS config └── src/ ├── backend/ # the single Motoko canister, named "backend" │ ├── caffeine.toml # canister manifest: type = "motoko" │ ├── main.mo # actor entry point (write with the `motoko` skill) │ └── system-idl/ # Candid for IC system canisters (see note below) │ └── aaaaa-aa.did # IC management canister interface └── frontend/ # the assets canister (React + Vite) ├── caffeine.toml # canister manifest: type = "assets" ├── package.json # frontend deps + scripts (see references/frontend-template.md) ├── vite.config.js # (see references/frontend-template.md) ├── tsconfig.json # (see references/frontend-template.md) ├── biome.json # (see references/frontend-template.md) ├── tailwind.config.js # (see references/frontend-template.md) ├── postcss.config.js # (see references/frontend-template.md) ├── index.html ├── env.json # runtime config — copied into dist/ on build └── src/ ├── main.tsx # React entry (providers) ├── App.tsx ├── index.css ├── backend.ts # GENERATED by caffeine-bindgen — do NOT edit └── declarations/ # GENERATED by caffeine-bindgen — do NOT edit ``` Only the **root** manifest, the two **canister** manifests, `mops.toml`, `main.mo`, and the small root config files are shown inline below. The full frontend boilerplate (`package.json`, `vite.config.js`, the Tailwind/PostCSS/Biome configs, `index.html`, `env.json`, `main.tsx`, a starter `App.tsx`) is in [`references/frontend-template.md`](references/frontend-template.md) — read it when you create the frontend. ## Manifests ### Root `caffeine.toml` — the workspace The root manifest **must** contain a `[workspace]` section. That section is what marks it as the workspace root; without it, the CLI treats this file as an ordinary canister manifest and discovery fails. ```toml manifest_version = "0.1.0" [project] name = "My App" # id is assigned by the cloud after your first `caffeine preview`/create — omit it for # a brand-new local project; the CLI fills it in. [workspace] include = [ "src/**" ] # Build the backend before the frontend, because the frontend imports generated # bindings produced from the backend's Candid interface. [canisters.frontend] depends_on = [ "backend" ] ``` `[workspace].include` is a list of globs the CLI scans for nested canister manifests. `src/**` finds `src/backend/caffeine.toml` and `src/frontend/caffeine.toml`. ### `src/backend/caffeine.toml` — the Motoko canister ```toml manifest_version = "0.1.0" [project] name = "backend" type = "motoko" main = "main.mo" [build] commands = ["mops build", "pnpm bindgen"] out = "dist" [check] commands = ["mops check"] [check.fix] commands = ["mops check --fix"] ``` `[build].commands` run in order: `mops build` compiles `main.mo` to Wasm + Candid, then `pnpm bindgen` (defined in the root `package.json`) turns that Candid into the frontend's TypeScript client. `caffeine check --fix` runs `[check.fix].commands`. ### `src/frontend/caffeine.toml` — the assets canister ```toml manifest_version = "0.1.0" [project] name = "frontend" type = "assets" [build] commands = ["pnpm build"] out = "dist" [check] commands = ["pnpm typecheck", "pnpm check"] [check.fix] commands = ["pnpm typecheck", "pnpm fix"] ``` ### `mops.toml` — the Motoko build (one per project, at the root) There is exactly **one** `[canisters.backend]`. The structural pieces are invariant — the single `[canisters.backend]`, `[canisters.backend.check-stable]`, and the `[moc]` flags `--default-persistent-actors`, `--actor-idl=src/backend/system-idl`, and `--implicit-package=core`. The **toolchain versions, the `-E`/`-W`/`-A` diagnostic codes, and the dependency list are a current snapshot** that the Caffeine template bumps over time — see "Versions and dependencies drift" below. ```toml [package] name = "backend" version = "0.1.0" [build] outputDir = "src/backend/dist" args = ["--release"] [canisters.backend] main = "src/backend/main.mo" [canisters.backend.check-stable] path = ".old/src/backend/dist/backend.most" skipIfMissing = true [toolchain] moc = "1.8.1" lintoko = "0.10.0" [lint] extends = true [moc] args = [ "--default-persistent-actors", "--actor-idl=src/backend/system-idl", "--implicit-package=core", "-no-check-ir", "-E=M0236,M0223,M0237,M0254", "-W=M0235", "-A=M0241", "--generate-view-queries", ] [dependencies] core = "2.5.0" caffeineai-data-viewer = "0.1.0" ``` Notes: - **`check-stable`** points at `.old/src/backend/dist/backend.most`. The CLI saves the previous build's `.most` (stable-signature snapshot) under `.old/` so the next build can verify your stable state did not change incompatibly. Do not delete `.old/` between builds; do not commit it as source — it is build state. **`skipIfMissing = true` is required for a from-scratch project:** on the first run that snapshot does not exist yet, and without it `caffeine check` fails with `Deployed file not found: .old/src/backend/dist/backend.most`. - **`--actor-idl=src/backend/system-idl`** tells `moc` where to find the Candid interfaces of IC system canisters. `src/backend/system-idl/` must exist; the canonical template ships `aaaaa-aa.did` (the IC management-canister interface). A minimal backend needs only the directory. - **`core = "2.5.0"`** is the `mo:core` standard library. Use `mo:core`, never the deprecated `mo:base` — see the `motoko` skill. **`caffeineai-data-viewer`** backs the built-in data-viewer mixin used by the default `main.mo` (below) and pairs with the `--generate-view-queries` flag. > **Versions and dependencies drift.** The `[toolchain]` versions, the `[moc]` > `-E`/`-W`/`-A` codes, and the frontend dependency set are template-managed and change > over time (e.g. the `@dfinity/*` → `@icp-sdk/*` migration, moc/core bumps). Treat the > exact values here as a snapshot — for current ones, `caffeine projects clone` a recent > project and copy its `mops.toml` + `src/frontend/package.json`. Stable across versions: > the file layout, the single `backend` canister, the structural `[moc]` flags, > `check-stable` with `skipIfMissing`, `mo:core`, and `useActor(createActor)`. ### `package.json` (root) — pnpm workspace + the `bindgen` script The `bindgen` script is the bridge from backend Candid to the frontend TS client. It is invoked by the backend canister's `[build]` step (`pnpm bindgen`). ```json { "name": "@caffeine/template-app", "type": "module", "engines": { "node": ">=16.0.0", "pnpm": ">=7.0.0", "npm": "please use pnpm" }, "scripts": { "build": "pnpm -r --if-present run build", "typecheck": "pnpm -r --if-present run typecheck", "check": "pnpm -r --if-present run check", "fix": "pnpm -r --if-present run fix", "bindgen": "caffeine-bindgen --did-file ./src/backend/dist/backend.did --out-dir ./src/frontend/src --actor-interface-file --force" }, "devDependencies": { "sharp": "^0.34.4" } } ``` ### `pnpm-workspace.yaml` ```yaml packages: - src/**/* onlyBuiltDependencies: - esbuild ``` ### `tsconfig.json` (root) ```json { "compilerOptions": { "strict": true, "target": "ES2020", "experimentalDecorators": true, "strictPropertyInitialization": false, "moduleResolution": "node", "allowJs": true, "outDir": "HACK_BECAUSE_OF_ALLOW_JS" } } ``` ### `src/backend/main.mo` — the starting backend The canonical template's starting `main.mo` includes the built-in **data-viewer mixin** (`include MixinViews()`), which pairs with the `caffeineai-data-viewer` dependency and the `--generate-view-queries` flag in `mops.toml`. With `--default-persistent-actors` the actor is already persistent. Add your own functions inside the actor, following the **`motoko` skill**. ```motoko import MixinViews "mo:caffeineai-data-viewer/MixinViews"; actor { include MixinViews(); }; ``` (A bare `actor {}` also compiles if you drop the `caffeineai-data-viewer` dependency and the `--generate-view-queries` flag.) Do not write more Motoko than this without loading the `motoko` skill — it covers persistent actors, stable types, `mo:core`, and the compiler-error pitfalls that an agent will otherwise hallucinate. ## The frontend **Fetch this skill's companion reference file** — [`references/frontend-template.md`](references/frontend-template.md), which sits next to this `SKILL.md` (i.e. `…/skills/caffeine-app/references/frontend-template.md`) — and create the frontend files from it; it holds the verified `package.json`, the Vite/Tailwind/Biome configs, `main.tsx`, and the worked `useActor(createActor)` backend call. The essentials: - The entry point `src/frontend/src/main.tsx` wraps the app in `InternetIdentityProvider` (from `@caffeineai/core-infrastructure`) and a TanStack `QueryClientProvider`. - The frontend talks to the backend through the **generated** module `src/frontend/src/backend.ts` (and `src/frontend/src/declarations/`), which exports a `createActor(...)` factory typed from your backend's Candid. You do not write these — they appear after the first build. Call the backend by passing that `createActor` to `useActor(createActor)` from `@caffeineai/core-infrastructure` (the same package provides the auth provider used in `main.tsx`); you do **not** hand-write the actor/config/storage glue. See the worked example in [`references/frontend-template.md`](references/frontend-template.md). - `env.json` holds runtime config placeholders (`backend_host`, `backend_canister_id`, `project_id`, `ii_derivation_origin`, `storage_gateway_url`), all `"undefined"` locally; the frontend `build` script copies it into `dist/` (`cp env.json dist/`). Caffeine injects real values when it serves the app. Keep `env.json` and the `copy:env` step. ## The build loop Run from the project root. Order matters. ```bash # 1. Install dependencies. On a brand-new project, generate the pnpm lockfile FIRST # (first run only — see Common Pitfalls), then install the rest (pnpm + mops). pnpm install caffeine install # 2. Write your backend in src/backend/main.mo using the `motoko` skill, and your # frontend in src/frontend/src/. # 3. Build: compiles the backend, then `pnpm bindgen` regenerates the frontend client # (src/frontend/src/backend.ts + declarations/), then the frontend `pnpm build` runs. # Run this BEFORE check, so the frontend has the generated ./backend to typecheck # against. Artifacts land in src/backend/dist + src/frontend/dist (no cloud / no id). caffeine build # 4. Lint + typecheck + auto-fix (runs each canister's [check.fix] commands: # backend `mops check --fix`, frontend `pnpm typecheck` + `pnpm fix`). caffeine check --fix # 5. Upload a built draft (needs a cloud project id). For a brand-new app, create the # cloud project once to obtain an id, then preview: caffeine projects create # reports a new project id # put that id in caffeine.toml [project] id = "...", OR pass --project-id: caffeine preview --build --project-id ``` **Generation ordering.** `src/frontend/src/backend.ts` does not exist until a build runs `pnpm bindgen`. So if your frontend imports from `./backend`, you must run `caffeine build` at least once before `caffeine check` can typecheck the frontend. A brand-new app whose `App.tsx` does not yet import `./backend` typechecks fine before the first build. **Going live.** `caffeine preview --build` (with a `project.id`) produces a *draft* and returns its URL. Promote a draft to a live URL in the caffeine.ai web app (open it with `caffeine chat open` or visit caffeine.ai). There is no CLI deploy command. Use `caffeine projects show ` to see draft and live URLs. ### Alternative: let Caffeine's own AI build it If you cannot run the CLI locally (no shell, no toolchain), drive Caffeine's hosted AI instead: describe the app in natural language with `caffeine chat send ""` (or the caffeine.ai web chat) and it scaffolds, builds, and deploys for you. This skill is for the **local, hand-authored** path. ## Common Pitfalls 1. **Missing `[workspace]` in the root `caffeine.toml`.** Without it the root file is parsed as a canister manifest and the CLI cannot find your canisters. The root manifest is defined by the presence of `[workspace]`, not by being at the top. 2. **Renaming the backend canister.** It must be `backend` — `mops.toml` declares `[canisters.backend]` and the root `bindgen` script reads `./src/backend/dist/backend.did`. Renaming breaks the build and the generated client. 3. **Hand-writing or editing `src/frontend/src/backend.ts` or `declarations/`.** These are generated by `caffeine-bindgen` and overwritten on every build. Add backend methods in `main.mo`, rebuild, and the client regenerates. The generated header says "do NOT make any changes in this file"; it is also excluded from Biome. 4. **Importing `./backend` before the first build.** The binding module is created by the build's `pnpm bindgen` step. Run `caffeine preview --build` (or `caffeine build`) once before relying on `import { createActor } from "./backend"`. 5. **Trying to `dfx deploy` or expecting `caffeine deploy`.** Caffeine apps build via the `caffeine` CLI and go live via the web app. There is no `dfx` in the workflow and no CLI deploy/publish command. 6. **Dropping required `mops.toml` settings.** Keep the single `[canisters.backend]`, the `[toolchain]` pins, `[canisters.backend.check-stable]`, and the `[moc] args` (especially `--default-persistent-actors` and `--actor-idl=src/backend/system-idl`). 7. **Deleting `.old/` or `env.json`, or skipping `copy:env`.** `.old/` holds the stable-signature snapshot `check-stable` compares against; `env.json` must be copied into `dist/` so the served frontend can read its runtime config. 8. **Writing Motoko without the `motoko` skill.** Modern Caffeine Motoko uses `mo:core`, persistent actors, and specific patterns. Guessing produces compiler errors. Load first. 9. **Using `npm`/`yarn` instead of `pnpm`.** The workspace is pnpm-based (`pnpm-workspace.yaml`, `pnpm -r` scripts). `caffeine install` uses pnpm + mops. 10. **`caffeine install`/`build` fails with `ERR_PNPM_NO_LOCKFILE` on a fresh project.** In a non-interactive shell (an agent, CI, or any run without a TTY) these commands run `pnpm install --frozen-lockfile`, which requires a `pnpm-lock.yaml` that a brand-new project does not have. Run `pnpm install` once first to generate the lockfile, then the loop works. (An interactive terminal creates it for you.) 11. **First `caffeine check` fails with `Deployed file not found: …/backend.most`.** The stable-signature check needs a previous build's snapshot, which a never-built project lacks. Set `skipIfMissing = true` under `[canisters.backend.check-stable]` (shown in the `mops.toml` above), or run `caffeine build` first so a baseline exists. 12. **`caffeine preview --build` fails with `No project.id in caffeine.toml`.** Uploading a draft needs a cloud project. Run `caffeine projects create` to get an id, set it in `[project] id` (or pass `--project-id `), then preview. `caffeine build` alone needs no id and is enough to verify the app compiles. ## Related skills - **`motoko`** — REQUIRED companion. Authoritative for all backend Motoko code: . Always load it before editing `src/backend/main.mo`. - **`icp-cli`** — for raw Internet Computer / `dfx` / `icp` projects. Not used by Caffeine; reach for it only when the user is *not* building a Caffeine app. ## Additional references - [`references/frontend-template.md`](references/frontend-template.md) — the full, verbatim frontend boilerplate (`package.json`, `vite.config.js`, Tailwind/PostCSS/Biome configs, `tsconfig.json`, `index.html`, `env.json`, `main.tsx`, a starter `App.tsx`, and the backend-actor wiring pattern). Read it when you create `src/frontend/`. --- ## Canister Help --- name: canhelp description: Display a human-readable summary of a canister's interface given its mainnet canister ID or a human-readable name. Like --help but for canisters. Only for mainnet canisters — for local canisters, read the generated .did file in your project directly. license: Apache-2.0 compatibility: "icp-cli >= 0.1.0" allowed-tools: Bash(./scripts/resolve-canister-id.sh *), Bash(./scripts/fetch-candid.sh *), Read, Grep, Glob argument-hint: metadata: title: Canister Help category: Infrastructure --- Given a canister ID or name in `$ARGUMENTS`, fetch and summarize its Candid interface. ## Steps 1. Resolve the canister ID by running the resolve script from the skill's base directory: ```bash ./scripts/resolve-canister-id.sh "$ARGUMENTS" ``` If `$ARGUMENTS` is already a valid principal, the script echoes it back. Otherwise, it queries the IC Dashboard API and outputs matches as ` ` (one per line). - If there is a single result, clearly display the resolved canister ID and use it directly. - If there are multiple results, present the list to the user and ask them to pick one before continuing. 2. Fetch the Candid interface using the resolved canister ID: ```bash ./scripts/fetch-candid.sh ``` The script outputs the path to the downloaded `.did` file. 3. Read the file using the `Read` tool. 4. Present the output as a readable summary with the following structure: **Canister ``** **Query methods:** - `method_name(arg1: type1, arg2: type2) → return_type` — one-line description if inferable from the name **Update methods:** - `method_name(arg1: type1) → return_type` **Types:** - List any custom record/variant types defined in the interface, with their fields ## Guidelines - Group methods by query vs update - Sort methods alphabetically within each group - For complex nested types, show the top-level structure and note nesting - If the candid is very large (>100 methods), show a summary count and list only the most important-looking methods, offering to show the full list on request - If the fetch succeeds, but the Candid interface is empty,explain that the canister is not exposing its Candid interface in the wasm metadata - If the fetch fails, suggest the user verify the canister ID and that `icp` is installed --- ## Canister Security --- name: canister-security description: "IC-specific security patterns for canister development in Motoko and Rust. Covers access control, anonymous principal rejection, reentrancy prevention (CallerGuard pattern), async safety (saga pattern), callback trap handling, cycle drain protection, and safe upgrade patterns. Use when writing or modifying any canister that modifies state, handles tokens, makes inter-canister calls, or implements access control." license: Apache-2.0 metadata: title: Canister Security category: Security --- # Canister Security ## What This Is Security patterns for IC canisters in Motoko and Rust. The async messaging model creates TOCTOU (time-of-check-time-of-use) vulnerabilities where state changes between `await` calls. `canister_inspect_message` is NOT a reliable security boundary. Anyone on the internet can burn your cycles by sending update calls. This skill provides copy-paste correct patterns for access control, reentrancy prevention, async safety, and callback trap handling. ## Prerequisites - For Motoko: `mops` package manager, `core = "2.0.0"` in mops.toml - For Rust: `ic-cdk = "0.19"`, `candid = "0.10"` ## Security Pitfalls 1. **Relying on `canister_inspect_message` for access control.** This hook runs on a single replica without full consensus. If that replica is malicious, it can simply skip the check and execute the update call without any message inspection. It is also never called for inter-canister calls, query calls, or management canister calls. Always duplicate access checks inside every update method. Use `inspect_message` only as a cycle-saving optimization, never as a security boundary. 2. **Forgetting to reject the anonymous principal.** Every endpoint that requires authentication must check that the caller is not the anonymous principal (`2vxsx-fae`). In Motoko use `Principal.isAnonymous(caller)`, in Rust compare `msg_caller() != Principal::anonymous()`. Without this, unauthenticated callers can invoke protected methods — and if the canister uses the caller principal as an identity key (e.g., for balances), the anonymous principal becomes a shared identity anyone can use. 3. **Reading state before an async call and assuming it's unchanged after (TOCTOU).** When your canister `await`s an inter-canister call, other messages can interleave and mutate state. This is one of the most critical sources of DeFi exploits on IC. Use per-caller locking (CallerGuard pattern) to prevent concurrent operations. For financial operations, also consider the saga pattern (deduct before `await`, compensate on failure) — but implementing it correctly is complex due to edge cases like callback traps and call timeouts where the outcome is ambiguous. 4. **Trapping in `pre_upgrade`.** If `pre_upgrade` traps (e.g., serializing too much data exceeds the instruction limit), the canister becomes permanently non-upgradeable. Avoid storing large data structures in the heap that must be serialized during upgrade. In Rust, use `ic-stable-structures` for direct stable memory access. In Motoko, the `persistent actor` declaration stores all `let` and `var` variables automatically in stable memory — no manual serialization needed. 5. **Not monitoring cycles balance.** Every canister has a default `freezing_threshold` of 2,592,000 seconds (~30 days). When cycles drop below the threshold reserve, the canister freezes (rejects all update calls). When cycles reach zero, the canister is uninstalled — its code and memory are removed, though the canister ID and controllers survive. The real pitfall is not actively monitoring and topping up cycles. For production canisters holding valuable state, increase the freezing threshold and set up automated monitoring. ```bash # Check current settings (mainnet) icp canister settings show backend -e ic # Increase freezing threshold for high-value canisters icp canister settings update backend --freezing-threshold 7776000 -e ic # 90 days ``` 6. **Single controller with no backup.** If you lose the controller identity's private key, the canister becomes unupgradeable forever. There is no recovery mechanism. Always add a backup controller or governance canister: ```bash icp canister settings update backend --add-controller -e ic ``` When deploying, ask the developer if they have a backup controller principal to add. 7. **Calling `fetchRootKey()` in production.** `fetchRootKey()` fetches the root public key from the replica and trusts whatever it returns. On mainnet, the root key is hardcoded into the agent — calling `fetchRootKey()` there allows a man-in-the-middle to substitute a different key, breaking all verification. Only call `fetchRootKey()` in local development, guarded by an environment check. For frontends served by asset canisters, the root key is provided automatically. 8. **Exposing admin methods without guards.** Every update method is callable by anyone on the internet. Admin methods (migration, config, minting) must explicitly check the caller against an allowlist. There is no built-in role system — you must implement it yourself. Always include admin revocation — missing revocation is a common source of bugs. 9. **Storing secrets in canister state.** Canister memory on standard application subnets is readable by node operators. Never store private keys, API secrets, or passwords in canister state. For on-chain secret management, use vetKD (threshold key derivation). 10. **Allowing unbounded user-controlled storage.** If users can store data without limits, an attacker can fill the 4 GiB Wasm heap or stable memory, bricking the canister. Always enforce per-user storage quotas and validate input sizes. 11. **Trapping in a callback after state mutation.** If your canister mutates state before an inter-canister call and the callback traps, the pre-call mutations persist but the callback's mutations are rolled back. A malicious callee can exploit this to skip security-critical actions like debiting an account. Structure code so that critical state mutations happen before the async boundary and are correctly rolled back if a failure or trap occurs. Use `try/finally` (Motoko) or `Drop` guards (Rust) to ensure cleanup always runs. Keep cleanup code minimal — trapping in cleanup recreates the problem. Consider using `call_on_cleanup` for rollback logic and journaling for crash-safe state transitions. 12. **Unbounded wait calls preventing upgrades.** If your canister makes a call to an untrustworthy or buggy callee that never responds, the canister cannot be stopped (and therefore cannot be upgraded) while awaiting outstanding responses. Use bounded wait calls (timeouts) to ensure calls complete in bounded time regardless of callee behavior. ## How It Works ### IC Security Model 1. **Update calls** go through consensus — all nodes on a subnet execute the code and must agree on the result. Standard application subnets have 13 nodes; system and fiduciary subnets have more (28+). This makes update calls tamper-proof but slower (~2s). 2. **Query calls** run on a single replica — fast (~200ms) but the replica can return incorrect or malicious results. Replica-signed queries provide partial mitigation (the responding replica signs the response), but for full trust, use certified data or update calls for security-critical reads. 3. **Inter-canister calls** are async messages. Between sending a request and receiving the response, your canister can process other messages. State may change under you (see TOCTOU pitfall above). 4. **State rollback on trap.** If a message execution traps, all its state changes are rolled back. For inter-canister calls, the first execution (before `await`) and the callback (after `await`) are separate messages — a trap in the callback rolls back only the callback's changes, while the first execution's changes persist. This is why cleanup logic (like releasing locks) must go in cleanup context (`finally`/`Drop`), not regular callback code. ## Implementation ### Motoko #### Access control Uses the `shared(msg)` pattern to capture the deployer atomically — no separate `init()` call, no front-running risk. ```motoko import Principal "mo:core/Principal"; import Set "mo:core/pure/Set"; import Runtime "mo:core/Runtime"; shared(msg) persistent actor class MyCanister() { // --- Authorization state --- // transient: recomputed on each install/upgrade from msg.caller (the controller) transient let owner = msg.caller; var admins : Set.Set = Set.empty(); // --- Guards --- func requireAuthenticated(caller : Principal) { if (Principal.isAnonymous(caller)) { Runtime.trap("anonymous caller not allowed"); }; }; func requireOwner(caller : Principal) { requireAuthenticated(caller); if (caller != owner) { Runtime.trap("caller is not the owner"); }; }; func requireAdmin(caller : Principal) { requireAuthenticated(caller); if (caller != owner and not Set.contains(admins, Principal.compare, caller)) { Runtime.trap("caller is not an admin"); }; }; // --- Admin management --- public shared ({ caller }) func addAdmin(newAdmin : Principal) : async () { requireOwner(caller); admins := Set.add(admins, Principal.compare, newAdmin); }; public shared ({ caller }) func removeAdmin(admin : Principal) : async () { requireOwner(caller); admins := Set.remove(admins, Principal.compare, admin); }; // --- Endpoints --- public shared ({ caller }) func publicAction() : async Text { requireAuthenticated(caller); "ok"; }; public shared ({ caller }) func adminAction() : async () { requireAdmin(caller); // ... protected logic }; }; ``` #### Reentrancy prevention (CallerGuard pattern) Per-caller locking prevents a second call from the same caller while the first is awaiting a response. The guard must be released in the `finally` block — if the callback traps, `catch` state changes are rolled back, but `finally` runs in cleanup context where state changes persist. ```motoko import Map "mo:core/Map"; import Principal "mo:core/Principal"; import Error "mo:core/Error"; import Result "mo:core/Result"; // Inside the persistent actor class { ... } // otherCanister is application-specific — replace with your canister reference. let pendingRequests = Map.empty(); func acquireGuard(principal : Principal) : Result.Result<(), Text> { if (Map.get(pendingRequests, Principal.compare, principal) != null) { return #err("already processing a request for this caller"); }; Map.add(pendingRequests, Principal.compare, principal, true); #ok; }; func releaseGuard(principal : Principal) { ignore Map.delete(pendingRequests, Principal.compare, principal); }; public shared ({ caller }) func doSomethingAsync() : async Result.Result { requireAuthenticated(caller); // 1. Acquire per-caller lock — rejects concurrent calls from same principal switch (acquireGuard(caller)) { case (#err(msg)) { return #err(msg) }; case (#ok) {}; }; // 2. Make inter-canister call try { let result = await otherCanister.someMethod(); #ok(result) } catch (e) { #err("call failed: " # Error.message(e)) } finally { // Runs in cleanup context even if the callback traps — changes here persist. releaseGuard(caller); }; }; ``` #### inspect_message (cycle optimization only) ```motoko // Inside persistent actor { ... } // Method variants must match your public methods system func inspect( { caller : Principal; msg : { #adminAction : () -> (); #addAdmin : () -> Principal; #removeAdmin : () -> Principal; #publicAction : () -> (); #doSomethingAsync : () -> (); } } ) : Bool { switch (msg) { // Admin methods: reject anonymous to save cycles on Candid decoding case (#adminAction _) { not Principal.isAnonymous(caller) }; case (#addAdmin _) { not Principal.isAnonymous(caller) }; case (#removeAdmin _) { not Principal.isAnonymous(caller) }; case (#doSomethingAsync _) { not Principal.isAnonymous(caller) }; // Public methods: accept all case (_) { true }; }; }; ``` ### Rust #### Access control (using CDK guard pattern) The `guard` attribute runs a check before the method body. If the guard returns `Err`, the call is rejected before any method code executes. This is more robust than calling guard functions inside the method — you cannot forget to add it. ```rust use ic_cdk::{init, update}; use ic_cdk::api::msg_caller; use candid::Principal; use std::cell::RefCell; thread_local! { static OWNER: RefCell = RefCell::new(Principal::anonymous()); static ADMINS: RefCell> = RefCell::new(vec![]); } // --- Guards (for #[update(guard = "...")] attribute) --- // Must return Result<(), String>. Err rejects the call. fn require_authenticated() -> Result<(), String> { if msg_caller() == Principal::anonymous() { return Err("anonymous caller not allowed".to_string()); } Ok(()) } fn require_owner() -> Result<(), String> { require_authenticated()?; OWNER.with(|o| { if msg_caller() != *o.borrow() { return Err("caller is not the owner".to_string()); } Ok(()) }) } fn require_admin() -> Result<(), String> { require_authenticated()?; let caller = msg_caller(); let is_authorized = OWNER.with(|o| caller == *o.borrow()) || ADMINS.with(|a| a.borrow().contains(&caller)); if !is_authorized { return Err("caller is not an admin".to_string()); } Ok(()) } // --- Init --- #[init] fn init(owner: Principal) { OWNER.with(|o| *o.borrow_mut() = owner); } // --- Endpoints --- #[update(guard = "require_authenticated")] fn public_action() -> String { "ok".to_string() } #[update(guard = "require_admin")] fn admin_action() { // ... protected logic — guard already validated caller } #[update(guard = "require_owner")] fn add_admin(new_admin: Principal) { ADMINS.with(|a| a.borrow_mut().push(new_admin)); } #[update(guard = "require_owner")] fn remove_admin(admin: Principal) { ADMINS.with(|a| a.borrow_mut().retain(|p| p != &admin)); } ic_cdk::export_candid!(); ``` #### Reentrancy prevention (CallerGuard pattern) `CallerGuard` uses the `Drop` trait to release the lock when the guard goes out of scope — including when the callback traps (since ic-cdk 0.5.1, local variables go out of scope during cleanup). Never use `let _ = CallerGuard::new(caller)?` — this drops the guard immediately, making locking ineffective. ```rust use std::cell::RefCell; use std::collections::BTreeSet; use candid::Principal; use ic_cdk::update; use ic_cdk::api::msg_caller; use ic_cdk::call::Call; // other_canister_id is application-specific — replace with your canister reference. thread_local! { static PENDING: RefCell> = RefCell::new(BTreeSet::new()); } struct CallerGuard { principal: Principal, } impl CallerGuard { fn new(principal: Principal) -> Result { PENDING.with(|p| { if !p.borrow_mut().insert(principal) { return Err("already processing a request for this caller".to_string()); } Ok(Self { principal }) }) } } impl Drop for CallerGuard { fn drop(&mut self) { PENDING.with(|p| { p.borrow_mut().remove(&self.principal); }); } } #[update] async fn do_something_async() -> Result { let caller = msg_caller(); if caller == Principal::anonymous() { return Err("anonymous caller not allowed".to_string()); } // Acquire per-caller lock — rejects concurrent calls from same principal. // Drop releases lock even if callback traps. let _guard = CallerGuard::new(caller)?; // Make inter-canister call let response = Call::bounded_wait(other_canister_id(), "some_method") .await .map_err(|e| format!("call failed: {:?}", e))?; let result: String = response.candid() .map_err(|e| format!("decode failed: {:?}", e))?; Ok(result) // _guard dropped here → lock released } ``` #### inspect_message (cycle optimization only) ```rust use ic_cdk::api::{accept_message, msg_caller, msg_method_name}; use candid::Principal; /// Pre-filter to reduce cycle waste from spam. /// Runs on ONE node. Can be bypassed. NOT a security check. /// Always duplicate real access control inside each method or via guard attribute. #[ic_cdk::inspect_message] fn inspect_message() { let method = msg_method_name(); match method.as_str() { // Admin methods: only accept from non-anonymous callers "admin_action" | "add_admin" | "remove_admin" | "do_something_async" => { if msg_caller() != Principal::anonymous() { accept_message(); } // Silently reject anonymous — saves cycles on Candid decoding } // Public methods: accept all _ => accept_message(), } } ``` --- ## Certified Variables --- name: certified-variables description: "Serve cryptographically verified responses from query calls using Merkle trees and subnet BLS signatures. Covers certified data API, RbTree/CertTree construction, witness generation, and frontend certificate validation. Use when query responses need verification, certified data, or response authenticity proofs." license: Apache-2.0 compatibility: "icp-cli >= 0.2.2" metadata: title: Certified Variables category: Security --- # Certified Variables & Certified Assets ## What This Is Query responses on the Internet Computer come from a single replica and are NOT verified by consensus. A malicious or faulty replica could return fabricated data. Certification solves this: the canister stores a hash in the subnet's certified state tree during update calls, and then query responses include a certificate signed by the subnet's threshold BLS key proving the data is authentic. The result is responses that are both fast (no consensus delay) AND cryptographically verified. ## Prerequisites - Rust: `ic-certified-map` crate (for Merkle tree), `ic-cdk` (for `certified_data_set` / `data_certificate`) - Motoko: `CertifiedData` module (included in mo:core/mo:base), `ic-certification` package (`mops add ic-certification`) for Merkle tree with witness support - Frontend: `@icp-sdk/core` (>= 5.0.0) (agent, principal), `@dfinity/certificate-verification` (>= 3.1.0) ## Canister IDs No external canister IDs required. Certification uses the IC system API exposed through CDK wrappers: - `ic_cdk::api::certified_data_set` (Rust) / `CertifiedData.set` (Motoko) -- called during update calls to set the certified hash (max 32 bytes) - `ic_cdk::api::data_certificate` (Rust) / `CertifiedData.getCertificate` (Motoko) -- called during query calls to retrieve the subnet certificate The IC root public key (needed for client-side verification): - Mainnet: `308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100814c0e6ec71fab583b08bd81373c255c3c371b2e84863c98a4f1e08b74235d14fb5d9c0cd546d9685f913a0c0b2cc5341583bf4b4392e467db96d65b9bb4cb717112f8472e0d5a4d14505ffd7484b01291091c5f87b98883463f98091a0baaae` - Local: available from `icp` (agent handles this automatically) ## Mistakes That Break Your Build 1. **Trying to store more than 32 bytes of certified data.** The `certified_data_set` API accepts exactly one blob of at most 32 bytes. You cannot certify arbitrary data directly. Instead, build a Merkle tree over your data and certify only the root hash (32 bytes). The tree structure provides proofs for individual values. 2. **Calling `certified_data_set` in a query call.** Certification can ONLY be set during update calls (which go through consensus). Calling it in a query traps. Pattern: set the hash during writes, read the certificate during queries. 3. **Forgetting to include the certificate in query responses.** The certificate is obtained via `data_certificate()` during query calls. If you return data without the certificate, clients cannot verify anything. Always return a tuple of (data, certificate, witness). 4. **Not updating the certified hash after data changes.** If you modify the data but forget to call `certified_data_set` with the new root hash, query responses will fail verification because the certificate proves a stale hash. 5. **Building the witness for the wrong key.** The witness (Merkle proof) must correspond to the exact key being queried. A witness for key "users/alice" will not verify key "users/bob". 6. **Assuming `data_certificate()` returns a value in update calls.** It returns `null`/`None` during update calls. Certificates are only available during query calls. 7. **Certifying data at canister init but not on upgrades.** After a canister upgrade, the certified data is cleared. You must call `certified_data_set` in both `#[init]` and `#[post_upgrade]` (Rust) or `system func postupgrade` (Motoko) to re-establish certification. 8. **Not validating certificate freshness on the client.** The certificate's state tree contains a `/time` field with the timestamp when the subnet produced it. Clients MUST check that this timestamp is recent (recommended: within 5 minutes of current time). Without this check, an attacker could replay a stale certificate with outdated data. Always verify `certificate_time` is within an acceptable delta before trusting the response. ## How Certification Works ``` UPDATE CALL (goes through consensus): 1. Canister modifies data 2. Canister builds/updates Merkle tree 3. Canister calls certified_data_set(root_hash) -- 32 bytes 4. Subnet includes root_hash in its certified state tree QUERY CALL (single replica, no consensus): 1. Client sends query 2. Canister calls data_certificate() -- gets subnet BLS signature 3. Canister builds witness (Merkle proof) for the requested key 4. Canister returns: { data, certificate, witness } CLIENT VERIFICATION: 1. Verify certificate signature against IC root public key 2. Extract root_hash from certificate's state tree 3. Verify witness: root_hash + witness proves data is in the tree 4. Trust the data ``` ## Implementation ### Rust **Cargo.toml:** ```toml [package] name = "certified_vars_backend" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] candid = "0.10" ic-cdk = "0.19" ic-certified-map = "0.4" serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" ciborium = "0.2" ``` **Complete certified key-value store:** ```rust use candid::{CandidType, Deserialize}; use ic_cdk::{init, post_upgrade, query, update}; use ic_certified_map::{AsHashTree, RbTree}; use serde_bytes::ByteBuf; use std::cell::RefCell; thread_local! { // RbTree is a Merkle-tree-backed map: keys and values are byte slices static TREE: RefCell, Vec>> = RefCell::new(RbTree::new()); } // Update the certified data hash after any modification fn update_certified_data() { TREE.with(|tree| { let tree = tree.borrow(); // root_hash() returns a 32-byte SHA-256 hash of the entire tree ic_cdk::api::certified_data_set(&tree.root_hash()); }); } #[init] fn init() { update_certified_data(); } #[post_upgrade] fn post_upgrade() { // Assumes data has already been deserialized from stable memory into the TREE. // CRITICAL: re-establish certification after upgrade — certified_data is cleared on upgrade. update_certified_data(); } #[update] fn set(key: String, value: String) { TREE.with(|tree| { let mut tree = tree.borrow_mut(); tree.insert(key.as_bytes().to_vec(), value.as_bytes().to_vec()); }); // Must update certified hash after every data change update_certified_data(); } #[update] fn delete(key: String) { TREE.with(|tree| { let mut tree = tree.borrow_mut(); tree.delete(key.as_bytes()); }); update_certified_data(); } #[derive(CandidType, Deserialize)] struct CertifiedResponse { value: Option, certificate: ByteBuf, // subnet BLS signature witness: ByteBuf, // Merkle proof for this key } #[query] fn get(key: String) -> CertifiedResponse { // data_certificate() is only available in query calls let certificate = ic_cdk::api::data_certificate() .expect("data_certificate only available in query calls"); TREE.with(|tree| { let tree = tree.borrow(); // Look up the value let value = tree.get(key.as_bytes()) .map(|v| String::from_utf8(v.clone()).unwrap()); // Build a witness (Merkle proof) for this specific key let witness = tree.witness(key.as_bytes()); // Serialize the witness as CBOR let mut witness_buf = vec![]; ciborium::into_writer(&witness, &mut witness_buf) .expect("Failed to serialize witness as CBOR"); CertifiedResponse { value, certificate: ByteBuf::from(certificate), witness: ByteBuf::from(witness_buf), } }) } // Batch set multiple values in one update call (more efficient) #[update] fn set_many(entries: Vec<(String, String)>) { TREE.with(|tree| { let mut tree = tree.borrow_mut(); for (key, value) in entries { tree.insert(key.as_bytes().to_vec(), value.as_bytes().to_vec()); } }); // Single certification update for all changes update_certified_data(); } ``` ### HTTP Certification (v2) for Custom HTTP Canisters For canisters serving HTTP responses directly (not through the asset canister), responses must be certified so the HTTP gateway can verify them. **Additional Cargo.toml dependency:** ```toml [package] name = "http_certified_backend" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] ic-http-certification = "3.1" ``` **Certifying HTTP responses:** > **Note:** The HTTP certification API is evolving rapidly. Verify these examples against the latest [ic-http-certification docs](https://docs.rs/ic-http-certification) before use. ```rust use ic_http_certification::{ HttpCertification, HttpCertificationPath, HttpCertificationTree, HttpCertificationTreeEntry, HttpRequest, HttpResponse, DefaultCelBuilder, DefaultResponseCertification, }; use std::cell::RefCell; thread_local! { static HTTP_TREE: RefCell = RefCell::new( HttpCertificationTree::default() ); } // Define what gets certified using CEL (Common Expression Language) fn certify_response(path: &str, request: &HttpRequest, response: &HttpResponse) { // Full certification: certify both request path and response body let cel = DefaultCelBuilder::full_certification() .with_response_certification(DefaultResponseCertification::certified_response_headers( vec!["Content-Type", "Content-Length"], )) .build(); // Create the certification from the CEL expression, request, and response let certification = HttpCertification::full(&cel, request, response, None) .expect("Failed to create HTTP certification"); let http_path = HttpCertificationPath::exact(path); HTTP_TREE.with(|tree| { let mut tree = tree.borrow_mut(); let entry = HttpCertificationTreeEntry::new(http_path, certification); tree.insert(&entry); // Update canister certified data with tree root hash ic_cdk::api::certified_data_set(&tree.root_hash()); }); } ``` ### Motoko **Using CertifiedData module:** ```motoko import CertifiedData "mo:core/CertifiedData"; import Blob "mo:core/Blob"; import Nat8 "mo:core/Nat8"; import Text "mo:core/Text"; import Map "mo:core/Map"; import Array "mo:core/Array"; import Iter "mo:core/Iter"; // Requires: mops add sha2 import Sha256 "mo:sha2/Sha256"; persistent actor { // Simple certified single-value example: var certifiedValue : Text = ""; // Set a certified value (update call only) public func setCertifiedValue(value : Text) : async () { certifiedValue := value; // Hash the value and set as certified data (max 32 bytes) let hash = Sha256.fromBlob(#sha256, Text.encodeUtf8(value)); CertifiedData.set(hash); }; // Get the certified value with its certificate (query call) public query func getCertifiedValue() : async { value : Text; certificate : ?Blob; } { { value = certifiedValue; certificate = CertifiedData.getCertificate(); } }; }; ``` **Certified key-value store with Merkle tree (advanced):** For certifying multiple values with per-key witnesses, use the `ic-certification` mops package (`mops add ic-certification`). It provides a real Merkle tree (`CertTree`) that can generate proofs for individual keys: ```motoko import CertifiedData "mo:core/CertifiedData"; import Blob "mo:core/Blob"; import Text "mo:core/Text"; // Requires: mops add ic-certification import CertTree "mo:ic-certification/CertTree"; persistent actor { // CertTree.Store is stable -- persists across upgrades let certStore : CertTree.Store = CertTree.newStore(); let ct = CertTree.Ops(certStore); // Set certified data on init ct.setCertifiedData(); // Set a key-value pair and update certification public func set(key : Text, value : Text) : async () { ct.put([Text.encodeUtf8(key)], Text.encodeUtf8(value)); // CRITICAL: call after every mutation to update the subnet-certified root hash ct.setCertifiedData(); }; // Delete a key and update certification public func remove(key : Text) : async () { ct.delete([Text.encodeUtf8(key)]); ct.setCertifiedData(); }; // Query with certificate and Merkle witness for the requested key public query func get(key : Text) : async { value : ?Blob; certificate : ?Blob; witness : Blob; } { let path = [Text.encodeUtf8(key)]; // reveal() generates a Merkle proof for this specific path let witness = ct.reveal(path); { value = ct.lookup(path); certificate = CertifiedData.getCertificate(); witness = ct.encodeWitness(witness); } }; // Re-establish certification after upgrade // (CertTree.Store is stable, so the tree data survives, but certified_data is cleared) system func postupgrade() { ct.setCertifiedData(); }; }; ``` ### Frontend Verification (TypeScript) Uses `@dfinity/certificate-verification` which handles the full 6-step verification: 1. Verify certificate BLS signature against IC root key 2. Validate certificate freshness (`/time` within `maxCertificateTimeOffsetMs`) 3. CBOR-decode the witness into a HashTree 4. Reconstruct the witness root hash 5. Compare reconstructed root hash with `certified_data` from the certificate 6. Return the verified HashTree for value lookup ```typescript import { verifyCertification } from "@dfinity/certificate-verification"; import { lookup_path, HashTree } from "@icp-sdk/core/agent"; import { Principal } from "@icp-sdk/core/principal"; const MAX_CERT_TIME_OFFSET_MS = 5 * 60 * 1000; // 5 minutes async function getVerifiedValue( rootKey: ArrayBuffer, canisterId: string, key: string, response: { value: string | null; certificate: ArrayBuffer; witness: ArrayBuffer } ): Promise { // verifyCertification performs steps 1-5: // - verifies BLS signature on the certificate // - checks certificate /time is within maxCertificateTimeOffsetMs // - CBOR-decodes the witness into a HashTree // - reconstructs root hash from the witness tree // - compares it against certified_data in the certificate // Throws CertificateTimeError or CertificateVerificationError on failure. const tree: HashTree = await verifyCertification({ canisterId: Principal.fromText(canisterId), encodedCertificate: response.certificate, encodedTree: response.witness, rootKey, maxCertificateTimeOffsetMs: MAX_CERT_TIME_OFFSET_MS, }); // Step 6: Look up the specific key in the verified witness tree. // The path must match how the canister inserted the key (e.g., key as UTF-8 bytes). const leafData = lookup_path([new TextEncoder().encode(key)], tree); if (!leafData) { // Key is provably absent from the certified tree return null; } const verifiedValue = new TextDecoder().decode(leafData); // Confirm the canister-returned value matches the witness-proven value if (response.value !== null && response.value !== verifiedValue) { throw new Error( "Response value does not match witness — canister returned tampered data" ); } return verifiedValue; } ``` For asset canisters, the HTTP gateway (boundary node) verifies certification transparently using the [HTTP Gateway Protocol](https://docs.internetcomputer.org/references/http-gateway-protocol-spec) -- no client-side code needed. ## Deploy & Test ```bash # Deploy the canister icp deploy backend # Set a certified value (update call -- goes through consensus) icp canister call backend set '("greeting", "hello world")' # Query the certified value icp canister call backend get '("greeting")' # Returns: record { value = opt "hello world"; certificate = blob "..."; witness = blob "..." } # Set multiple values icp canister call backend set '("name", "Alice")' icp canister call backend set '("age", "30")' # Delete a value icp canister call backend delete '("age")' # Verify the root hash is being set # (No direct command -- verified by the presence of a non-null certificate in query response) ``` ## Verify It Works ```bash # 1. Verify certificate is present in query response icp canister call backend get '("greeting")' # Expected: certificate field is a non-empty blob (NOT null) # If certificate is null, you are calling from an update context (wrong) # 2. Verify data integrity after update icp canister call backend set '("key1", "value1")' icp canister call backend get '("key1")' # Expected: value = opt "value1" with valid certificate # 3. Verify certification survives canister upgrade icp canister call backend set '("persistent", "data")' icp deploy backend # triggers upgrade icp canister call backend get '("persistent")' # Expected: certificate is still non-null (postupgrade re-established certification) # Note: data persistence depends on stable storage implementation # 4. Verify non-existent key returns null value with valid certificate icp canister call backend get '("nonexistent")' # Expected: value = null, certificate = blob "..." (certificate still valid) # 5. Frontend verification test # Open browser developer tools, check network requests # Query responses should include IC-Certificate header # The service worker (if using asset canister) validates automatically # Console should NOT show "Certificate verification failed" errors # 6. For HTTP certification (custom HTTP canister): curl -v https://CANISTER_ID.icp.net/path # Expected: Response headers include IC-Certificate # HTTP gateway verifies the certificate before forwarding to client ``` --- ## ckBTC (chain-key Bitcoin) --- name: ckbtc description: "Accept, send, and manage ckBTC (chain-key Bitcoin). Covers BTC deposit flow via minter, ckBTC transfers, withdrawal to BTC, subaccount derivation, and UTXO management. Use when integrating Bitcoin, ckBTC, BTC deposits, or BTC withdrawals in a canister. Do NOT use for plain token transfers without BTC minting/withdrawal — use icrc-ledger instead." license: Apache-2.0 compatibility: "icp-cli >= 0.2.2" metadata: title: ckBTC (chain-key Bitcoin) category: DeFi --- # Chain-Key Bitcoin (ckBTC) Integration ## What This Is ckBTC is a 1:1 BTC-backed token native to the Internet Computer. No bridges, no wrapping, no third-party custodians. The ckBTC minter canister holds real BTC and mints/burns ckBTC tokens. Transfers settle in 1-2 seconds with a 10 satoshi fee (versus minutes and thousands of satoshis on Bitcoin L1). ## Prerequisites - For Motoko: `mops` package manager, `core = "2.0.0"` in mops.toml - For Rust: `ic-cdk`, `icrc-ledger-types`, `candid`, `serde` ## Canister IDs ### Bitcoin Mainnet | Canister | ID | |---|---| | ckBTC Ledger | `mxzaz-hqaaa-aaaar-qaada-cai` | | ckBTC Minter | `mqygn-kiaaa-aaaar-qaadq-cai` | | ckBTC Index | `n5wcd-faaaa-aaaar-qaaea-cai` | | ckBTC Checker | `oltsj-fqaaa-aaaar-qal5q-cai` | ### Bitcoin Testnet4 | Canister | ID | |---|---| | ckBTC Ledger | `mc6ru-gyaaa-aaaar-qaaaq-cai` | | ckBTC Minter | `ml52i-qqaaa-aaaar-qaaba-cai` | | ckBTC Index | `mm444-5iaaa-aaaar-qaabq-cai` | ## How It Works ### Deposit Flow (BTC -> ckBTC) 1. Call `get_btc_address` on the minter with the user's principal + subaccount. This returns a unique Bitcoin address controlled by the minter. 2. User sends BTC to that address using any Bitcoin wallet. 3. Wait for Bitcoin confirmations (the minter requires confirmations before minting). 4. Call `update_balance` on the minter with the same principal + subaccount. The minter checks for new UTXOs and mints equivalent ckBTC to the user's ICRC-1 account. ### Transfer Flow (ckBTC -> ckBTC) Call `icrc1_transfer` on the ckBTC ledger. Fee is 10 satoshis. Settles in 1-2 seconds. ### Withdrawal Flow (ckBTC -> BTC) 1. Call `icrc2_approve` on the ckBTC ledger to grant the minter canister an allowance to spend from your account. 2. Call `retrieve_btc_with_approval` on the minter with `{ address, amount, from_subaccount: null }`. 3. The minter uses the approval to burn the ckBTC and submits a Bitcoin transaction. 4. The BTC arrives at the destination address after Bitcoin confirmations. ### Subaccount Generation Each user gets a unique deposit address derived from their principal + an optional 32-byte subaccount. To give each user a distinct deposit address within your canister, derive subaccounts from a user-specific identifier (their principal or a sequential ID). ## Mistakes That Break Your Build 1. **Using the wrong minter canister ID.** The minter ID is `mqygn-kiaaa-aaaar-qaadq-cai`. Do not confuse it with the ledger (`mxzaz-...`) or index (`n5wcd-...`). 2. **Forgetting the 10 satoshi transfer fee.** Every `icrc1_transfer` deducts 10 satoshis beyond the amount. If the user has exactly 1000 satoshis and you transfer 1000, it fails with `InsufficientFunds`. Transfer `balance - 10` instead. 3. **Not calling `update_balance` after a BTC deposit.** Sending BTC to the deposit address does nothing until you call `update_balance`. The minter does not auto-detect deposits. Your app must call this. 4. **Using Account Identifier instead of ICRC-1 Account.** ckBTC uses the ICRC-1 standard: `{ owner: Principal, subaccount: ?Blob }`. Do NOT use the legacy `AccountIdentifier` (hex string) from the ICP ledger. 5. **Subaccount must be exactly 32 bytes or null.** Passing a subaccount shorter or longer than 32 bytes causes a trap. Pad with leading zeros if deriving from a shorter value. 6. **Calling `retrieve_btc` with amount below the minimum.** The minter has a minimum withdrawal amount (currently 50,000 satoshis / 0.0005 BTC). Below this, you get `AmountTooLow`. 7. **Not checking the `retrieve_btc` response for errors.** The response is a variant: `Ok` contains `{ block_index }`, `Err` contains specific errors like `MalformedAddress`, `InsufficientFunds`, `TemporarilyUnavailable`. Always match both arms. 8. **Forgetting `owner` in `get_btc_address` args.** If you omit `owner`, Candid sub-typing assigns null, and the minter returns the deposit address of the caller (the canister) instead of the user. ## Implementation ### Motoko #### mops.toml ```toml [package] name = "ckbtc-app" version = "0.1.0" [dependencies] core = "2.0.0" icrc2-types = "1.1.0" ``` #### icp.yaml Your backend canister calls the ckBTC ledger and minter by principal directly — no local ckBTC canister deployment needed. ```yaml canisters: - name: backend recipe: type: "@dfinity/motoko@v4.1.0" configuration: main: src/backend/main.mo ``` #### src/backend/main.mo ```motoko import Principal "mo:core/Principal"; import Blob "mo:core/Blob"; import Nat "mo:core/Nat"; import Nat8 "mo:core/Nat8"; import Nat64 "mo:core/Nat64"; import Array "mo:core/Array"; import Result "mo:core/Result"; import Error "mo:core/Error"; import Runtime "mo:core/Runtime"; persistent actor Self { // -- Types -- type Account = { owner : Principal; subaccount : ?Blob; }; type TransferArgs = { from_subaccount : ?Blob; to : Account; amount : Nat; fee : ?Nat; memo : ?Blob; created_at_time : ?Nat64; }; type TransferResult = { #Ok : Nat; // block index #Err : TransferError; }; type TransferError = { #BadFee : { expected_fee : Nat }; #BadBurn : { min_burn_amount : Nat }; #InsufficientFunds : { balance : Nat }; #TooOld; #CreatedInFuture : { ledger_time : Nat64 }; #Duplicate : { duplicate_of : Nat }; #TemporarilyUnavailable; #GenericError : { error_code : Nat; message : Text }; }; type UpdateBalanceResult = { #Ok : [UtxoStatus]; #Err : UpdateBalanceError; }; type UtxoStatus = { #ValueTooSmall : Utxo; #Tainted : Utxo; #Checked : Utxo; #Minted : { block_index : Nat64; minted_amount : Nat64; utxo : Utxo }; }; type Utxo = { outpoint : { txid : Blob; vout : Nat32 }; value : Nat64; height : Nat32; }; type UpdateBalanceError = { #NoNewUtxos : { required_confirmations : Nat32; pending_utxos : ?[PendingUtxo]; current_confirmations : ?Nat32; }; #AlreadyProcessing; #TemporarilyUnavailable : Text; #GenericError : { error_code : Nat64; error_message : Text }; }; type PendingUtxo = { outpoint : { txid : Blob; vout : Nat32 }; value : Nat64; confirmations : Nat32; }; type ApproveArgs = { from_subaccount : ?Blob; spender : Account; amount : Nat; expected_allowance : ?Nat; expires_at : ?Nat64; fee : ?Nat; memo : ?Blob; created_at_time : ?Nat64; }; type ApproveError = { #BadFee : { expected_fee : Nat }; #InsufficientFunds : { balance : Nat }; #AllowanceChanged : { current_allowance : Nat }; #Expired : { ledger_time : Nat64 }; #TooOld; #CreatedInFuture : { ledger_time : Nat64 }; #Duplicate : { duplicate_of : Nat }; #TemporarilyUnavailable; #GenericError : { error_code : Nat; message : Text }; }; type RetrieveBtcWithApprovalArgs = { address : Text; amount : Nat64; from_subaccount : ?Blob; }; type RetrieveBtcResult = { #Ok : { block_index : Nat64 }; #Err : RetrieveBtcError; }; type RetrieveBtcError = { #MalformedAddress : Text; #AlreadyProcessing; #AmountTooLow : Nat64; #InsufficientFunds : { balance : Nat64 }; #InsufficientAllowance : { allowance : Nat64 }; #TemporarilyUnavailable : Text; #GenericError : { error_code : Nat64; error_message : Text }; }; // -- Remote canister references (mainnet) -- transient let ckbtcLedger : actor { icrc1_transfer : shared (TransferArgs) -> async TransferResult; icrc1_balance_of : shared query (Account) -> async Nat; icrc1_fee : shared query () -> async Nat; icrc2_approve : shared (ApproveArgs) -> async { #Ok : Nat; #Err : ApproveError }; } = actor "mxzaz-hqaaa-aaaar-qaada-cai"; transient let ckbtcMinter : actor { get_btc_address : shared ({ owner : ?Principal; subaccount : ?Blob; }) -> async Text; update_balance : shared ({ owner : ?Principal; subaccount : ?Blob; }) -> async UpdateBalanceResult; retrieve_btc_with_approval : shared (RetrieveBtcWithApprovalArgs) -> async RetrieveBtcResult; } = actor "mqygn-kiaaa-aaaar-qaadq-cai"; // -- Subaccount derivation -- // Derive a 32-byte subaccount from a principal for per-user deposit addresses. func principalToSubaccount(p : Principal) : Blob { let bytes = Blob.toArray(Principal.toBlob(p)); let size = bytes.size(); // First byte is length, remaining padded to 32 bytes let sub = Array.tabulate(32, func(i : Nat) : Nat8 { if (i == 0) { Nat8.fromNat(size) } else if (i <= size) { bytes[i - 1] } else { 0 } }); Blob.fromArray(sub) }; // -- Deposit: Get user's BTC deposit address -- public shared ({ caller }) func getDepositAddress() : async Text { if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; let subaccount = principalToSubaccount(caller); await ckbtcMinter.get_btc_address({ owner = ?Principal.fromActor(Self); subaccount = ?subaccount; }) }; // -- Deposit: Check for new BTC and mint ckBTC -- public shared ({ caller }) func updateBalance() : async UpdateBalanceResult { if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; let subaccount = principalToSubaccount(caller); await ckbtcMinter.update_balance({ owner = ?Principal.fromActor(Self); subaccount = ?subaccount; }) }; // -- Check user's ckBTC balance -- public shared ({ caller }) func getBalance() : async Nat { if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; let subaccount = principalToSubaccount(caller); await ckbtcLedger.icrc1_balance_of({ owner = Principal.fromActor(Self); subaccount = ?subaccount; }) }; // -- Transfer ckBTC to another user -- public shared ({ caller }) func transfer(to : Principal, amount : Nat) : async TransferResult { if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; let fromSubaccount = principalToSubaccount(caller); await ckbtcLedger.icrc1_transfer({ from_subaccount = ?fromSubaccount; to = { owner = to; subaccount = null }; amount = amount; fee = ?10; // 10 satoshis memo = null; created_at_time = null; }) }; // -- Withdraw: Convert ckBTC back to BTC -- public shared ({ caller }) func withdraw(btcAddress : Text, amount : Nat64) : async RetrieveBtcResult { if (Principal.isAnonymous(caller)) { Runtime.trap("Authentication required") }; // Step 1: Approve the minter to spend ckBTC from the user's subaccount let fromSubaccount = principalToSubaccount(caller); let approveResult = await ckbtcLedger.icrc2_approve({ from_subaccount = ?fromSubaccount; spender = { owner = Principal.fromText("mqygn-kiaaa-aaaar-qaadq-cai"); subaccount = null; }; amount = Nat64.toNat(amount) + 10; // amount + fee for the minter's burn expected_allowance = null; expires_at = null; fee = ?10; memo = null; created_at_time = null; }); switch (approveResult) { case (#Err(e)) { return #Err(#GenericError({ error_code = 0; error_message = "Approve for minter failed" })) }; case (#Ok(_)) {}; }; // Step 2: Call retrieve_btc_with_approval on the minter await ckbtcMinter.retrieve_btc_with_approval({ address = btcAddress; amount = amount; from_subaccount = ?fromSubaccount; }) }; }; ``` ### Rust #### Cargo.toml ```toml [package] name = "ckbtc_backend" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] ic-cdk = "0.19" ic-cdk-timers = "1.0" candid = "0.10" serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" icrc-ledger-types = "0.1" ``` #### src/lib.rs ```rust use candid::{CandidType, Deserialize, Nat, Principal}; use ic_cdk::update; use ic_cdk::call::Call; use icrc_ledger_types::icrc1::account::Account; use icrc_ledger_types::icrc1::transfer::{TransferArg, TransferError}; use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError}; // -- Canister IDs -- const CKBTC_LEDGER: &str = "mxzaz-hqaaa-aaaar-qaada-cai"; const CKBTC_MINTER: &str = "mqygn-kiaaa-aaaar-qaadq-cai"; // -- Minter types -- #[derive(CandidType, Deserialize, Debug)] struct GetBtcAddressArgs { owner: Option, subaccount: Option>, } #[derive(CandidType, Deserialize, Debug)] struct UpdateBalanceArgs { owner: Option, subaccount: Option>, } #[derive(CandidType, Deserialize, Debug)] struct RetrieveBtcWithApprovalArgs { address: String, amount: u64, from_subaccount: Option>, } #[derive(CandidType, Deserialize, Debug)] struct RetrieveBtcOk { block_index: u64, } #[derive(CandidType, Deserialize, Debug)] enum RetrieveBtcError { MalformedAddress(String), AlreadyProcessing, AmountTooLow(u64), InsufficientFunds { balance: u64 }, InsufficientAllowance { allowance: u64 }, TemporarilyUnavailable(String), GenericError { error_code: u64, error_message: String }, } #[derive(CandidType, Deserialize, Debug)] struct Utxo { outpoint: OutPoint, value: u64, height: u32, } #[derive(CandidType, Deserialize, Debug)] struct OutPoint { txid: Vec, vout: u32, } #[derive(CandidType, Deserialize, Debug)] struct PendingUtxo { outpoint: OutPoint, value: u64, confirmations: u32, } #[derive(CandidType, Deserialize, Debug)] enum UtxoStatus { ValueTooSmall(Utxo), Tainted(Utxo), Checked(Utxo), Minted { block_index: u64, minted_amount: u64, utxo: Utxo, }, } #[derive(CandidType, Deserialize, Debug)] enum UpdateBalanceError { NoNewUtxos { required_confirmations: u32, pending_utxos: Option>, current_confirmations: Option, }, AlreadyProcessing, TemporarilyUnavailable(String), GenericError { error_code: u64, error_message: String }, } type UpdateBalanceResult = Result, UpdateBalanceError>; type RetrieveBtcResult = Result; // -- Subaccount derivation -- // Derive a 32-byte subaccount from a principal for per-user deposit addresses. fn principal_to_subaccount(principal: &Principal) -> [u8; 32] { let mut subaccount = [0u8; 32]; let principal_bytes = principal.as_slice(); subaccount[0] = principal_bytes.len() as u8; subaccount[1..1 + principal_bytes.len()].copy_from_slice(principal_bytes); subaccount } fn ledger_id() -> Principal { Principal::from_text(CKBTC_LEDGER).unwrap() } fn minter_id() -> Principal { Principal::from_text(CKBTC_MINTER).unwrap() } // -- Deposit: Get user's BTC deposit address -- #[update] async fn get_deposit_address() -> String { let caller = ic_cdk::api::msg_caller(); assert_ne!(caller, Principal::anonymous(), "Authentication required"); let subaccount = principal_to_subaccount(&caller); let args = GetBtcAddressArgs { owner: Some(ic_cdk::api::canister_self()), subaccount: Some(subaccount.to_vec()), }; let (address,): (String,) = Call::unbounded_wait(minter_id(), "get_btc_address") .with_arg(args) .await .expect("Failed to get BTC address") .candid_tuple() .expect("Failed to decode response"); address } // -- Deposit: Check for new BTC and mint ckBTC -- #[update] async fn update_balance() -> UpdateBalanceResult { let caller = ic_cdk::api::msg_caller(); assert_ne!(caller, Principal::anonymous(), "Authentication required"); let subaccount = principal_to_subaccount(&caller); let args = UpdateBalanceArgs { owner: Some(ic_cdk::api::canister_self()), subaccount: Some(subaccount.to_vec()), }; let (result,): (UpdateBalanceResult,) = Call::unbounded_wait(minter_id(), "update_balance") .with_arg(args) .await .expect("Failed to call update_balance") .candid_tuple() .expect("Failed to decode response"); result } // -- Check user's ckBTC balance -- #[update] async fn get_balance() -> Nat { let caller = ic_cdk::api::msg_caller(); assert_ne!(caller, Principal::anonymous(), "Authentication required"); let subaccount = principal_to_subaccount(&caller); let account = Account { owner: ic_cdk::api::canister_self(), subaccount: Some(subaccount), }; let (balance,): (Nat,) = Call::unbounded_wait(ledger_id(), "icrc1_balance_of") .with_arg(account) .await .expect("Failed to get balance") .candid_tuple() .expect("Failed to decode response"); balance } // -- Transfer ckBTC to another user -- #[update] async fn transfer(to: Principal, amount: Nat) -> Result { let caller = ic_cdk::api::msg_caller(); assert_ne!(caller, Principal::anonymous(), "Authentication required"); let from_subaccount = principal_to_subaccount(&caller); let args = TransferArg { from_subaccount: Some(from_subaccount), to: Account { owner: to, subaccount: None, }, amount, fee: Some(Nat::from(10u64)), // 10 satoshis memo: None, created_at_time: None, }; let (result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc1_transfer") .with_arg(args) .await .expect("Failed to call icrc1_transfer") .candid_tuple() .expect("Failed to decode response"); result } // -- Withdraw: Convert ckBTC back to BTC -- #[update] async fn withdraw(btc_address: String, amount: u64) -> RetrieveBtcResult { let caller = ic_cdk::api::msg_caller(); assert_ne!(caller, Principal::anonymous(), "Authentication required"); // Step 1: Approve the minter to spend ckBTC from the user's subaccount let from_subaccount = principal_to_subaccount(&caller); let approve_args = ApproveArgs { from_subaccount: Some(from_subaccount), spender: Account { owner: minter_id(), subaccount: None, }, amount: Nat::from(amount) + Nat::from(10u64), // amount + fee for the minter's burn expected_allowance: None, expires_at: None, fee: Some(Nat::from(10u64)), memo: None, created_at_time: None, }; let (approve_result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc2_approve") .with_arg(approve_args) .await .expect("Failed to call icrc2_approve") .candid_tuple() .expect("Failed to decode response"); if let Err(e) = approve_result { return Err(RetrieveBtcError::GenericError { error_code: 0, error_message: format!("Approve for minter failed: {:?}", e), }); } // Step 2: Call retrieve_btc_with_approval on the minter let args = RetrieveBtcWithApprovalArgs { address: btc_address, amount, from_subaccount: Some(from_subaccount.to_vec()), }; let (result,): (RetrieveBtcResult,) = Call::unbounded_wait(minter_id(), "retrieve_btc_with_approval") .with_arg(args) .await .expect("Failed to call retrieve_btc_with_approval") .candid_tuple() .expect("Failed to decode response"); result } // -- Export Candid interface -- ic_cdk::export_candid!(); ``` ## Deploy & Test ### Local Development There is no local ckBTC minter. For local testing, mock the minter interface or test against mainnet/testnet. ### Deploy to Mainnet ```bash # Deploy your backend canister icp deploy backend -e ic # Your canister calls the mainnet ckBTC canisters directly by principal ``` ### Using icp to Interact with ckBTC Directly ```bash # Check ckBTC balance for an account icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ -e ic # Get deposit address icp canister call mqygn-kiaaa-aaaar-qaadq-cai get_btc_address \ '(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' \ -e ic # Check for new deposits and mint ckBTC icp canister call mqygn-kiaaa-aaaar-qaadq-cai update_balance \ '(record { owner = opt principal "YOUR-PRINCIPAL"; subaccount = null })' \ -e ic # Transfer ckBTC (amount in e8s — 1 ckBTC = 100_000_000) icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer \ '(record { to = record { owner = principal "RECIPIENT-PRINCIPAL"; subaccount = null }; amount = 100_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; })' -e ic # Withdraw ckBTC to a BTC address (amount in satoshis, minimum 50_000) # Note: In production, use icrc2_approve + retrieve_btc_with_approval (see withdraw function above) icp canister call mqygn-kiaaa-aaaar-qaadq-cai retrieve_btc_with_approval \ '(record { address = "bc1q...your-btc-address"; amount = 50_000; from_subaccount = null })' \ -e ic # Check transfer fee icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_fee '()' -e ic ``` ## Verify It Works ### Check Balance ```bash icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ '(record { owner = principal "YOUR-PRINCIPAL"; subaccount = null })' \ -e ic # Expected: (AMOUNT : nat) — balance in satoshis (e8s) ``` ### Verify Transfer ```bash # Transfer 1000 satoshis icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_transfer \ '(record { to = record { owner = principal "RECIPIENT"; subaccount = null }; amount = 1_000; fee = opt 10; memo = null; from_subaccount = null; created_at_time = null; })' -e ic # Expected: (variant { Ok = BLOCK_INDEX : nat }) # Verify recipient received it icp canister call mxzaz-hqaaa-aaaar-qaada-cai icrc1_balance_of \ '(record { owner = principal "RECIPIENT"; subaccount = null })' \ -e ic # Expected: balance increased by 1000 ``` ### Verify Deposit Flow ```bash # 1. Get deposit address icp canister call YOUR-CANISTER getDepositAddress -e ic # Expected: "bc1q..." or "3..." — a valid Bitcoin address # 2. Send BTC to that address (external wallet) # 3. Check for new deposits icp canister call YOUR-CANISTER updateBalance -e ic # Expected: (variant { Ok = vec { variant { Minted = record { ... } } } }) # 4. Check ckBTC balance icp canister call YOUR-CANISTER getBalance -e ic # Expected: balance reflects minted ckBTC ``` ### Verify Withdrawal ```bash icp canister call YOUR-CANISTER withdraw '("bc1q...destination", 50_000 : nat64)' -e ic # Expected: (variant { Ok = record { block_index = BLOCK_INDEX : nat64 } }) # The BTC will arrive at the destination address after Bitcoin confirmations ``` --- ## Custom Domains --- name: custom-domains description: "Register and manage custom domains for IC canisters via the HTTP gateway custom domain service. Covers DNS record configuration (CNAME, TXT, ACME challenge), the .well-known/ic-domains file, domain registration/validation/update/deletion via the REST API, TLS certificate provisioning, and HttpAgent host configuration. Use when the user wants to serve a canister under a custom domain, configure DNS for IC, register a domain with boundary nodes, troubleshoot custom domain issues, or update/remove a custom domain. Do NOT use for general frontend hosting or asset canister configuration without custom domains — use asset-canister instead." license: Apache-2.0 compatibility: "curl, DNS registrar access, deployed canister" metadata: title: "Custom Domains" category: Frontend --- # Custom Domains ## What This Is By default, canisters are accessible at `.icp.net`. The custom domains service lets you serve any canister under your own domain (e.g., `yourdomain.com`). You configure DNS, deploy a domain ownership file to your canister, and register via a REST API. The HTTP gateways then handle TLS certificate provisioning, renewal, and routing automatically. Custom domains work at the boundary node level — they map a domain to any canister ID via DNS. This works with any canister that can serve `/.well-known/ic-domains` over HTTP, not just asset canisters. That includes asset canisters, Juno satellites, and custom canisters implementing `http_request`. ## Prerequisites - A registered domain from any registrar (e.g., Namecheap, GoDaddy, Cloudflare) - Access to edit DNS records for that domain - A deployed canister that serves `/.well-known/ic-domains` over HTTP (asset canisters, Juno satellites, or any canister implementing `http_request`) - `curl` for the registration API calls - `jq` (optional, for formatting JSON responses) ## Mistakes That Break Your Setup 1. **Not disabling your DNS provider's SSL/TLS.** Providers like Cloudflare enable Universal SSL by default. This interferes with the ACME challenge the IC uses to provision certificates and can prevent certificate renewal. Disable any certificate/SSL/TLS offering from your DNS provider before registering. 2. **Setting a CNAME on the apex domain.** Many DNS providers don't allow CNAME records on the apex (e.g., `example.com` with no subdomain). Use ANAME or ALIAS record types (CNAME flattening) if your provider supports them. Otherwise, use a subdomain like `www.example.com`. 3. **Missing the `_acme-challenge` CNAME.** Without `_acme-challenge.CUSTOM_DOMAIN` pointing to `_acme-challenge.CUSTOM_DOMAIN.icp2.io`, the HTTP gateways cannot obtain a TLS certificate. Registration will fail. 4. **Multiple TXT records on `_canister-id`.** If more than one TXT record exists for `_canister-id.CUSTOM_DOMAIN`, registration fails. Keep exactly one containing your canister ID. 5. **Forgetting the `.well-known/ic-domains` file.** The canister must serve `/.well-known/ic-domains` listing your custom domain. Without it, domain ownership verification fails during registration. 6. **Stale `_acme-challenge` TXT records from your DNS provider.** Previous ACME challenges by your provider may leave TXT records on `_acme-challenge.CUSTOM_DOMAIN` that don't appear in your dashboard. These conflict with the IC's ACME flow. Disable all TLS offerings from your provider to clear them. Verify with `dig TXT _acme-challenge.CUSTOM_DOMAIN`. 7. **Not explicitly registering the domain.** DNS configuration alone is not enough. You must call `POST /custom-domains/v1/CUSTOM_DOMAIN` to start registration. It is not automatic. 8. **Setting `HttpAgent`'s `host` to your custom domain.** `host` is the **API endpoint** canister calls go to, not the domain your frontend is served from. Your custom domain is the HTTP gateway — it does not serve `/api/v2`, so pointing `host` at it (or at `window.location.origin`) makes calls fail. You do not need to set `host`: a recent `@icp-sdk/core` `HttpAgent` resolves an omitted `host` to `https://icp-api.io` (the mainnet API boundary nodes) on a custom domain. Leave it unset, or set it explicitly to `https://icp-api.io` — never the gateway domain. 9. **Forgetting alternative origins for Internet Identity.** II principals depend on the origin domain. Switching from a canister URL to a custom domain changes principals. Configure `.well-known/ii-alternative-origins` to keep the same principals. See the `internet-identity` skill. ## Implementation ### Step 1: Configure DNS Records Add three DNS records (replace `CUSTOM_DOMAIN` with your domain, e.g., `app.example.com`): | Record Type | Host | Value | |---|---|---| | CNAME | `CUSTOM_DOMAIN` | `CUSTOM_DOMAIN.icp1.io` | | TXT | `_canister-id.CUSTOM_DOMAIN` | your canister ID (e.g., `hwvjt-wqaaa-aaaam-qadra-cai`) | | CNAME | `_acme-challenge.CUSTOM_DOMAIN` | `_acme-challenge.CUSTOM_DOMAIN.icp2.io` | Some DNS providers omit the main domain suffix. For `app.example.com` on such providers: - `app` instead of `app.example.com` - `_canister-id.app` instead of `_canister-id.app.example.com` - `_acme-challenge.app` instead of `_acme-challenge.app.example.com` For apex domains without CNAME support, use your provider's ANAME or ALIAS record type pointing to `CUSTOM_DOMAIN.icp1.io`. ### Step 2: Create the `ic-domains` File Your canister must serve `/.well-known/ic-domains` over HTTP. Create this file listing each custom domain on its own line: ```text app.example.com www.example.com ``` **Asset canister users:** place `.well-known/` inside your `public/` directory (Vite projects) or alongside your source files, and ensure `.ic-assets.json5` includes `{ "match": ".well-known", "ignore": false }` so the hidden directory gets deployed. See the `asset-canister` skill for details on file placement. **Custom `http_request` canisters:** serve the file contents at `/.well-known/ic-domains` directly from your HTTP request handler. ### Step 3: Deploy Deploy your canister so that `/.well-known/ic-domains` is accessible at `https://.icp.net/.well-known/ic-domains`. ### Step 4: Validate Check DNS records and canister configuration before registering: ```bash curl -sL -X GET "https://icp.net/custom-domains/v1/CUSTOM_DOMAIN/validate" | jq ``` Success response: ```json { "status": "success", "message": "Domain is eligible for registration: DNS records are valid and canister ownership is verified", "data": { "domain": "CUSTOM_DOMAIN", "canister_id": "CANISTER_ID", "validation_status": "valid" } } ``` If validation fails, common errors and fixes: | Error | Fix | |---|---| | Missing DNS CNAME record | Add the `_acme-challenge` CNAME pointing to `_acme-challenge.CUSTOM_DOMAIN.icp2.io` | | Missing DNS TXT record | Add the `_canister-id` TXT record with your canister ID | | Invalid DNS TXT record | Ensure the TXT value is a valid canister ID | | More than one DNS TXT record | Remove duplicate `_canister-id` TXT records, keep one | | Failed to retrieve known domains | Ensure `.well-known/ic-domains` is deployed and served by the canister | | Domain missing from list | Add the domain to the `ic-domains` file and redeploy | ### Step 5: Register ```bash curl -sL -X POST "https://icp.net/custom-domains/v1/CUSTOM_DOMAIN" | jq ``` Success response: ```json { "status": "success", "message": "Domain registration request accepted and may take a few minutes to process", "data": { "domain": "CUSTOM_DOMAIN", "canister_id": "CANISTER_ID" } } ``` ### Step 6: Wait for Certificate Provisioning Poll until `registration_status` is `registered`: ```bash curl -sL -X GET "https://icp.net/custom-domains/v1/CUSTOM_DOMAIN" | jq ``` Status values: `registering` → `registered` (success), or `failed` (check error message). After `registered`, wait a few more minutes for propagation to all HTTP gateways before testing. ## Updating a Custom Domain To point an existing custom domain at a different canister: 1. Update the `_canister-id` TXT record to the new canister ID. 2. Notify the service: ```bash curl -sL -X PATCH "https://icp.net/custom-domains/v1/CUSTOM_DOMAIN" | jq ``` 3. Check status: ```bash curl -sL -X GET "https://icp.net/custom-domains/v1/CUSTOM_DOMAIN" | jq ``` ## Removing a Custom Domain 1. Remove the `_canister-id` TXT record and `_acme-challenge` CNAME from DNS. 2. Notify the service: ```bash curl -sL -X DELETE "https://icp.net/custom-domains/v1/CUSTOM_DOMAIN" | jq ``` 3. Confirm deletion (should return 404): ```bash curl -sL -X GET "https://icp.net/custom-domains/v1/CUSTOM_DOMAIN" | jq ``` ## HttpAgent Configuration A frontend served from your custom domain still makes its canister calls through the mainnet **API boundary nodes** (`https://icp-api.io`), not through the domain it is served from. `HttpAgent`'s `host` is that API endpoint — *not* your frontend's origin. The custom domain is the HTTP gateway and does not serve `/api/v2`. You do not need to set `host`. When it is omitted, a recent `@icp-sdk/core` `HttpAgent` resolves to `https://icp-api.io` on a custom domain (and on `icp.net`). Do **not** point `host` at your custom domain or `window.location.origin` — that is the gateway, so calls would fail. ```typescript import { HttpAgent } from "@icp-sdk/core/agent"; // host omitted on a custom domain → resolves to https://icp-api.io const agent = await HttpAgent.create(); // equivalent, explicit: const agentExplicit = await HttpAgent.create({ host: "https://icp-api.io" }); ``` ## Deploy & Test ```bash # 1. Deploy your canister with the ic-domains file served at /.well-known/ic-domains # 2. Validate DNS + canister config curl -sL -X GET "https://icp.net/custom-domains/v1/yourdomain.com/validate" | jq # 3. Register curl -sL -X POST "https://icp.net/custom-domains/v1/yourdomain.com" | jq # 4. Poll until registered curl -sL -X GET "https://icp.net/custom-domains/v1/yourdomain.com" | jq ``` ## Verify It Works ```bash # 1. Verify DNS records dig CNAME yourdomain.com # Expected: yourdomain.com. CNAME yourdomain.com.icp1.io. dig TXT _canister-id.yourdomain.com # Expected: "" dig CNAME _acme-challenge.yourdomain.com # Expected: _acme-challenge.yourdomain.com. CNAME _acme-challenge.yourdomain.com.icp2.io. # 2. Verify ic-domains file is served by the canister curl -sL "https://.icp.net/.well-known/ic-domains" # Expected: your domain listed # 3. Verify registration status is "registered" curl -sL -X GET "https://icp.net/custom-domains/v1/yourdomain.com" | jq '.data.registration_status' # Expected: "registered" # 4. Verify the custom domain serves your canister curl -sI "https://yourdomain.com" # Expected: HTTP/2 200 # 5. Verify no stale ACME TXT records dig TXT _acme-challenge.yourdomain.com # Expected: no TXT records (only the CNAME) ``` --- ## Cycles Management --- name: cycles-management description: "Manage cycles and canister lifecycle. Covers cycle balance checks, top-ups, freezing thresholds, canister creation, and ICP-to-cycles conversion via the CMC. Use when working with cycles, canister funding, freezing threshold, frozen canister, out of cycles, top-up, canister creation, or cycle balance. Do NOT use for wallet-to-dApp integration or ICRC signer flows — use wallet-integration instead." license: Apache-2.0 compatibility: "icp-cli >= 0.2.2" metadata: title: Cycles Management category: Infrastructure --- # Cycles & Canister Management ## What This Is Cycles are the computation fuel for canisters on Internet Computer. Every canister operation (execution, storage, messaging) burns cycles. When a canister runs out of cycles, it freezes and eventually gets deleted. 1 trillion cycles (1T) costs approximately 1 USD equivalent in ICP (the exact rate is set by the NNS and fluctuates with ICP price via the CMC). **Note:** icp-cli uses the **cycles ledger** (`um5iw-rqaaa-aaaaq-qaaba-cai`) by default. The cycles ledger is a single canister that tracks cycle balances for all principals, similar to a token ledger. Commands like `icp cycles balance`, `icp cycles mint`, and `icp canister top-up` go through the cycles ledger. There is no legacy wallet concept in icp-cli. The programmatic patterns below (accepting cycles, creating canisters via management canister) remain the same regardless of which funding mechanism is used. ## Prerequisites - For Motoko: `mops` package manager, `core = "2.0.0"` in mops.toml - For Rust: `ic-cdk >= 0.19` ## Canister IDs | Service | Canister ID | Purpose | |---------|------------|---------| | Cycles Minting Canister (CMC) | `rkp4c-7iaaa-aaaaa-aaaca-cai` | Converts ICP to cycles, creates canisters | | Cycles Ledger | `um5iw-rqaaa-aaaaq-qaaba-cai` | Tracks cycle balances for all principals | | Management Canister | `aaaaa-aa` | Canister lifecycle (create, install, stop, delete, status) | The Management Canister (`aaaaa-aa`) is a virtual canister -- it does not exist on a specific subnet but is handled by every subnet's execution layer. ## Mistakes That Break Your Build 1. **Running out of cycles silently freezes the canister** -- There is no warning. The canister stops responding to all calls. If cycles are not topped up before the freezing threshold, the canister and all its data will be permanently deleted. Set a freezing threshold and monitor balances. 2. **Not setting freezing_threshold** -- Default is 30 days. If your canister burns cycles fast (high traffic, large stable memory), 30 days may not be enough warning. Set it higher for production canisters. The freezing threshold defines how many seconds worth of idle cycles the canister must retain before it freezes. 3. **Confusing local vs mainnet cycles** -- Local replicas give canisters virtually unlimited cycles. Code that works locally may fail on mainnet because the canister has insufficient cycles. Always test with realistic cycle amounts before mainnet deployment. 4. **Sending cycles to the wrong canister** -- Cycles sent to a canister cannot be retrieved. There is no refund mechanism for cycles transferred to the wrong principal. Double-check the canister ID before topping up. 5. **Forgetting to set the canister controller** -- If you lose the controller identity, you permanently lose the ability to upgrade, top up, or manage the canister. Always add a backup controller. Use `icp canister update-settings --add-controller PRINCIPAL` to add one. 6. **Using ExperimentalCycles in mo:core** -- In mo:core 2.0, the module is renamed to `Cycles`. `import ExperimentalCycles "mo:base/ExperimentalCycles"` will fail. Use `import Cycles "mo:core/Cycles"`. ## Implementation ### Motoko #### Checking and Accepting Cycles ```motoko import Cycles "mo:core/Cycles"; import Principal "mo:core/Principal"; import Runtime "mo:core/Runtime"; persistent actor { // Check this canister's cycle balance public query func getBalance() : async Nat { Cycles.balance() }; // Accept cycles sent with a call (for "tip jar" or payment patterns) public func deposit() : async Nat { let available = Cycles.available(); if (available == 0) { Runtime.trap("No cycles sent with this call") }; let accepted = Cycles.accept(available); accepted }; // Send cycles to another canister via inter-canister call public func topUpCanister(target : Principal) : async () { let targetActor = actor (Principal.toText(target)) : actor { deposit_cycles : shared () -> async (); }; // Attach 1T cycles to the call await (with cycles = 1_000_000_000_000) targetActor.deposit_cycles(); }; } ``` #### Creating a Canister Programmatically ```motoko import Principal "mo:core/Principal"; persistent actor Self { type CanisterId = { canister_id : Principal }; type CreateCanisterSettings = { controllers : ?[Principal]; compute_allocation : ?Nat; memory_allocation : ?Nat; freezing_threshold : ?Nat; }; // Management canister interface let ic = actor ("aaaaa-aa") : actor { create_canister : shared { settings : ?CreateCanisterSettings } -> async CanisterId; canister_status : shared { canister_id : Principal } -> async { status : { #running; #stopping; #stopped }; memory_size : Nat; cycles : Nat; settings : CreateCanisterSettings; module_hash : ?Blob; }; deposit_cycles : shared { canister_id : Principal } -> async (); stop_canister : shared { canister_id : Principal } -> async (); delete_canister : shared { canister_id : Principal } -> async (); }; // Create a new canister with 1T cycles public func createNewCanister() : async Principal { let result = await (with cycles = 1_000_000_000_000) ic.create_canister({ settings = ?{ controllers = ?[Principal.fromActor(Self)]; compute_allocation = null; memory_allocation = null; freezing_threshold = ?2_592_000; // 30 days in seconds }; }); result.canister_id }; // Check a canister's status and cycle balance public func checkStatus(canisterId : Principal) : async Nat { let status = await ic.canister_status({ canister_id = canisterId }); status.cycles }; // Top up another canister public func topUp(canisterId : Principal, amount : Nat) : async () { await (with cycles = amount) ic.deposit_cycles({ canister_id = canisterId }); }; } ``` ### Rust #### Checking Balance and Accepting Cycles ```rust use ic_cdk::{query, update}; use candid::Nat; #[query] fn get_balance() -> Nat { Nat::from(ic_cdk::api::canister_cycle_balance()) } #[update] fn deposit() -> Nat { let available = ic_cdk::api::msg_cycles_available(); if available == 0 { ic_cdk::trap("No cycles sent with this call"); } let accepted = ic_cdk::api::msg_cycles_accept(available); Nat::from(accepted) } ``` #### Creating and Managing Canisters ```rust use candid::{Nat, Principal}; use ic_cdk::update; use ic_cdk::management_canister::{ create_canister_with_extra_cycles, canister_status, deposit_cycles, stop_canister, delete_canister, CreateCanisterArgs, CanisterStatusArgs, DepositCyclesArgs, StopCanisterArgs, DeleteCanisterArgs, CanisterSettings, CanisterStatusResult, }; #[update] async fn create_new_canister() -> Principal { let caller = ic_cdk::api::canister_self(); // capture canister's own principal let user = ic_cdk::api::msg_caller(); // capture caller before await let settings = CanisterSettings { controllers: Some(vec![caller, user]), compute_allocation: None, memory_allocation: None, freezing_threshold: Some(Nat::from(2_592_000u64)), // 30 days reserved_cycles_limit: None, log_visibility: None, wasm_memory_limit: None, wasm_memory_threshold: None, environment_variables: None, }; let arg = CreateCanisterArgs { settings: Some(settings), }; // Send 1T cycles with the create call let result = create_canister_with_extra_cycles(&arg, 1_000_000_000_000u128) .await .expect("Failed to create canister"); result.canister_id } #[update] async fn check_status(canister_id: Principal) -> CanisterStatusResult { canister_status(&CanisterStatusArgs { canister_id }) .await .expect("Failed to get canister status") } #[update] async fn top_up(canister_id: Principal, amount: u128) { deposit_cycles(&DepositCyclesArgs { canister_id }, amount) .await .expect("Failed to deposit cycles"); } #[update] async fn stop_and_delete(canister_id: Principal) { stop_canister(&StopCanisterArgs { canister_id }) .await .expect("Failed to stop canister"); delete_canister(&DeleteCanisterArgs { canister_id }) .await .expect("Failed to delete canister"); } ``` ## Deploy & Test ### Check Cycle Balance ```bash # Check your canister's cycle balance icp canister status backend # Look for "Balance:" line in output # Check balance on mainnet icp canister status backend -e ic # Check any canister by ID icp canister status ryjl3-tyaaa-aaaaa-aaaba-cai -e ic ``` ### Top Up a Canister ```bash # Top up with cycles from the cycles ledger (local) icp canister top-up backend --amount 1000000000000 # Adds 1T cycles to the backend canister # Top up on mainnet icp canister top-up backend --amount 1000000000000 -e ic # Convert ICP to cycles and top up in one step (mainnet) icp cycles mint --icp 1.0 -e ic icp canister top-up backend --amount 1000000000000 -e ic ``` ### Create a Canister via icp ```bash # Create an empty canister (local) icp canister create my_canister # Create on mainnet with specific cycles icp canister create my_canister -e ic --cycles 2000000000000 # Add a backup controller icp canister update-settings my_canister --add-controller BACKUP_PRINCIPAL_HERE ``` ### Set Freezing Threshold ```bash # Set freezing threshold to 90 days (in seconds: 90 * 24 * 60 * 60 = 7776000) icp canister update-settings backend --freezing-threshold 7776000 # Mainnet icp canister update-settings backend --freezing-threshold 7776000 -e ic ``` ## Verify It Works ```bash # Deploy and check status icp network start -d icp deploy backend icp canister status backend # Expected output includes: # Status: Running # Balance: 3_100_000_000_000 Cycles (local default, varies) # Freezing threshold: 2_592_000 # Check balance programmatically (if you added getBalance) icp canister call backend getBalance '()' # Expected: a large nat value, e.g. (3_100_000_000_000 : nat) # On mainnet: verify cycles balance is not zero icp canister status backend -e ic # If Balance shows 0, the canister will freeze. Top up immediately. ``` --- ## Deploy to Cloud Engine --- name: deploy-to-cloud-engine description: "Deploys an already-built Internet Computer project to a user's own cloud engine (an OpenCloud / control-panel engine): verify the icp CLI, link the console identity with `icp identity link web` (console origin defaults to https://opencloud.org), obtain the engine's subnet id, run `icp deploy` against that subnet, and tag canisters with `__META_*` environment variables for a named app with labelled canisters and an icon. Also covers deploying and calling a funded proxy canister when an engine app must make cross-subnet cycle-bearing calls (the exchange-rate canister, threshold ECDSA/Schnorr, vetKD). Use when shipping an app to a cloud engine; on mention of OpenCloud, an engine subnet id, or linking the icp CLI to an engine console; when naming or adding an icon to a console app; or when an engine canister needs a cross-subnet cycle-bearing call (proxy canister). Do NOT use for a general mainnet deploy with no specific engine or subnet (use the icp-cli skill) or for writing general canister logic." license: Apache-2.0 compatibility: "icp-cli >= 0.3.0 (commands verified against 0.3.0), a cloud engine console account, a browser for the Internet Identity sign-in" metadata: title: Deploy to Cloud Engine category: Infrastructure --- # Deploy to Cloud Engine ## What This Is A **cloud engine** is a user-owned slice of Internet Computer capacity, administered from a web console (by default `https://opencloud.org`). Each engine runs on a single **subnet**. This skill takes a project that already builds and gets it deployed onto that engine, from a coding agent. This skill only covers the cloud-engine-specific steps: linking the CLI to the engine's console identity, and a subnet-targeted deploy. For everything else about the CLI (`icp.yaml`, recipes, environments, bindings, identities), load the **`icp-cli`** skill. Before running any `icp` command you are unsure of, run `icp --help` (e.g. `icp identity link --help`, `icp deploy --help`) to confirm the command and flags exist. Do not infer flags. Authoritative reference: https://cli.internetcomputer.org/llms.txt ## What You Need Two values. Look for them first in `icp.yaml` or earlier in the conversation. One has a default; the other you must ask for: 1. **Console origin** — the URL the user signs in to their cloud engine console with. **Defaults to `https://opencloud.org`** (the main OpenCloud console). It is used as the `--auth` origin in Step 1 so the linked CLI identity derives the **same principal that administers the engine**. Use the default, but say so and give the user a chance to override before linking: - Say: "I'll link the CLI against `https://opencloud.org`, the default console. If you sign in to your engine console at a different URL, tell me now." - Only use a different origin when the user names one — never substitute another URL on your own; the `--auth` origin determines the derived principal (see Pitfall 2). 2. **Subnet id** — the subnet the engine deploys to, required by `icp deploy --subnet`. There is **no default**; never guess it. The user finds it on the engine's **App Center / Applications** page in the console. If absent, **ask and do not proceed without it**: - Ask: "What is your engine's subnet id? It is shown on your engine's App Center / Applications page." Record both so you do not re-ask within the session. ## Prerequisites - `icp` on `$PATH` — see the **`icp-cli`** skill to install. Verify with `icp --version` (this skill's commands are verified against 0.3.0). - A project that already builds. If it does not build or package yet, set that up first (see the `icp-cli` skill), then return here. ## Step 1 — Link the CLI to your engine identity (once per machine) The CLI must sign as the **same identity that administers the engine** — that is the principal you log in to the console with. First check what already exists: ```bash icp identity list # names + principals; * marks the active identity ``` The list does **not** show which console (if any) an identity was linked against — that cannot be determined from the CLI. Decide like this: - **Only `anonymous` (or plain local identities) listed** — no web-linked identity exists; run the link command below. - **An identity the user recognizes as their engine identity** (by name or principal) — set it active (below) and skip to Step 2. - **Unsure** — ask the user, or simply relink under a new name; linking again is cheap and safe. To link, run this, substituting a name the user picks — `` is any local label, not a fixed value (do not hardcode something like `my-engine-admin`); reuse the **same** name in every command below: ```bash icp identity link web --auth ``` - Use `https://opencloud.org` as `` unless the user named a different console. Never omit `--auth`: the flag has a built-in default (`https://id.ai`) that is **not** your console and silently derives the wrong principal. - The command first waits at a **"Press Enter to log in"** prompt before anything happens. Run it interactively when you can; in a non-interactive shell (e.g. a background process) pipe a real newline: ```bash printf '\n' | icp identity link web --auth ``` Do **not** redirect stdin from `/dev/null` — the bare EOF does not satisfy the prompt, and the command sits on "Press Enter to log in" indefinitely with no browser ever opening. - After Enter it opens a **browser tab**. The **user** completes the Internet Identity sign-in there. Wait for them to confirm before continuing — you cannot complete the sign-in for them. - `--auth` must be the **exact** console origin (scheme + host), e.g. `https://opencloud.org`. A mismatched origin derives a *different* principal, and the engine will reject the deploy as unauthorized. - This is a **one-time, per-machine** step. Then make it the active identity and verify: ```bash icp identity default icp identity default # prints the active identity name icp identity principal # prints the principal the deploy will sign as ``` ## Step 2 — Name the app (and give it an icon) in the console (recommended) By default, CLI-deployed canisters appear on the engine console's Applications page as bare rows labelled only by their principal id. A set of **canister environment variables** makes the console group them into a single named application with readable per-canister labels, an "Open" button, and an icon. Set them once in your project config: - `__META_PROJECT` — the application name. Canisters that share the **same** value are grouped into one named app, so set an identical value on every canister of the app. - `__META_NAME` — the per-canister display label (e.g. `Backend`, `Frontend`). - `__META_MAIN_CANISTER` — the literal string `"true"` on exactly one canister (the entry point, usually the frontend/asset canister). This marks the app's **main canister**: the console reads `__META_BASE_URL` and `__META_ICON_PATH` only from it, and the "Open" button targets it. - `__META_BASE_URL` — an **absolute `https://` URL**, set on the main canister (e.g. the frontend canister's URL `https://.icp.net`, or a custom domain). When present and valid, it is the URL the "Open" button opens; when absent or not `https`, the "Open" button falls back to the main canister's gateway URL. It is also the base that `__META_ICON_PATH` resolves against. - `__META_ICON_PATH` — the path to the app icon, resolved against `__META_BASE_URL` to form the icon the console renders (e.g. `/favicon.svg` → `https:///favicon.svg`). Set it on the **main** canister, alongside `__META_BASE_URL`. The icon and "Open" link are read **only from the main canister** (the one marked `__META_MAIN_CANISTER: "true"`) — `__META_BASE_URL` / `__META_ICON_PATH` on any other canister are ignored. Set them under each canister's `settings.environment_variables` — this is valid alongside a recipe. With per-canister `canister.yaml` files: ```yaml # frontend/canister.yaml name: frontend recipe: type: "@dfinity/asset-canister@v2.2.1" configuration: build: - npm install - npm run build dir: dist settings: environment_variables: __META_PROJECT: "My App" __META_NAME: "Frontend" __META_MAIN_CANISTER: "true" __META_BASE_URL: "https://.icp.net" __META_ICON_PATH: "/favicon.svg" ``` ```yaml # backend/canister.yaml name: backend recipe: type: "@dfinity/motoko@v4.1.0" configuration: main: src/main.mo settings: environment_variables: __META_PROJECT: "My App" __META_NAME: "Backend" ``` For a single inline `icp.yaml` (canisters defined there directly), put the same `settings.environment_variables` block under each canister entry. Note the inline form: `canisters` is an **array** of `{name, recipe, settings}` items, not a map keyed by canister name: ```yaml # icp.yaml — canisters defined inline canisters: - name: frontend recipe: # … as in the canister.yaml example above settings: environment_variables: __META_PROJECT: "My App" __META_NAME: "Frontend" __META_MAIN_CANISTER: "true" __META_BASE_URL: "https://.icp.net" __META_ICON_PATH: "/favicon.svg" - name: backend recipe: # … as in the canister.yaml example above settings: environment_variables: __META_PROJECT: "My App" __META_NAME: "Backend" ``` Notes: - icp-cli **merges** these with the `PUBLIC_CANISTER_ID:` variables it injects automatically at deploy time — the asset canister keeps serving and the app keeps working. (Verified against icp-cli 0.3.0.) - All values are strings; `__META_MAIN_CANISTER` must be the exact string `"true"`. - They are applied during `icp deploy` (the "Setting environment variables" step). After deploy, confirm with `icp canister settings show -e ic`. Icon specifics (the console builds the icon as `__META_BASE_URL` + `__META_ICON_PATH`): - **Both** must be present and **on the main canister** for an icon to appear — there is no fallback. `__META_ICON_PATH` alone does nothing. - `__META_BASE_URL` must parse as an absolute **`https://`** URL. A bare host, an `http://` URL, or a `data:` / `javascript:` value is rejected: the icon then does not render, and the "Open" button falls back to the main canister's gateway URL (it does not disappear). (The console validates the scheme before using it.) - `__META_ICON_PATH` is a **path to an asset your frontend actually serves** (e.g. `/favicon.svg`), not an inline image. The resolved URL is rendered as an `` `src`, so it must return an image. Do **not** put a `data:` URI here: engine env values are length-capped (≤128 chars observed), so it would not fit, and the field is a path by design. - The frontend canister's id is only known **after** the first deploy. The usual flow is: deploy once, read the frontend canister id from the output, set `__META_BASE_URL` to `https://.icp.net` (and `__META_ICON_PATH`), then re-deploy to apply. If you control a custom domain for the app, you can set it up front instead. ## Step 3 — Deploy to the engine's subnet From the project root: ```bash icp deploy -e ic --subnet ``` - `-e ic` targets mainnet (the engine runs on an IC subnet); `--subnet ` pins the deploy to **your engine's** subnet. Confirm the exact flags with `icp deploy --help` before running if unsure. - Deploying consumes capacity on the engine; make sure the engine has room. **Alternative — packaged upload.** If the project is distributed as a built `.icp` package and a direct `icp deploy` is not available, upload the bundle on the console's App Center via **"Upload a custom app"** instead. ## Step 4 — Verify - The `icp deploy` output reports the deployed canister ids. - The canisters appear on the engine's **Applications** page in the console; each canister's detail view offers an "Open in browser" link. - If you set the metadata in Step 2, the canisters are grouped under your `__META_PROJECT` name with their `__META_NAME` labels, and the main canister shows an "Open" button — instead of bare principal rows. With `__META_BASE_URL` + `__META_ICON_PATH` set, the app also shows its icon (allow for a short console cache delay). - A frontend (asset) canister is served at `https://.icp.net`. Report the deployed canister ids (and the frontend URL, if any) back to the user. ## Cross-subnet calls via a proxy canister (only if the app needs them) A cloud engine runs on a **`CloudEngine` subnet**, which the IC protocol restricts: a canister on it **cannot** send a cross-subnet (XNet) message that carries **attached cycles** or that is a **guaranteed-response (unbounded-wait)** call. So an engine canister **cannot call these directly**, because they live on other subnets and require cycles: - the **exchange-rate canister** (XRC), - **threshold ECDSA / Schnorr** signing (`sign_with_ecdsa`, `sign_with_schnorr`) and their public-key methods, - **vetKD** (`vetkd_derive_key`, `vetkd_public_key`), - any other canister you must call **with cycles** across a subnet boundary. The workaround is a **proxy canister**: deployed on a normal Application subnet and funded with cycles. Your engine canister makes a cheap, cycle-less, bounded-wait call to the proxy, which re-issues it locally **with the cycles attached** and relays the raw reply back. Same-subnet or cycle-less calls do **not** need it. ### Deploy and fund the proxy (from the console, not the CLI) 1. Open the engine console → **Applications** (App Center) → **Proxy canisters**. 2. **Deploy a proxy**, choose an initial balance (minimum $5), and optionally enable **automatic top-up** to recharge from the saved card when it runs low. You can top up manually or delete the proxy later from the same table. 3. Copy the **proxy canister id** shown in the table — the app calls this id. The proxy authorizes callers whose principal falls in **the engine's canister-id range**, so every canister deployed to the engine may use it with no extra configuration. ### Call through the proxy from your canister Instead of calling the target canister directly, call the proxy's `proxy` method with the target id, method name, candid-encoded argument bytes, and the cycles to attach. Its candid interface: ```candid type ProxyArgs = record { canister_id : principal; method : text; args : blob; cycles : nat }; type ProxySucceed = record { result : blob }; type ProxyError = variant { InsufficientCycles : record { available : nat; required : nat }; CallFailed : record { reason : text }; UnauthorizedUser; }; type ProxyResult = variant { Ok : ProxySucceed; Err : ProxyError }; service : { proxy : (ProxyArgs) -> (ProxyResult); get_allowed_ranges : () -> (vec record { start : principal; end : principal }) query; }; ``` - `args` is the **candid-encoded argument of the _target_ method** (you encode it); `result` is the target's **raw reply bytes** (you decode it). - `cycles` is what the proxy attaches to the relayed call — size it to what the target charges (e.g. the XRC or signing fee). Do **not** attach cycles to the outer `proxy` call itself; the engine subnet forbids that and it is unnecessary. - Handle `ProxyError`: `InsufficientCycles` means the proxy's balance is too low (top it up in the console), `UnauthorizedUser` means the caller is outside the engine's range, `CallFailed` carries the downstream reject reason. Motoko sketch (Rust is analogous with `candid::encode_one` / `decode_one`): ```motoko // `proxy` is an actor typed to the candid interface above. let arg = to_candid (request); // encode the TARGET method's argument let res = await proxy.proxy({ canister_id = xrcPrincipal; method = "get_exchange_rate"; args = arg; cycles = 1_000_000_000; // the XRC fee the proxy forwards }); switch (res) { case (#Ok { result }) { let ?reply = from_candid (result) else return; /* … */ }; case (#Err e) { /* InsufficientCycles | CallFailed | UnauthorizedUser */ }; }; ``` ### Threshold keys through the proxy (read this before deriving keys) For the management-canister key methods (`sign_with_ecdsa`, `ecdsa_public_key`, `sign_with_schnorr`, `schnorr_public_key`, `vetkd_derive_key`, `vetkd_public_key`), the proxy **isolates the derivation per calling canister**: it prefixes the caller's (unforgeable) principal into the `derivation_path` (ECDSA/Schnorr) or `context` (vetKD), and forces `canister_id = None` on the `*_public_key` calls. Two consequences: - Each engine canister behind the proxy gets its **own** key namespace — one canister cannot read or sign with another's key. - The key/address obtained **through the proxy differs** from what a direct management-canister call would give (the injected prefix changes the derivation). Always fetch the public key and sign **through the same proxy**, consistently, so the address you derive matches the key you can sign for. Do not mix direct and proxied key calls for one identity. ## Common Pitfalls 1. **Sign-in not completed.** Running `icp identity link web …` but not finishing the Internet Identity sign-in in the browser leaves the CLI unlinked; later commands fail with authorization errors. Re-run and wait for the user to confirm the browser flow finished. If no browser ever opened, the command is stalled at the "Press Enter to log in" prompt — relaunch with a piped newline, `printf '\n' | icp identity link web …`, never `< /dev/null` (see Step 1). 2. **Wrong `--auth` origin.** Using any URL other than the console origin the user signs in with derives a different principal, and the engine rejects the deploy as not authorized. Relink with the exact console URL. If the deploy is rejected as unauthorized after linking against the default `https://opencloud.org`, ask the user for the exact URL they sign in with and relink. 3. **Guessing the subnet id.** Never invent it — the deploy fails or targets the wrong subnet. It is on the engine's App Center / Applications page; ask the user. 4. **Deploying with the anonymous identity.** The default local identity is anonymous and is not the engine admin. You must link and `icp identity default ` first. 5. **Using `dfx`.** This ecosystem uses `icp`, never `dfx`. The correct sequence is `icp identity link web --auth ` (Step 1), then `icp deploy -e ic --subnet ` (Step 3). See the `icp-cli` skill. 6. **Skipping the app metadata.** Without `__META_PROJECT` (Step 2), the canisters still deploy and work but render as bare, unnamed principal rows in the console. Setting `__META_*` is what produces a named app with labelled canisters and an "Open" button. 7. **Wrong `__META_MAIN_CANISTER` value.** It is matched as the exact string `"true"`. A boolean, `"True"`, or marking more than one canister means no (or the wrong) "Open" button. Mark exactly one entry-point canister. 8. **Inventing an icon variable.** The icon variable is `__META_ICON_PATH` (a path resolved against `__META_BASE_URL`). Do not guess `__META_ICON`, `__META_LOGO`, or `__META_ICON_LINK` — they are ignored, so the icon silently never appears. 9. **Icon set on the wrong canister, or without a base URL.** The icon is read only from the **main** canister and needs **both** `__META_BASE_URL` (a valid absolute `https://` URL) and `__META_ICON_PATH`. Setting the icon path on a side canister, omitting the base URL, or giving a non-https / `data:` base means no icon renders. (The "Open" button still works — it falls back to the main canister's gateway URL — so a bad base URL costs the icon and the custom Open URL, not the button.) 10. **Calling the XRC / threshold signing / vetKD directly from an engine canister.** These are cross-subnet, cycle-bearing calls, which a `CloudEngine`-subnet canister cannot make — the call is rejected. Route them through a funded proxy canister (see "Cross-subnet calls via a proxy canister"). Plain same-subnet or cycle-less calls do not need the proxy. 11. **Attaching cycles to the outer `proxy` call.** The engine subnet forbids cycle-bearing cross-subnet messages — that is the whole reason for the proxy. Put the cycles inside `ProxyArgs.cycles` (the proxy attaches them locally); never `with_cycles` on the call to `proxy` itself. 12. **Proxy out of cycles, or funded from the CLI.** A `ProxyError::InsufficientCycles` means the proxy's balance is spent — top it up (or enable auto top-up) on the console's Proxy canisters page. Deploying and funding the proxy is a console action, not an `icp` command. 13. **Expecting a direct-call key through the proxy.** Threshold-key derivation via the proxy is caller-isolated, so the derived key/address is not the same as a direct management-canister call. Fetch the public key and sign through the proxy consistently; do not mix direct and proxied key calls for the same identity. ## Related Skills - **icp-cli** — general icp CLI usage (`icp.yaml`, recipes, environments, bindings, identities). Load it for anything beyond this cloud-engine deploy flow. - **internet-identity** — details of the Internet Identity sign-in that Step 1 triggers in the browser. --- ## EVM RPC --- name: evm-rpc description: "Call Ethereum and EVM chains from IC canisters (Rust) via the EVM RPC canister using the evm_rpc_client crate. Covers typed API calls, raw JSON-RPC, multi-provider consensus, ERC-20 reads, and sending pre-signed transactions. Use when calling Ethereum, Arbitrum, Base, Optimism, or any EVM chain from a Rust canister. Do NOT use for generic HTTPS calls to non-EVM APIs — use https-outcalls instead." license: Apache-2.0 compatibility: "icp-cli >= 0.2.2" metadata: title: EVM RPC category: Integration --- # EVM RPC Canister — Calling Ethereum from IC ## What This Is The EVM RPC canister is an IC system canister that proxies JSON-RPC calls to Ethereum and EVM-compatible chains via HTTPS outcalls. Your canister sends a request to the EVM RPC canister, which fans it out to multiple RPC providers, compares responses for consensus, and returns the result. No API keys required for default providers. No bridges or oracles needed. ## Prerequisites - `evm_rpc_client` crate (provides typed client API and re-exports `evm_rpc_types`) ## Canister IDs | Canister | ID | Subnet | |---|---|---| | EVM RPC (mainnet) | `7hfb6-caaaa-aaaar-qadga-cai` | 34-node fiduciary | Candid interface: `https://github.com/dfinity/evm-rpc-canister/releases/latest/download/evm_rpc.did` — or use the `canhelp` skill to fetch it directly from the mainnet canister. ## Supported Chains | Chain | Chain ID | Candid | Rust | |---|---|---|---| | Ethereum Mainnet | 1 | `variant { EthMainnet }` | `RpcServices::EthMainnet` | | Ethereum Sepolia | 11155111 | `variant { EthSepolia }` | `RpcServices::EthSepolia` | | Arbitrum One | 42161 | `variant { ArbitrumOne }` | `RpcServices::ArbitrumOne` | | Base Mainnet | 8453 | `variant { BaseMainnet }` | `RpcServices::BaseMainnet` | | Optimism Mainnet | 10 | `variant { OptimismMainnet }` | `RpcServices::OptimismMainnet` | | Custom EVM chain | any | `variant { Custom }` | `RpcServices::Custom` | ## RPC Providers Built-in providers (no API key needed for defaults): | Provider | Ethereum | Sepolia | Arbitrum | Base | Optimism | |---|---|---|---|---|---| | Alchemy | yes | yes | yes | yes | yes | | Ankr | yes | - | yes | yes | yes | | BlockPi | yes | yes | yes | yes | yes | | Cloudflare | yes | - | - | - | - | | LlamaNodes | yes | - | yes | yes | yes | | PublicNode | yes | yes | yes | yes | yes | ## Cycle Costs **Formula:** ``` (5_912_000 + 60_000 * nodes + 2400 * request_bytes + 800 * max_response_bytes) * nodes * rpc_count ``` Where `nodes` = 34 (fiduciary subnet), `rpc_count` = number of providers queried. **Practical guidance:** Send 10_000_000_000 cycles (10B) as a starting budget. Unused cycles are refunded. Typical calls cost 100M-1B cycles (~$0.0001-$0.001 USD). Use `requestCost` to get an exact estimate before calling. ## Pitfalls 1. **Not sending enough cycles.** Every EVM RPC call requires cycles attached. The `evm_rpc_client` defaults to 10B cycles per call, but if you override with `.with_cycles()`, sending too few causes silent failures or traps. 2. **Using default `Equality` consensus.** The default consensus strategy requires all providers to return identical responses. This fails for queries like `eth_getBlockByNumber(Latest)` where providers are often 1-2 blocks apart. Use `ConsensusStrategy::Threshold { total: Some(3), min: 2 }` (2-of-3 agreement) for most use cases. 3. **Ignoring the `Inconsistent` result variant.** Even with threshold consensus, providers can still disagree beyond the threshold. Multi-provider calls return `MultiRpcResult::Consistent(result)` or `MultiRpcResult::Inconsistent(results)`. Always handle both arms or your canister traps on provider disagreement. 4. **Using wrong chain variant.** `RpcServices::EthMainnet` is for Ethereum L1. For Arbitrum use `RpcServices::ArbitrumOne`, for Base use `RpcServices::BaseMainnet`. Using the wrong variant queries the wrong chain. 5. **Response size limits.** Large responses (e.g., `eth_getLogs` with broad filters) can exceed the max response size. Use `.with_response_size_estimate()` on the client builder or the call fails. 6. **Calling `eth_sendRawTransaction` without signing first.** The EVM RPC canister does not sign transactions. You must sign the transaction yourself (using threshold ECDSA via the IC management canister) and pass the raw signed bytes. 7. **Defining EVM RPC Candid types manually.** Use the `evm_rpc_client` crate which provides a typed client API and re-exports all Candid types from `evm_rpc_types`. Manual type definitions drift from the canister's actual interface and cause `IC0503` decode traps at runtime. ## Implementation ### icp.yaml Configuration The `evm_rpc` canister definition is only needed for local development — the local replica doesn't have the EVM RPC canister pre-installed, so you deploy your own copy from the pre-built WASM. On mainnet, the DFINITY-maintained canister is already deployed at `7hfb6-caaaa-aaaar-qadga-cai` — do NOT deploy your own instance. Use environments to control which canisters are deployed where: ```yaml canisters: - name: backend recipe: type: "@dfinity/rust@v3.2.0" configuration: package: backend - name: evm_rpc build: steps: - type: pre-built url: https://github.com/dfinity/evm-rpc-canister/releases/latest/download/evm_rpc.wasm.gz init_args: "(record {})" environments: - name: local network: local canisters: [backend, evm_rpc] - name: ic network: ic canisters: [backend] settings: backend: environment_variables: PUBLIC_CANISTER_ID:evm_rpc: "7hfb6-caaaa-aaaar-qadga-cai" ``` ### Rust The `evm_rpc_client` crate provides a typed client API for calling the EVM RPC canister. It handles cycle attachment, argument encoding, and response decoding. All Candid types are re-exported from `evm_rpc_types`. For projects using the Alloy ecosystem, enable the `alloy` Cargo feature to use Alloy-native request/response types. #### Cargo.toml ```toml [package] name = "evm_rpc_backend" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] evm_rpc_client = "0.4" evm_rpc_types = "3" ic-canister-runtime = "0.2" ic-cdk = "0.20" candid = "0.10" serde_json = "1" ``` #### src/lib.rs ```rust use candid::Principal; use evm_rpc_client::{CandidResponseConverter, EvmRpcClient, NoRetry}; use evm_rpc_types::{ Block, BlockTag, CallArgs, ConsensusStrategy, Hex, Hex20, Hex32, MultiRpcResult, RpcServices, SendRawTransactionStatus, }; use ic_canister_runtime::IcRuntime; use ic_cdk::update; use std::str::FromStr; // Resolve the EVM RPC canister ID from environment variable injected by icp-cli. // Locally: auto-injected with the locally deployed evm_rpc canister ID. // Mainnet: set explicitly in icp.yaml to the well-known canister ID. fn client() -> EvmRpcClient { let canister_id = Principal::from_text( ic_cdk::api::env_var_value("PUBLIC_CANISTER_ID:evm_rpc"), ) .expect("Invalid principal in PUBLIC_CANISTER_ID:evm_rpc"); EvmRpcClient::builder(IcRuntime::new(), canister_id) .with_rpc_sources(RpcServices::EthMainnet(None)) .with_consensus_strategy(ConsensusStrategy::Threshold { total: Some(3), min: 2, }) .build() } // -- Get ETH balance via raw JSON-RPC -- // Returns hex-encoded wei (e.g., "0xde0b6b3a7640000" = 1 ETH). // Parse hex to u128, divide by 10^18 to get ETH. #[update] async fn get_eth_balance(address: String) -> String { let json = serde_json::json!({ "jsonrpc": "2.0", "method": "eth_getBalance", "params": [address, "latest"], "id": 1 }); client() .multi_request(json) .send() .await .expect_consistent() .unwrap_or_else(|err| ic_cdk::trap(&format!("RPC error: {:?}", err))) } // -- Get latest block via typed API -- #[update] async fn get_latest_block() -> Block { let result = client() .get_block_by_number(BlockTag::Latest) .send() .await; match result { MultiRpcResult::Consistent(Ok(block)) => block, MultiRpcResult::Consistent(Err(err)) => { ic_cdk::trap(&format!("RPC error: {:?}", err)) } MultiRpcResult::Inconsistent(_) => { ic_cdk::trap("Providers returned inconsistent results") } } } // -- Read ERC-20 balance via eth_call -- // Returns hex-encoded uint256. Divide by 10^decimals (e.g., 6 for USDC, 18 for DAI). // Function selector for balanceOf(address): 0x70a08231 // Pad address to 32 bytes (remove 0x prefix, left-pad with zeros) #[update] async fn get_erc20_balance(token_contract: String, wallet_address: String) -> String { let addr = wallet_address.trim_start_matches("0x"); let calldata = format!("0x70a08231{:0>64}", addr); let args = CallArgs { transaction: evm_rpc_types::TransactionRequest { to: Some(Hex20::from_str(&token_contract).unwrap()), input: Some(Hex::from_str(&calldata).unwrap()), ..Default::default() }, block: None, }; let result = client() .call(args) .send() .await .expect_consistent() .unwrap_or_else(|err| ic_cdk::trap(&format!("eth_call error: {:?}", err))); result.to_string() } // -- Send a signed raw transaction -- #[update] async fn send_raw_transaction(signed_tx_hex: String) -> SendRawTransactionStatus { client() .send_raw_transaction(Hex::from_str(&signed_tx_hex).unwrap()) .send() .await .expect_consistent() .unwrap_or_else(|err| ic_cdk::trap(&format!("RPC error: {:?}", err))) } // -- Get transaction receipt -- #[update] async fn get_transaction_receipt(tx_hash: String) -> evm_rpc_types::TransactionReceipt { client() .get_transaction_receipt(Hex32::from_str(&tx_hash).unwrap()) .send() .await .expect_consistent() .unwrap_or_else(|err| ic_cdk::trap(&format!("RPC error: {:?}", err))) .unwrap_or_else(|| ic_cdk::trap("Transaction receipt not found")) } // -- Override chain per request (e.g., Arbitrum instead of the client default) -- #[update] async fn get_arbitrum_block() -> Block { client() .get_block_by_number(BlockTag::Latest) .with_rpc_sources(RpcServices::ArbitrumOne(None)) .send() .await .expect_consistent() .unwrap_or_else(|err| ic_cdk::trap(&format!("RPC error: {:?}", err))) } ic_cdk::export_candid!(); ``` ## Deploy & Test ```bash # Local: deploys both backend and evm_rpc (as defined in the local environment) icp network start -d icp deploy -e local # Mainnet: deploys only backend (evm_rpc is excluded from the ic environment) icp deploy -e ic ``` ### Test via icp CLI ```bash # Set up variables export CYCLES=10000000000 # Get ETH balance (raw JSON-RPC via single provider) icp canister call evm_rpc request '( variant { EthMainnet = variant { PublicNode } }, "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\",\"latest\"],\"id\":1}", 1000 )' --with-cycles=$CYCLES # Get latest block (typed API, multi-provider) icp canister call evm_rpc eth_getBlockByNumber '( variant { EthMainnet = null }, null, variant { Latest } )' --with-cycles=$CYCLES # Get transaction receipt icp canister call evm_rpc eth_getTransactionReceipt '( variant { EthMainnet = null }, null, "0xdd5d4b18923d7aae953c7996d791118102e889bea37b48a651157a4890e4746f" )' --with-cycles=$CYCLES # Check available providers icp canister call evm_rpc getProviders # Estimate cost before calling icp canister call evm_rpc requestCost '( variant { EthMainnet = variant { PublicNode } }, "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBalance\",\"params\":[\"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045\",\"latest\"],\"id\":1}", 1000 )' ``` --- ## HTTPS Outcalls --- name: https-outcalls description: "Make HTTPS requests from canisters to external web APIs. Covers transform functions for consensus, cycle cost management, response size limits, and idempotency patterns. Use when a canister needs to call an external API, fetch data from the web, or make HTTP requests. Do NOT use for EVM/Ethereum calls — use evm-rpc instead." license: Apache-2.0 compatibility: "icp-cli >= 0.2.2" metadata: title: HTTPS Outcalls category: Integration --- # HTTPS Outcalls ## What This Is HTTPS outcalls allow canisters to make HTTP requests to external web services directly from on-chain code. Because the Internet Computer runs on a replicated subnet (multiple nodes execute the same code), all nodes must agree on the response. A transform function strips non-deterministic fields (timestamps, request IDs, ordering) so that every replica sees an identical response and can reach consensus. ## Prerequisites - For Motoko: `mo:core` 2.0 and `ic >= 2.1.0` in mops.toml - For Rust: `ic-cdk >= 0.19`, `serde_json` for JSON parsing ## Canister IDs HTTPS outcalls use the IC management canister: | Name | Canister ID | Used For | |------|-------------|----------| | Management canister | `aaaaa-aa` | The `http_request` management call target | You do not deploy anything extra. The management canister is built into every subnet. ## Mistakes That Break Your Build 1. **Forgetting the transform function.** Without a transform, the raw HTTP response often differs between replicas (different headers, different ordering in JSON fields, timestamps). Consensus fails and the call is rejected. ALWAYS provide a transform function. 2. **Not attaching cycles to the call.** HTTPS outcalls are not free. The calling canister must attach cycles to cover the cost. If you attach zero cycles, the call fails immediately. Both Motoko and Rust have wrappers that compute and attach the required cycles automatically: in Motoko, use `await Call.httpRequest(args)` from the `ic` mops package (`import Call "mo:ic/Call"`); in Rust, use `ic_cdk::management_canister::http_request` (available since ic-cdk 0.18). Under the hood, both use the `ic0.cost_http_request` system API to calculate the exact cost from `request_size` and `max_response_bytes`. 3. **Using HTTP instead of HTTPS.** The IC only supports HTTPS outcalls. Plain HTTP URLs are rejected. The target server must have a valid TLS certificate. 4. **Exceeding the 2MB response limit.** The maximum response body is 2MB (2_097_152 bytes). If the external API returns more, the call fails. Use the `max_response_bytes` field to set a limit and design your queries to return small responses. 5. **Omitting `max_response_bytes`.** If you do not set `max_response_bytes`, the system assumes the maximum (2MB) and charges cycles accordingly — roughly 21.5 billion cycles on a 13-node subnet. Always set this to a reasonable upper bound for your expected response. 6. **Non-idempotent POST requests without caution.** Because multiple replicas make the same request, a POST endpoint that is not idempotent (e.g., "create order") will be called N times (once per replica, typically 13 on a 13-node subnet). Use idempotency keys or design endpoints to handle duplicate requests. 7. **Not handling outcall failures.** External servers can be down, slow, or return errors. Always handle the error case. On the IC, if the external server does not respond within the timeout (~30 seconds), the call traps. 8. **Calling localhost or private IPs.** HTTPS outcalls can only reach public internet endpoints. Localhost, 10.x.x.x, 192.168.x.x, and other private ranges are blocked. 9. **Forgetting the `Host` header.** Some API endpoints require the `Host` header to be explicitly set. The IC does not automatically set this from the URL. ## Implementation ### Motoko The management canister types are imported via `import IC "ic:aaaaa-aa"` (compiler-provided). The `ic` mops package (`import Call "mo:ic/Call"`) provides `Call.httpRequest` which auto-computes and attaches the required cycles. ```motoko import Blob "mo:core/Blob"; import Nat "mo:core/Nat"; import Text "mo:core/Text"; import IC "ic:aaaaa-aa"; import Call "mo:ic/Call"; persistent actor { // Transform function: strips headers so all replicas see the same response for consensus. // MUST be a `shared query` function. public query func transform({ context : Blob; response : IC.http_request_result; }) : async IC.http_request_result { { response with headers = []; // Strip headers -- they often contain non-deterministic values }; }; // GET request: fetch a JSON API public func getIcpPriceUsd() : async Text { let url = "https://api.coingecko.com/api/v3/simple/price?ids=internet-computer&vs_currencies=usd"; let request : IC.http_request_args = { url = url; max_response_bytes = ?(10_000 : Nat64); // Always set — omitting defaults to 2MB and charges accordingly headers = [ { name = "User-Agent"; value = "ic-canister" }, ]; body = null; method = #get; transform = ?{ function = transform; context = Blob.fromArray([]); }; is_replicated = null; }; // Call.httpRequest computes and attaches the required cycles automatically let response = await Call.httpRequest(request); switch (Text.decodeUtf8(response.body)) { case (?text) { text }; case (null) { "Response is not valid UTF-8" }; }; }; // POST transform: also discards the body because httpbin.org includes the // sender's IP in the "origin" field, which differs across replicas. public query func transformPost({ context : Blob; response : IC.http_request_result; }) : async IC.http_request_result { { response with headers = []; body = Blob.fromArray([]); }; }; // POST request: send JSON data public func postData(jsonPayload : Text) : async Text { let url = "https://httpbin.org/post"; let request : IC.http_request_args = { url = url; max_response_bytes = ?(50_000 : Nat64); headers = [ { name = "Content-Type"; value = "application/json" }, { name = "User-Agent"; value = "ic-canister" }, // Idempotency key: prevents duplicate processing if multiple replicas hit the endpoint { name = "Idempotency-Key"; value = "unique-request-id-12345" }, ]; body = ?Text.encodeUtf8(jsonPayload); method = #post; transform = ?{ function = transformPost; context = Blob.fromArray([]); }; is_replicated = null; }; // Call.httpRequest computes and attaches the required cycles automatically let response = await Call.httpRequest(request); if (response.status == 200) { "POST successful (status 200)"; } else { "POST failed with status " # Nat.toText(response.status); }; }; }; ``` ### Rust ```toml # Cargo.toml [package] name = "https_outcalls_backend" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] ic-cdk = "0.19" candid = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" ``` ```rust use ic_cdk::api::canister_self; use ic_cdk::management_canister::{ http_request, HttpHeader, HttpMethod, HttpRequestArgs, HttpRequestResult, TransformArgs, TransformContext, TransformFunc, }; use ic_cdk::{query, update}; use serde::Deserialize; /// Transform function: strips non-deterministic headers so all replicas agree. /// MUST be a #[query] function. #[query(hidden = true)] fn transform(args: TransformArgs) -> HttpRequestResult { HttpRequestResult { status: args.response.status, body: args.response.body, headers: vec![], // Strip all headers for consensus // If you need specific headers, filter them here: // headers: args.response.headers.into_iter() // .filter(|h| h.name.to_lowercase() == "content-type") // .collect(), } } /// GET request: Fetch JSON from an external API #[update] async fn fetch_price() -> String { let url = "https://api.coingecko.com/api/v3/simple/price?ids=internet-computer&vs_currencies=usd"; let request = HttpRequestArgs { url: url.to_string(), max_response_bytes: Some(10_000), method: HttpMethod::GET, headers: vec![ HttpHeader { name: "User-Agent".to_string(), value: "ic-canister".to_string(), }, ], body: None, transform: Some(TransformContext { function: TransformFunc::new(canister_self(), "transform".to_string()), context: vec![], }), is_replicated: None, }; // http_request calls automatically attaches the required cycles match http_request(&request).await { Ok(response) => { let body = String::from_utf8(response.body) .unwrap_or_else(|_| "Invalid UTF-8 in response".to_string()); if response.status != candid::Nat::from(200u64) { return format!("HTTP error: status {}", response.status); } body } Err(err) => { format!("HTTP outcall failed: {:?}", err) } } } /// Typed response parsing example #[derive(Deserialize)] struct PriceResponse { #[serde(rename = "internet-computer")] internet_computer: PriceData, } #[derive(Deserialize)] struct PriceData { usd: f64, } #[update] async fn get_icp_price_usd() -> String { let body = fetch_price().await; match serde_json::from_str::(&body) { Ok(parsed) => format!("ICP price: ${:.2}", parsed.internet_computer.usd), Err(e) => format!("Failed to parse price response: {}", e), } } /// POST transform: strips headers AND body because httpbin.org includes the /// sender's IP in the "origin" field, which differs across replicas. #[query(hidden = true)] fn transform_post(args: TransformArgs) -> HttpRequestResult { HttpRequestResult { status: args.response.status, body: vec![], headers: vec![], } } /// POST request: Send JSON data to an external API #[update] async fn post_data(json_payload: String) -> String { let url = "https://httpbin.org/post"; let request = HttpRequestArgs { url: url.to_string(), max_response_bytes: Some(50_000), method: HttpMethod::POST, headers: vec![ HttpHeader { name: "Content-Type".to_string(), value: "application/json".to_string(), }, HttpHeader { name: "User-Agent".to_string(), value: "ic-canister".to_string(), }, // Idempotency key: prevents duplicate processing across replicas HttpHeader { name: "Idempotency-Key".to_string(), value: "unique-request-id-12345".to_string(), }, ], body: Some(json_payload.into_bytes()), transform: Some(TransformContext { function: TransformFunc::new(canister_self(), "transform_post".to_string()), context: vec![], }), is_replicated: None, }; // http_request automatically attaches the required cycles match http_request(&request).await { Ok(response) => { if response.status == candid::Nat::from(200u64) { "POST successful (status 200)".to_string() } else { format!("POST failed with status {}", response.status) } } Err(err) => { format!("HTTP outcall failed: {:?}", err) } } } ``` ### Cycle Cost Estimation The `ic0.cost_http_request` system API computes the exact cycle cost at runtime, so canisters do not need to hard-code the formula. Both `Call.httpRequest` from the `ic` mops package (Motoko) and `ic_cdk::management_canister::http_request` (Rust) call it internally and attach the required cycles automatically. For manual use: in Motoko, `Prim.costHttpRequest(requestSize, maxResponseBytes)` (via `import Prim "mo:⛔"`); in Rust, `ic_cdk::api::cost_http_request(request_size, max_res_bytes)`. `request_size` is the sum of byte lengths of the URL, all header names and values, the body, the transform function name, and the transform context. For reference, the underlying formula on a 13-node subnet (n = 13) is: ```text Base cost: 49_140_000 cycles (= (3_000_000 + 60_000*13) * 13) + per request byte: 5_200 cycles (= 400 * 13) + per max_response_bytes byte: 10_400 cycles (= 800 * 13) IMPORTANT: The charge is against max_response_bytes, NOT actual response size. If you omit max_response_bytes, the system assumes 2MB and charges ~21.5B cycles. ``` Unused cycles are refunded to the canister, so it is safe to over-budget. ## Deploy & Test ### Local Deployment ```bash # Start the local replica icp network start -d # Deploy your canister icp deploy backend ``` Note: HTTPS outcalls work on the local replica. icp-cli proxies the requests through the local HTTP gateway. ### Mainnet Deployment ```bash # Ensure your canister has enough cycles (check balance first) icp canister status backend -e ic # Deploy icp deploy -e ic backend ``` ## Verify It Works ```bash # 1. Test the GET outcall (fetch price) icp canister call backend fetchPrice # Expected: Something like '("{\"internet-computer\":{\"usd\":12.34}}")' # (actual price will vary) # 2. Test the POST outcall icp canister call backend postData '("{\"test\": \"hello\"}")' # Expected: JSON response from httpbin.org echoing back your data # 3. If using Rust with the typed parser: icp canister call backend get_icp_price_usd # Expected: '("ICP price: $12.34")' # 4. Check canister cycle balance (outcalls consume cycles) icp canister status backend # Verify the balance decreased slightly after outcalls # 5. Test error handling: call with an unreachable URL # Add a test function that calls a non-existent domain and verify # it returns an error message rather than trapping ``` ### Debugging Outcall Failures If an outcall fails: ```bash # Check the replica log for detailed error messages # Local: icp output shows errors inline # Mainnet: check the canister logs # Common errors: # "Timeout" -- external server took too long (>30s) # "No consensus" -- transform function is missing or not stripping enough # "Body size exceeds limit" -- response > max_response_bytes # "Not enough cycles" -- attach more cycles to the call ``` ### Transform Debugging If you get "no consensus could be reached" errors, your transform function is not making responses identical. Common culprits: 1. **Response headers differ** -- strip ALL headers in the transform 2. **JSON field ordering differs** -- parse and re-serialize the JSON in the transform 3. **Timestamps in response body** -- extract only the fields you need Advanced transform that normalizes JSON: ```rust #[query] fn transform_normalize(args: TransformArgs) -> HttpRequestResult { // Parse and re-serialize to normalize field ordering let body = if let Ok(json) = serde_json::from_slice::(&args.response.body) { serde_json::to_vec(&json).unwrap_or(args.response.body) } else { args.response.body }; HttpRequestResult { status: args.response.status, body, headers: vec![], } } ``` --- ## IC Dashboard APIs --- name: ic-dashboard description: "Query the public REST APIs that power dashboard.internetcomputer.org. Covers canister metadata, ICRC ledger data, SNS data, ICP ledger, and network metrics with cursor-based pagination. Use when fetching canister info, token data, SNS proposals, or network stats via HTTP from off-chain code. No canister deployment or cycles needed." license: Apache-2.0 metadata: title: IC Dashboard APIs category: Integration --- # IC Dashboard APIs ## What This Is These public REST APIs power **dashboard.internetcomputer.org**. They expose read-only access to canister metadata, ICRC ledgers, SNS data, the ICP ledger, and network metrics via OpenAPI specs and Swagger UI. Agents and scripts can call them over HTTPS from off-chain (no canister deployment or cycles required). **Prefer v2 or higher API versions** where available; they provide cursor-based pagination (`after`, `before`, `limit`) and are the same surface the dashboard uses. ## Prerequisites - Any HTTP client: `curl`, `fetch`, `axios`, or the language’s native HTTP library. - No `icp-cli` or canister deployment needed for read-only API access. - For OpenAPI-based codegen: optional use of the `openapi.json` URLs with your preferred OpenAPI tooling. ## API Base URLs and Docs | API | Base URL | OpenAPI spec | Swagger / Docs | Prefer | |-----|----------|--------------|----------------|--------| | IC API | `https://ic-api.internetcomputer.org` | `/api/v3/openapi.json` | `/api/v3/swagger` | v4 for canisters, subnets (cursor pagination) | | ICRC API | `https://icrc-api.internetcomputer.org` | `/openapi.json` | `/docs` | v2 for ledgers (TestICP and other ICRC tokens; **not** mainnet ICP) | | SNS API | `https://sns-api.internetcomputer.org` | `/openapi.json` | `/docs` | v2 for snses, proposals, neurons | | Ledger API (mainnet ICP) | `https://ledger-api.internetcomputer.org` | `/openapi.json` | `/swagger-ui/` | Use for **ICP token**; v2 for cursor pagination | | Metrics API | `https://metrics-api.internetcomputer.org` | `/api/v1/openapi.json` | `/api/v1/docs` | v1 (no newer version) | Full URLs for specs and UI: - IC API: https://ic-api.internetcomputer.org/api/v3/openapi.json — https://ic-api.internetcomputer.org/api/v3/swagger - ICRC API: https://icrc-api.internetcomputer.org/openapi.json — https://icrc-api.internetcomputer.org/docs - SNS API: https://sns-api.internetcomputer.org/openapi.json — https://sns-api.internetcomputer.org/docs - Ledger API: https://ledger-api.internetcomputer.org/openapi.json — https://ledger-api.internetcomputer.org/swagger-ui/ - Metrics API: https://metrics-api.internetcomputer.org/api/v1/openapi.json — https://metrics-api.internetcomputer.org/api/v1/docs ## How It Works 1. **Prefer v2+ APIs with cursor pagination.** IC API v4 (`/api/v4/canisters`, `/api/v4/subnets`), ICRC API v2 (`/api/v2/ledgers`, `/api/v2/ledgers/{id}/transactions`, etc.), and SNS API v2 (`/api/v2/snses`, `/api/v2/snses/{id}/proposals`, `/api/v2/snses/{id}/neurons`) use `after`, `before`, and `limit` for stable, efficient paging. Avoid v1/offset-based endpoints when a v2+ alternative exists. 2. **Choose the right API** for the data you need: IC API (canisters, subnets, NNS neurons/proposals), **Ledger API for mainnet ICP** (accounts, transactions, supply), ICRC API for **other** ICRC ledgers only (ckBTC, SNS tokens, testicp — ICRC API does not expose mainnet ICP), SNS API (SNS list, neurons, proposals), Metrics API (governance, cycles, Bitcoin, etc.). 3. **Use the OpenAPI spec** to get exact path, query, and body schemas and response shapes; prefer the spec over hand-written docs to avoid drift. 4. **Call over HTTPS** with `GET` (or documented method). Use the `next_cursor` / `previous_cursor` from v2+ responses to request the next or previous page. ## Mistakes That Break Your Build 1. **Wrong base URL or API version.** IC API uses `/api/v3/` (and v4 for canisters/subnets); ICRC has `/api/v1/` and `/api/v2/` (ICRC API does not serve mainnet ICP — use Ledger API). Ledger API uses unversioned paths for some endpoints (e.g. `/accounts`, `/supply/total/latest`) and `/v2/` for cursor-paginated lists. Metrics API uses `/api/v1/`. Using the wrong prefix returns 404 or wrong schema. 2. **Canister ID format.** Canister IDs in paths and queries must match the principal-like pattern: 27 characters, five groups of five plus a final three (e.g. `ryjl3-tyaaa-aaaaa-aaaba-cai`). Subnet IDs use the longer pattern (e.g. 63 chars). Sending a raw principal string in the wrong encoding or length causes 422 or 400. 3. **Using ICRC API for mainnet ICP.** ICRC API exposes **test ICP (TestICP) only**, not mainnet ICP. For mainnet ICP token data (accounts, transactions, supply) use **Ledger API** (`ledger-api.internetcomputer.org`). Use ICRC API for other ICRC ledgers (e.g. ckBTC, SNS tokens) and for TestICP. 4. **ICRC API: ledger_canister_id in path.** ICRC endpoints require `ledger_canister_id` in the path (e.g. `/api/v2/ledgers/{ledger_canister_id}/transactions`). Use the canister ID of the ledger you want (e.g. ckBTC `mxzaz-hqaaa-aaaar-qaada-cai`). Do not use ICRC API for mainnet ICP — use Ledger API instead. 5. **Using v1 or offset-based pagination when v2+ exists.** Always prefer v2 or higher endpoints that support cursor pagination (`after`, `before`, `limit`). IC API v4 (canisters, subnets), ICRC API v2 (ledgers, accounts, transactions), and SNS API v2 (snses, proposals, neurons) return `next_cursor`/`previous_cursor` and accept cursor query params. Older v1/offset/`max_*_index` endpoints are legacy; using the wrong pagination model returns empty or incorrect pages. 6. **Timestamps.** Most time-range query params (`start`, `end`) expect Unix seconds (integer). Sending milliseconds or ISO strings causes validation errors (422). 7. **Account identifier format.** Ledger API and ICRC/ICP endpoints use **account identifiers** (hex hashes), not raw principals, for account-specific paths. Use the same encoding the API documents (e.g. 64-char hex for account_identifier where required). 8. **Assuming authentication.** These public dashboard APIs do not require API keys or auth for the documented read endpoints. If you get 401/403, confirm you are not hitting a different environment or a write endpoint that requires auth. ## Implementation ### IC API — Canisters and subnets (prefer v4 with cursor pagination) ```bash # List canisters (v4: cursor pagination, next_cursor/previous_cursor in response) curl -s "https://ic-api.internetcomputer.org/api/v4/canisters?limit=5" # Next page: use after= from previous response's next_cursor (see OpenAPI for cursor format) # curl -s "https://ic-api.internetcomputer.org/api/v4/canisters?limit=5&after=..." # Get one canister by ID (v3; no v4 single-canister endpoint) curl -s "https://ic-api.internetcomputer.org/api/v3/canisters/ryjl3-tyaaa-aaaaa-aaaba-cai" # List subnets (v4: cursor pagination) curl -s "https://ic-api.internetcomputer.org/api/v4/subnets?limit=10" # List NNS proposals (v3; use limit) curl -s "https://ic-api.internetcomputer.org/api/v3/proposals?limit=5" ``` ### ICRC API — Other ICRC ledgers only (v2 with cursor pagination) ICRC API exposes **TestICP and other ICRC ledgers (e.g. ckBTC, SNS tokens), not mainnet ICP.** For mainnet ICP use Ledger API. ```bash # List ledgers (v2: after/before/limit, next_cursor/previous_cursor in response) curl -s "https://icrc-api.internetcomputer.org/api/v2/ledgers?limit=10" # Get one ledger (e.g. ckBTC — mainnet ICP is not on ICRC API) curl -s "https://icrc-api.internetcomputer.org/api/v2/ledgers/mxzaz-hqaaa-aaaar-qaada-cai" # List transactions for a ledger (v2: cursor pagination) curl -s "https://icrc-api.internetcomputer.org/api/v2/ledgers/mxzaz-hqaaa-aaaar-qaada-cai/transactions?limit=5" # List accounts for a ledger (v2: after/before/limit) curl -s "https://icrc-api.internetcomputer.org/api/v2/ledgers/mxzaz-hqaaa-aaaar-qaada-cai/accounts?limit=10" ``` ### SNS API — SNS list and proposals (prefer v2 with cursor pagination) ```bash # List SNSes (v2: after/before/limit, next_cursor/previous_cursor) curl -s "https://sns-api.internetcomputer.org/api/v2/snses?limit=10" # List proposals for an SNS root canister (v2: cursor pagination) # Replace ROOT_CANISTER_ID with a real SNS root canister ID curl -s "https://sns-api.internetcomputer.org/api/v2/snses/ROOT_CANISTER_ID/proposals?limit=5" # List neurons for an SNS (v2: after/before/limit) curl -s "https://sns-api.internetcomputer.org/api/v2/snses/ROOT_CANISTER_ID/neurons?limit=10" ``` ### Ledger API — Mainnet ICP token (prefer v2 for cursor pagination) ```bash # List accounts (v2: after/before/limit, next_cursor/prev_cursor) curl -s "https://ledger-api.internetcomputer.org/v2/accounts?limit=10" # Get account by account_identifier (64-char hex) curl -s "https://ledger-api.internetcomputer.org/accounts/ACCOUNT_IDENTIFIER" # List transactions (v2: cursor pagination) curl -s "https://ledger-api.internetcomputer.org/v2/transactions?limit=10" # Total supply (latest) curl -s "https://ledger-api.internetcomputer.org/supply/total/latest" ``` ### Metrics API ```bash # Average cycle burn rate curl -s "https://metrics-api.internetcomputer.org/api/v1/average-cycle-burn-rate" # Governance metrics curl -s "https://metrics-api.internetcomputer.org/api/v1/governance-metrics" # ICP/XDR conversion rates (with optional start/end/step) curl -s "https://metrics-api.internetcomputer.org/api/v1/icp-xdr-conversion-rates?start=1700000000&end=1700086400&step=86400" ``` ### Fetching OpenAPI spec (for codegen or validation) ```bash # IC API v3 curl -s "https://ic-api.internetcomputer.org/api/v3/openapi.json" -o ic-api-v3.json # ICRC API curl -s "https://icrc-api.internetcomputer.org/openapi.json" -o icrc-api.json # SNS API curl -s "https://sns-api.internetcomputer.org/openapi.json" -o sns-api.json # Ledger API curl -s "https://ledger-api.internetcomputer.org/openapi.json" -o ledger-api.json # Metrics API v1 curl -s "https://metrics-api.internetcomputer.org/api/v1/openapi.json" -o metrics-api-v1.json ``` ## Deploy & Test No canister deployment is required. These are external HTTP APIs. Test from the shell or your app: ```bash # Smoke test: IC API root curl -s -o /dev/null -w "%{http_code}" "https://ic-api.internetcomputer.org/api/v3/" # Expected: 200 # Smoke test: ICRC ledgers list curl -s -o /dev/null -w "%{http_code}" "https://icrc-api.internetcomputer.org/api/v2/ledgers?limit=1" # Expected: 200 ``` ## Verify It Works ```bash # 1. IC API returns canister list with data array curl -s "https://ic-api.internetcomputer.org/api/v3/canisters?limit=1" | head -c 200 # Expected: JSON with "data" or similar key and at least one canister # 2. ICRC API returns ledger list curl -s "https://icrc-api.internetcomputer.org/api/v2/ledgers?limit=1" | head -c 200 # Expected: JSON with "data" and ledger entries # 3. Ledger API returns supply (array of [timestamp, value]) curl -s "https://ledger-api.internetcomputer.org/supply/total/latest" # Expected: JSON array with two elements (timestamp and supply string) # 4. OpenAPI specs are valid JSON curl -s "https://ic-api.internetcomputer.org/api/v3/openapi.json" | python3 -c "import sys,json; json.load(sys.stdin); print('OK')" # Expected: OK ``` --- ## ICP CLI --- name: icp-cli description: "Guides use of the icp command-line tool for building and deploying Internet Computer applications. Covers project configuration (icp.yaml), recipes, environments, canister lifecycle, identity management, and bundling a project into a self-contained .icp package (icp project bundle). Use when building, deploying, or managing any IC project. Use when the user mentions icp, dfx, canister deployment, local network, project setup, or bundling/packaging an app as an .icp file. Do NOT use for canister-level programming patterns like access control, inter-canister calls, or stable memory — use domain-specific skills instead." license: Apache-2.0 metadata: title: ICP CLI category: Infrastructure --- # ICP CLI ## What This Is The `icp` command-line tool builds and deploys applications on the Internet Computer. It replaces the legacy `dfx` tool with YAML configuration, a recipe system for reusable build templates, and an environment model that separates deployment targets from network connections. Never use `dfx` — always use `icp`. Before generating any `icp` command not explicitly documented here, run `icp --help` or `icp --help` to verify the command and its flags exist. Do not infer flags from `dfx` equivalents — the CLIs are not flag-compatible. ## Installation ```bash npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm ``` `ic-wasm` is required when using official recipes (`@dfinity/rust`, `@dfinity/motoko`, `@dfinity/asset-canister`) — they depend on it for optimization and metadata embedding. Requires [Node.js](https://nodejs.org/) >= 22. Also available via Homebrew and shell script installer — see the [icp-cli releases](https://github.com/dfinity/icp-cli/releases). **Linux note:** On minimal installs, you may need system libraries: `sudo apt-get install -y libdbus-1-3 libssl3 ca-certificates` (Ubuntu/Debian) or `sudo dnf install -y dbus-libs openssl ca-certificates` (Fedora/RHEL). ## Prerequisites - For Rust canisters: `rustup target add wasm32-unknown-unknown` - For Motoko canisters: `npm i -g ic-mops` and a `mops.toml` at the project root with the Motoko compiler version and a `[canisters]` entry: ```toml [toolchain] moc = "1.9.0" [canisters.backend] main = "src/backend/main.mo" ``` The `@dfinity/motoko@v5+` recipe compiles via `mops build `. The canister name in `icp.yaml` must exactly match a key in `[canisters]` — a missing or mismatched key causes `mops build` to fail with `No Motoko canisters found in mops.toml configuration` (see Pitfall 17). Without `mops.toml`, the recipe fails because `mops` is not found. Templates include `mops.toml` automatically; for manual projects, create it before running `icp build`. Load `mops-cli` for `[canisters]` configuration options, dependency management, and `mops build` details. ## Common Pitfalls 1. **Using `dfx` instead of `icp`.** The `dfx` tool is legacy. All commands have `icp` equivalents — see `references/dfx-migration.md` for the full command mapping. Never generate `dfx` commands or reference `dfx` documentation. Configuration uses `icp.yaml`, not `dfx.json` — and the structure differs: canisters are an array of objects, not a keyed object. 2. **Using `--network ic` to deploy to mainnet.** icp-cli uses environments, not direct network targeting. The correct flag is `-e ic` (short for `--environment ic`). ```bash # Wrong icp deploy --network ic # Correct icp deploy -e ic ``` Note: `-n` / `--network` targets a network directly and works with canister IDs (principals). Use `-e` / `--environment` when referencing canisters by name. For token and cycles operations, use `-n` since they don't reference project canisters. 3. **Using a recipe without a version pin.** icp-cli rejects unpinned recipe references. Always include an explicit version. Official recipes are hosted at [dfinity/icp-cli-recipes](https://github.com/dfinity/icp-cli-recipes). ```yaml # Wrong — rejected by icp-cli recipe: type: "@dfinity/rust" # Correct — pinned version recipe: type: "@dfinity/rust@v3.2.0" ``` 4. **Writing manual build steps when a recipe exists.** Official recipes handle Rust, Motoko, and asset canister builds. Use `recipe: { type: "@dfinity/rust@v3.2.0", configuration: { package: backend } }` instead of writing shell commands in `build.steps`. 5. **Not committing `.icp/data/` to version control.** Mainnet canister IDs are stored in `.icp/data/mappings/.ids.json`. Losing this file means losing the mapping between canister names and on-chain IDs. Always commit `.icp/data/` — never delete it. Add `.icp/cache/` to `.gitignore` (it is ephemeral and rebuilt automatically). If you have environments using a connected network that gets reset frequently you can add those specific environment mapping files to .gitignore. **Never** add the entire `.icp` or `.icp/data` directory to gitignore. 6. **Using `icp identity use` instead of `icp identity default`.** The dfx command `dfx identity use ` became `icp identity default ` (setter). `icp identity default` with no argument is the getter — it prints the current default identity, equivalent to `dfx identity whoami`. The command `icp identity use` does not exist. Similarly, `dfx identity get-principal` became `icp identity principal`, and `dfx identity remove` became `icp identity delete`. 7. **Confusing networks and environments.** A network is a connection endpoint (URL). An environment combines a network + canisters + settings. You deploy to environments (`-e`), not networks. Multiple environments can target the same network with different settings (e.g., staging and production both on `ic`). 8. **Writing `networks` or `environments` as a YAML map instead of an array.** Both `networks` and `environments` are arrays of objects in `icp.yaml`, not maps: ```yaml # Wrong — map syntax networks: local: mode: managed environments: staging: network: ic # Correct — array syntax networks: - name: local mode: managed environments: - name: staging network: ic canisters: [backend, frontend] ``` 9. **Forgetting that local networks are project-local.** Unlike dfx which runs one shared global network, icp-cli runs a local network per project. You must run `icp network start -d` in your project directory before deploying locally. The local network auto-starts with system canisters and seeds accounts with ICP and cycles. Stop it when done: ```bash icp network start -d # start background network icp deploy # build + deploy + sync icp network stop # stop when done ``` 10. **Not specifying build commands for asset canisters.** dfx automatically runs `npm run build` for asset canisters. icp-cli requires explicit build commands in the recipe configuration: ```yaml canisters: - name: frontend recipe: type: "@dfinity/asset-canister@v2.2.1" configuration: dir: dist build: - npm install - npm run build ``` 11. **Expecting `output_env_file` or `.env` with canister IDs.** dfx writes canister IDs to a `.env` file (`CANISTER_ID_BACKEND=...`) via `output_env_file`. icp-cli does not generate `.env` files. Instead, it injects canister IDs as environment variables (`PUBLIC_CANISTER_ID:`) directly into canisters during `icp deploy`. Frontends read these from the `ic_env` cookie set by the asset canister. Remove `output_env_file` from your config and any code that reads `CANISTER_ID_*` from `.env` — use the `ic_env` cookie instead (see Canister Environment Variables below). 12. **Expecting `dfx generate` for TypeScript bindings.** icp-cli does not have a `dfx generate` equivalent. Use `@icp-sdk/bindgen` (>= 0.3.0) with `@icp-sdk/core` (>= 5.0.0 — there is no 0.x or 1.x release) to generate TypeScript bindings from `.did` files at build time. Use `outDir: "./src/bindings"` so imports are clean (e.g., `./bindings/backend`). The `.did` file must exist on disk — either commit it to the repo, or generate it with `icp build` first (recipes auto-generate it when `candid` is not specified). See `references/binding-generation.md` for the full Vite plugin setup. 13. **Passing `{ agent }` to `createActor` from `@icp-sdk/bindgen`.** The old `@dfinity/agent` pattern was `createActor(canisterId, { agent })`. The `@icp-sdk/bindgen` pattern is `createActor(canisterId, { agentOptions: { host, rootKey } })` — the binding creates the agent internally. Passing `{ agent }` to the new API **silently creates an anonymous identity** — no error is thrown, but calls return empty data or access denied. See `references/binding-generation.md` for the correct pattern. 14. **Mixing canister-level fields across config styles.** When using a recipe, the only valid canister-level fields are `name`, `recipe`, `sync`, `settings`, and `init_args`. Fields like `candid`, `build`, or `wasm` are **not** valid at canister level alongside a recipe — recipe-specific options go inside `recipe.configuration`. When using bare `build` (no recipe), valid canister-level fields are `name`, `build`, `sync`, `settings`, and `init_args`. The field `init_arg_file` does not exist — use `init_args.path` instead (e.g., `init_args: { path: ./args.bin, format: bin }`). For the authoritative field reference, consult the [icp-cli configuration reference](https://cli.internetcomputer.org/0.2/reference/configuration.md). ```yaml # Wrong — candid is not a canister-level field when using a recipe canisters: - name: backend candid: backend/backend.did recipe: type: "@dfinity/rust@v3.2.0" configuration: package: backend # Correct — candid goes inside recipe.configuration canisters: - name: backend recipe: type: "@dfinity/rust@v3.2.0" configuration: package: backend candid: backend/backend.did ``` 15. **Placing `mops.toml` where `mops` cannot find it.** `mops` searches upward from the build working directory. Where to place `mops.toml` depends on how the canister is defined: - **Inline canisters** (defined directly in `icp.yaml`): build cwd is the project root. Place `mops.toml` at the project root next to `icp.yaml`. A `mops.toml` in `src/backend/` will not be found. - **Path-based canisters** (referenced via `canisters/*` or `./my-canister`, each with its own `canister.yaml`): build cwd is the canister directory. Place `mops.toml` in each canister's directory for per-canister dependencies and compiler versions, or omit it to fall back to a shared `mops.toml` in a parent directory. When `mops.toml` is not found, `mops build` fails because it cannot locate the project configuration. When `mops.toml` exists but is missing the matching `[canisters.]` entry, see Pitfall 17. 16. **Misunderstanding Candid file generation with recipes.** Binding generation tools (e.g. `@icp-sdk/bindgen`) require a `.did` file at a known path on disk. Where to configure it depends on the recipe: **Rust** — `candid` goes inside `recipe.configuration` in `icp.yaml`: - If **specified**: the file must already exist. The recipe uses it as-is and does not generate one. - If **omitted**: the recipe auto-generates the `.did` via `candid-extractor` into the build cache (no predictable project path). To generate and commit it, then add `candid: backend/backend.did` inside `recipe.configuration`: ```bash cargo install candid-extractor # one-time setup icp build backend candid-extractor target/wasm32-unknown-unknown/release/backend.wasm > backend/backend.did ``` **Motoko (v5 recipe)** — `mops build` auto-generates the `.did` to `.mops/.build/.did`. - **No binding generation needed** — nothing to do. The generated `.did` in `.mops/.build/` is sufficient; do not commit it. - **Binding generation needed** — commit a `.did` at a stable path and keep it in sync: ```bash mops build backend cp .mops/.build/backend.did backend/backend.did ``` Point the binding tool's config (e.g. `@icp-sdk/bindgen`'s `didFile`) at `backend/backend.did`. **After any interface change, re-run both commands** — `mops build` always writes to `.mops/.build/` and does not update the committed file automatically. 17. **Missing or mismatched `[canisters]` key in `mops.toml`.** The `@dfinity/motoko@v5+` recipe calls `mops build `, where the name comes from the `name` field in `icp.yaml`. `mops build` requires a matching `[canisters.]` entry in `mops.toml`. If the entry is absent or the key does not exactly match (including casing), the build fails with: ``` No Motoko canisters found in mops.toml configuration ``` Add the matching entry — the key must equal the `name:` value in `icp.yaml`: ```toml [canisters.backend] main = "src/backend/main.mo" ``` 18. **Port 8000 already in use when starting the local network.** Two scenarios: **Scenario A — another icp-cli project holds the port.** Stop that project's network using `--project-root-override` (a global flag available on all commands): ```bash icp network stop --project-root-override /path/to/other-project ``` To run both networks at once instead of stopping one — e.g. parallel git worktrees — set `gateway.port: 0` so each gets a free port. See "Parallel local networks (git worktrees)" under How It Works. **Scenario B — a non-icp service holds the port.** Configure an alternate port in `icp.yaml` and read the actual URLs dynamically via `icp network status --json` rather than hardcoding localhost:8000: ```yaml networks: - name: local mode: managed gateway: port: 8001 ``` ```bash icp network status --json # returns gateway URL, replica URL, etc. ``` 19. **`icp new` hangs in CI without `--silent`.** Without `--define` flags, `icp new` launches an interactive prompt that blocks indefinitely in non-interactive environments. Always pass `--subfolder`, `--define`, and `--silent` for scripted use: ```bash icp new my-project --subfolder rust --define project_name=my-project --silent ``` 20. **Using the anonymous identity on mainnet.** The local network seeds all managed identities — including the anonymous identity, which is the default — with ICP and cycles on start, so local development works out of the box with no identity or cycles setup required. On mainnet this does not apply, and the anonymous identity should never be used: it is shared by anyone, meaning ICP sent to it is publicly accessible and canisters deployed under it are uncontrolled. Before deploying to mainnet, switch to a named identity: ```bash icp identities list # check available identities icp identity default my-identity # switch to an existing one # or: icp identity new my-identity && icp identity default my-identity ``` Then verify it has funds — a new identity will need to be funded with ICP or cycles before proceeding: ```bash icp token balance -n ic # check ICP balance on mainnet icp cycles balance -n ic # check cycles balance on mainnet icp identity account-id # get account ID to fund if needed ``` ## How It Works ### Project Creation `icp new` scaffolds projects from templates. Pass `--subfolder`, `--define`, and `--silent` for non-interactive use: ```bash icp new my-project --subfolder rust --define project_name=my-project --silent ``` Available templates and options: [dfinity/icp-cli-templates](https://github.com/dfinity/icp-cli-templates). ### Build → Deploy → Sync ```text Source Code → [Build] → WASM → [Deploy] → Running Canister → [Sync] → Configured State ``` `icp deploy` runs all three phases in sequence: 1. **Build** — Compile canisters to WASM (via recipes or explicit build steps) 2. **Deploy** — Create canisters (if new), apply settings, install WASM 3. **Sync** — Post-deployment operations via `script` or `plugin` steps (e.g., uploading assets). Asset uploading is not built into the CLI: the `@dfinity/asset-canister@v2.2.1` recipe supplies a `plugin` sync step that uploads the `dir` contents. The legacy built-in `type: assets` step is removed in icp-cli 0.3.0 — see the `asset-canister` skill. Run phases separately for more control: ```bash icp build # Build only icp deploy # Full pipeline (build + deploy + sync) icp sync my-canister # Sync only (e.g., re-upload assets) ``` ### Environments and Networks Two implicit environments are always available: | Environment | Network | Purpose | |-------------|---------|---------| | `local` | `local` (managed, localhost:8000) | Local development | | `ic` | `ic` (connected, https://icp-api.io) | Mainnet production | The `ic` network is protected and cannot be overridden. Custom environments enable multiple deployment targets on the same network: ```yaml environments: - name: staging network: ic canisters: [frontend, backend] settings: backend: compute_allocation: 5 - name: production network: ic canisters: [frontend, backend] settings: backend: compute_allocation: 20 freezing_threshold: 7776000 ``` ### Parallel local networks (git worktrees) Local networks are project-local — keyed by project root (Pitfall 9). Separate git worktrees of the same repo are separate project roots, so each worktree can run its own independent local network. This lets multiple agents or branches build and deploy in parallel without interfering. The only obstacle is the gateway port: every worktree defaults to `8000`, so the second `icp network start` fails with a port conflict. Set the managed network's gateway port to `0` so the OS assigns a free ephemeral port per worktree: ```yaml networks: - name: local mode: managed gateway: port: 0 # 0 = OS picks a free port — avoids collisions across worktrees ``` `icp network start -d` prints the chosen port (`Network started on port 58157`). To recover it afterward — for tests, scripts, or another agent — query the running network and read `gateway_url`: ```bash icp network start -d icp network status --json # -> { "managed": true, "api_url": "http://localhost:58157/", "gateway_url": "http://localhost:58157/", ... } icp network status --json | jq -r '.gateway_url' # http://localhost:58157/ ``` Never hardcode `localhost:8000` when using `port: 0` — the port changes on every start, so read `gateway_url` (or `api_url`) from `icp network status --json` each time. To target a specific worktree's network from outside its directory, pass the global `--project-root-override ` flag (e.g. `icp network status --json --project-root-override /path/to/worktree`). ### Install Modes ```bash icp deploy # Auto: install new, upgrade existing (default) icp deploy --mode upgrade # Preserve state, run upgrade hooks icp deploy --mode reinstall # Clear all state (dangerous) ``` ### Bundling a project into an `.icp` package (experimental) `icp project bundle` (icp-cli >= 0.3.0) packages a project into a self-contained deployable archive. **This is an experimental feature, intentionally hidden from help output** — `icp --help` and `icp project --help` do not list it, but the command exists and works. Do not conclude it doesn't exist because help omits it, and do not suggest it proactively — use it only when the user explicitly asks to bundle an app or produce an `.icp` package. ```bash icp project bundle --output my-app.icp ``` The output is a gzipped tar archive; `--output` accepts any path (`my-app.icp` and `bundle.tar.gz` are both common). The bundle contains the built WASMs and a rewritten `icp.yaml`: - All canisters are built first; each canister's build steps are replaced with a prebuilt step referencing the bundled WASM (`canisters/.wasm`), pinned by sha256. - Plugin sync steps (e.g. the asset canister's upload plugin) are preserved — the plugin WASM and its `dirs`/`files` inputs are copied into the archive. - Network and environment manifests referenced by path are inlined; `init_args` files are copied into the archive. - An optional `icp_appmanifest.yaml` (app metadata) is included, with its `screenshots` paths relocated into the archive. To deploy from a bundle, extract it and run `icp deploy` from the extracted directory — no build toolchain (Rust, mops, npm) is required because every build step is prebuilt: ```bash mkdir app && tar -xzf my-app.icp -C app cd app && icp deploy -e ``` An `.icp` package can also be uploaded to a Caffeine cloud engine via the console's App Center ("Upload a custom app") — see the `deploy-to-cloud-engine` skill. Bundling fails when: - A canister has a `script` sync step — only `plugin` sync steps can be replayed from a bundle (`canister 'X' has a script sync step, which is not supported in bundles`). - Any synced directory, plugin file, `init_args` file, or screenshot resolves outside the project directory. - The `--output` path is inside a directory the bundle would sync (the partial archive would include itself). - A managed network defines a bind mount with an absolute host path — bundles require relative paths for portability. ## Configuration ### Rust canister ```yaml canisters: - name: backend recipe: type: "@dfinity/rust@v3.2.0" configuration: package: backend candid: backend.did # optional — if specified, file must exist (auto-generated when omitted) ``` ### Motoko canister The v5 recipe delegates compilation to `mops build`. Canister configuration (`main`, `candid`, `args`) moves from `icp.yaml` to `mops.toml`: ```yaml # icp.yaml canisters: - name: backend recipe: type: "@dfinity/motoko@v5.0.0" ``` ```toml # mops.toml [toolchain] moc = "1.9.0" [canisters.backend] main = "src/backend/main.mo" candid = "backend.did" # optional — auto-generated to .mops/.build/ when omitted ``` The canister name (`backend`) must exactly match between `icp.yaml` and `mops.toml`. No `recipe.configuration` block is needed in `icp.yaml`. ### Asset canister (frontend) ```yaml canisters: - name: frontend recipe: type: "@dfinity/asset-canister@v2.2.1" configuration: dir: dist build: - npm install - npm run build ``` For multi-canister projects, list all canisters in the same `canisters` array. icp-cli builds them in parallel. There is no `dependencies` field — use Canister Environment Variables for inter-canister communication. ### Custom build steps (no recipe) When not using a recipe, only `name`, `build`, `sync`, `settings`, and `init_args` are valid canister-level fields. There are no `wasm`, `candid`, or `metadata` fields — handle these in the build script instead: - **WASM output**: copy the final WASM to `$ICP_WASM_OUTPUT_PATH` - **Candid metadata**: use `ic-wasm` to embed `candid:service` metadata - **Candid file**: the `.did` file is referenced only in the `ic-wasm` command, not as a YAML field ```yaml canisters: - name: backend build: steps: - type: script commands: - cargo build --target wasm32-unknown-unknown --release - cp target/wasm32-unknown-unknown/release/backend.wasm "$ICP_WASM_OUTPUT_PATH" - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "$ICP_WASM_OUTPUT_PATH" metadata candid:service -f backend/backend.did -v public --keep-name-section ``` ### Available recipes | Recipe | Type string | Required config | Optional config | |--------|------------|-----------------|-----------------| | Rust | `@dfinity/rust@v3.2.0` | `package` | `candid`, `locked`, `shrink`, `compress` | | Motoko | `@dfinity/motoko@v5.0.0` | — | `shrink`, `compress`, `metadata` | | Asset | `@dfinity/asset-canister@v2.2.1` | `dir` | `build`, `version` | | Prebuilt | `@dfinity/prebuilt@v1.0.0` | `wasm` | `sha256`, `candid`, `shrink`, `compress` | Verify latest recipe versions at [dfinity/icp-cli-recipes releases](https://github.com/dfinity/icp-cli-recipes/releases). Use `icp project show` to see the effective configuration after recipe expansion. ### Canister Environment Variables icp-cli automatically injects all canister IDs as environment variables during `icp deploy`. Variables are formatted as `PUBLIC_CANISTER_ID:` and injected into every canister in the environment. **Frontend → Backend** (reading canister IDs in JavaScript): Asset canisters expose injected variables through a cookie named `ic_env`, set on all HTML responses. Use `@icp-sdk/core` to read it: ```js import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; const canisterEnv = safeGetCanisterEnv(); const backendId = canisterEnv?.["PUBLIC_CANISTER_ID:backend"]; ``` **Backend → Backend** (reading canister IDs in canister code): - Rust: `ic_cdk::api::env_var_value("PUBLIC_CANISTER_ID:other_canister")` - Motoko (motoko-core v2.1.0+): ```motoko import Runtime "mo:core/Runtime"; let otherId = Runtime.envVar("PUBLIC_CANISTER_ID:other_canister"); ``` Note: variables are only updated for canisters at deploy time. When adding a new canister, run `icp deploy` (without specifying a canister name) to update all canisters with the complete ID set. ### Web identity flows Two specialized skills cover `icp identity link web`. Load the right one for the task — both document the stdin "Press Enter" block, the browser sign-in step, and their flag-specific pitfalls: - `deploy-to-cloud-engine` — link the CLI to a cloud engine console with `--auth `, then deploy to the engine's subnet - `agent-web-identity` — obtain a delegation for an app-specific principal with `--app `, then make canister calls as the user ## Additional References For the complete CLI and configuration schema, consult the [icp-cli documentation index](https://cli.internetcomputer.org/llms.txt). For detailed guides on specific topics, consult these reference files when needed: - **`references/binding-generation.md`** — TypeScript binding generation with `@icp-sdk/bindgen` (Vite plugin, CLI, actor setup) - **`references/dev-server.md`** — Vite dev server configuration to simulate the `ic_env` cookie locally. Important: wrap `getDevServerConfig()` in a `command === "serve"` guard so it only runs during `vite dev`, not `vite build`. - **`references/dfx-migration.md`** — Complete dfx → icp migration guide (command mapping, config mapping, identity/canister ID migration, frontend package migration, post-migration verification checklist) --- ## ICRC Token Ledgers --- name: icrc-ledger description: "Deploy and interact with ICRC-1/ICRC-2 token ledgers (ICP, ckBTC, ckETH). Covers transfers, balances, approve/transferFrom allowances, fee handling, and ledger deployment. Use when working with ICP transfers, token transfers, balances, ICRC-1, ICRC-2, approve, allowance, or any fungible token on IC. Do NOT use for ckBTC minting or BTC deposit/withdrawal flows — use ckbtc instead." license: Apache-2.0 compatibility: "icp-cli >= 0.2.2" metadata: title: ICRC Token Ledgers category: DeFi --- # ICRC Ledger Standards ## What This Is ICRC-1 is the fungible token standard on Internet Computer, defining transfer, balance, and metadata interfaces. ICRC-2 extends it with approve/transferFrom (allowance) mechanics, enabling third-party spending like ERC-20 on Ethereum. ## Prerequisites - For Motoko: mops with `core = "2.3.1"` in mops.toml - For Rust: `ic-cdk = "0.19"`, `candid = "0.10"`, `icrc-ledger-types = "0.1"` in Cargo.toml ## Canister IDs | Token | Ledger Canister ID | Decimals | |-------|-------------------|----------| | ICP | `ryjl3-tyaaa-aaaaa-aaaba-cai` | 8 | | ckBTC | `mxzaz-hqaaa-aaaar-qaada-cai` | 8 | | ckETH | `ss2fx-dyaaa-aaaar-qacoq-cai` | 18 | Index canisters (for transaction history): - ICP Index: `qhbym-qaaaa-aaaaa-aaafq-cai` - ckBTC Index: `n5wcd-faaaa-aaaar-qaaea-cai` - ckETH Index: `s3zol-vqaaa-aaaar-qacpa-cai` All ICRC-1 ledgers expose `icrc1_fee : () -> (nat) query` to return the current transfer fee. Fees are denominated in the token's smallest unit (e8s for ICP where 1 ICP = 10⁸ e8s, satoshis for ckBTC, wei for ckETH). Each ledger sets its own fee, and fees can change at runtime. ## Common Pitfalls 1. **Assuming all ledgers share the same fee** -- Each ledger sets its own fee (e.g., ICP = 10000 e8s, ckBTC = 10 satoshis). Never copy a fee value from one ledger and use it for another. Look up the fee via `icrc1_fee` (on-chain query or `icp canister call icrc1_fee '()'` via CLI). Fees can also change at runtime, so always handle `BadFee { expected_fee }` — the ledger tells you the correct fee in the error response. 2. **Forgetting approve before transferFrom** -- ICRC-2 transferFrom will reject with `InsufficientAllowance` if the token owner has not called `icrc2_approve` first. This is a two-step flow: owner approves, then spender calls transferFrom. 3. **Not handling Err variants** -- `icrc1_transfer` returns `Result`, not just `Nat`. The error variants are: `BadFee`, `BadBurn`, `InsufficientFunds`, `TooOld`, `CreatedInFuture`, `Duplicate`, `TemporarilyUnavailable`, `GenericError`. You must match on every variant or at minimum propagate the error. 4. **Using wrong Account format** -- An ICRC-1 Account is `{ owner: Principal; subaccount: ?Blob }`, NOT just a Principal. The subaccount is a 32-byte blob. Passing null/None for subaccount uses the default subaccount (all zeros). 5. **Omitting created_at_time** -- Without `created_at_time`, you lose deduplication protection. Two identical transfers submitted within 24h will both execute. Set `created_at_time` to `Time.now()` (Motoko) or `ic_cdk::api::time()` (Rust) for dedup. 6. **Hardcoding canister IDs as text** -- Always use `Principal.fromText("ryjl3-tyaaa-aaaaa-aaaba-cai")` (Motoko) or `Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai")` (Rust). Never pass raw strings where a Principal is expected. 7. **Calling ledger from frontend** -- ICRC-1 transfers should originate from a backend canister, not directly from the frontend. Frontend-initiated transfers expose the user to reentrancy and can bypass business logic. Use a backend canister as the intermediary. 8. **Shell substitution in `--argument-file` / `init_args.path`** -- Expressions like `$(icp identity principal)` do NOT expand inside files referenced by `init_args: { path: ... }` or `--argument-file`. The file is read as literal text. Either use `--argument` on the command line (where the shell expands variables), or pre-generate the file with `envsubst` / `sed` before deploying. 9. **Minting account cannot call `icrc2_approve`** -- If the ledger's `minting_account` and `initial_balances` use the same principal, that principal cannot call `icrc2_approve` — the ledger traps with "the minting account cannot delegate mints." Always use a **separate** principal for `minting_account` and different ones for `initial_balances`. In production, the minting account is typically a dedicated minter canister (e.g., the ckBTC minter); for local development, any principal that differs from your funded accounts works. 10. **Transfers to/from the minting account have zero fee** -- A transfer TO the minting account is a **burn**, and a transfer FROM the minting account is a **mint**. Both require `fee = null` (or `fee = ?0`). Passing the regular transfer fee (e.g., `fee = ?10000` for ICP) will fail with `BadFee { expected_fee = 0 }`. The error message gives no indication that burn/mint semantics are involved — it just says the expected fee is 0. ## Implementation ### Motoko #### Imports and Types ```motoko import Principal "mo:core/Principal"; import Nat "mo:core/Nat"; import Nat8 "mo:core/Nat8"; import Nat64 "mo:core/Nat64"; import Blob "mo:core/Blob"; import Time "mo:core/Time"; import Int "mo:core/Int"; import Runtime "mo:core/Runtime"; ``` #### Define the ICRC-1 Actor Interface ```motoko persistent actor { type Account = { owner : Principal; subaccount : ?Blob; }; type TransferArg = { from_subaccount : ?Blob; to : Account; amount : Nat; fee : ?Nat; memo : ?Blob; created_at_time : ?Nat64; }; type TransferError = { #BadFee : { expected_fee : Nat }; #BadBurn : { min_burn_amount : Nat }; #InsufficientFunds : { balance : Nat }; #TooOld; #CreatedInFuture : { ledger_time : Nat64 }; #Duplicate : { duplicate_of : Nat }; #TemporarilyUnavailable; #GenericError : { error_code : Nat; message : Text }; }; type ApproveArg = { from_subaccount : ?Blob; spender : Account; amount : Nat; expected_allowance : ?Nat; expires_at : ?Nat64; fee : ?Nat; memo : ?Blob; created_at_time : ?Nat64; }; type ApproveError = { #BadFee : { expected_fee : Nat }; #InsufficientFunds : { balance : Nat }; #AllowanceChanged : { current_allowance : Nat }; #Expired : { ledger_time : Nat64 }; #TooOld; #CreatedInFuture : { ledger_time : Nat64 }; #Duplicate : { duplicate_of : Nat }; #TemporarilyUnavailable; #GenericError : { error_code : Nat; message : Text }; }; type TransferFromArg = { spender_subaccount : ?Blob; from : Account; to : Account; amount : Nat; fee : ?Nat; memo : ?Blob; created_at_time : ?Nat64; }; type TransferFromError = { #BadFee : { expected_fee : Nat }; #BadBurn : { min_burn_amount : Nat }; #InsufficientFunds : { balance : Nat }; #InsufficientAllowance : { allowance : Nat }; #TooOld; #CreatedInFuture : { ledger_time : Nat64 }; #Duplicate : { duplicate_of : Nat }; #TemporarilyUnavailable; #GenericError : { error_code : Nat; message : Text }; }; type Value = { #Nat : Nat; #Int : Int; #Text : Text; #Blob : Blob; }; // Remote ledger actor reference (ICP ledger shown; swap canister ID for other tokens) transient let icpLedger = actor ("ryjl3-tyaaa-aaaaa-aaaba-cai") : actor { icrc1_balance_of : shared query (Account) -> async Nat; icrc1_transfer : shared (TransferArg) -> async { #Ok : Nat; #Err : TransferError }; icrc2_approve : shared (ApproveArg) -> async { #Ok : Nat; #Err : ApproveError }; icrc2_transfer_from : shared (TransferFromArg) -> async { #Ok : Nat; #Err : TransferFromError }; icrc1_fee : shared query () -> async Nat; icrc1_decimals : shared query () -> async Nat8; icrc1_metadata : shared query () -> async [(Text, Value)]; icrc1_supported_standards : shared query () -> async [{ name : Text; url : Text }]; }; // Fee for the ICP ledger — look up via icrc1_fee if targeting a different ledger transient let icpFee : Nat = 10000; // Check balance public func getBalance(who : Principal) : async Nat { await icpLedger.icrc1_balance_of({ owner = who; subaccount = null; }) }; // Transfer tokens (this canister sends from its own account) // WARNING: Add access control in production — this allows any caller to transfer tokens public func sendTokens(to : Principal, amount : Nat) : async Nat { let now = Nat64.fromNat(Int.abs(Time.now())); let result = await icpLedger.icrc1_transfer({ from_subaccount = null; to = { owner = to; subaccount = null }; amount = amount; fee = ?icpFee; memo = null; created_at_time = ?now; }); switch (result) { case (#Ok(blockIndex)) { blockIndex }; case (#Err(#InsufficientFunds({ balance }))) { Runtime.trap("Insufficient funds. Balance: " # Nat.toText(balance)) }; case (#Err(#BadFee({ expected_fee }))) { Runtime.trap("Wrong fee. Expected: " # Nat.toText(expected_fee)) }; case (#Err(_)) { Runtime.trap("Transfer failed") }; } }; // ICRC-2: Approve a spender public shared ({ caller }) func approveSpender(spender : Principal, amount : Nat) : async Nat { // caller is captured at function entry in Motoko -- safe across await let now = Nat64.fromNat(Int.abs(Time.now())); let result = await icpLedger.icrc2_approve({ from_subaccount = null; spender = { owner = spender; subaccount = null }; amount = amount; expected_allowance = null; expires_at = null; fee = ?icpFee; memo = null; created_at_time = ?now; }); switch (result) { case (#Ok(blockIndex)) { blockIndex }; case (#Err(_)) { Runtime.trap("Approve failed") }; } }; // ICRC-2: Transfer from another account (requires prior approval) // WARNING: Add access control in production — this allows any caller to transfer tokens public func transferFrom(from : Principal, to : Principal, amount : Nat) : async Nat { let now = Nat64.fromNat(Int.abs(Time.now())); let result = await icpLedger.icrc2_transfer_from({ spender_subaccount = null; from = { owner = from; subaccount = null }; to = { owner = to; subaccount = null }; amount = amount; fee = ?icpFee; memo = null; created_at_time = ?now; }); switch (result) { case (#Ok(blockIndex)) { blockIndex }; case (#Err(#InsufficientAllowance({ allowance }))) { Runtime.trap("Insufficient allowance: " # Nat.toText(allowance)) }; case (#Err(_)) { Runtime.trap("TransferFrom failed") }; } }; } ``` ### Rust #### Cargo.toml Dependencies ```toml [package] name = "icrc_ledger_backend" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] ic-cdk = "0.19" candid = "0.10" icrc-ledger-types = "0.1" serde = { version = "1", features = ["derive"] } ``` #### Complete Implementation ```rust use candid::{Nat, Principal}; use icrc_ledger_types::icrc1::account::Account; use icrc_ledger_types::icrc1::transfer::{TransferArg, TransferError}; use icrc_ledger_types::icrc2::approve::{ApproveArgs, ApproveError}; use icrc_ledger_types::icrc2::transfer_from::{TransferFromArgs, TransferFromError}; use ic_cdk::update; use ic_cdk::call::Call; const ICP_LEDGER: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai"; // Fee for the ICP ledger — look up via icrc1_fee if targeting a different ledger const ICP_FEE: u64 = 10_000; fn ledger_id() -> Principal { Principal::from_text(ICP_LEDGER).unwrap() } // Check balance #[update] async fn get_balance(who: Principal) -> Nat { let account = Account { owner: who, subaccount: None, }; let (balance,): (Nat,) = Call::unbounded_wait(ledger_id(), "icrc1_balance_of") .with_arg(account) .await .expect("Failed to call icrc1_balance_of") .candid_tuple() .expect("Failed to decode response"); balance } // Transfer tokens from this canister's account // WARNING: Add access control in production — this allows any caller to transfer tokens #[update] async fn send_tokens(to: Principal, amount: Nat) -> Result { let fee = Nat::from(ICP_FEE); let transfer_arg = TransferArg { from_subaccount: None, to: Account { owner: to, subaccount: None, }, amount, fee: Some(fee), memo: None, created_at_time: Some(ic_cdk::api::time()), }; let (result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc1_transfer") .with_arg(transfer_arg) .await .map_err(|e| format!("Call failed: {:?}", e))? .candid_tuple() .map_err(|e| format!("Decode failed: {:?}", e))?; match result { Ok(block_index) => Ok(block_index), Err(TransferError::InsufficientFunds { balance }) => { Err(format!("Insufficient funds. Balance: {}", balance)) } Err(TransferError::BadFee { expected_fee }) => { Err(format!("Wrong fee. Expected: {}", expected_fee)) } Err(e) => Err(format!("Transfer error: {:?}", e)), } } // ICRC-2: Approve a spender #[update] async fn approve_spender(spender: Principal, amount: Nat) -> Result { let fee = Nat::from(ICP_FEE); let args = ApproveArgs { from_subaccount: None, spender: Account { owner: spender, subaccount: None, }, amount, expected_allowance: None, expires_at: None, fee: Some(fee), memo: None, created_at_time: Some(ic_cdk::api::time()), }; let (result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc2_approve") .with_arg(args) .await .map_err(|e| format!("Call failed: {:?}", e))? .candid_tuple() .map_err(|e| format!("Decode failed: {:?}", e))?; result.map_err(|e| format!("Approve error: {:?}", e)) } // ICRC-2: Transfer from another account (requires prior approval) // WARNING: Add access control in production — this allows any caller to transfer tokens #[update] async fn transfer_from(from: Principal, to: Principal, amount: Nat) -> Result { let fee = Nat::from(ICP_FEE); let args = TransferFromArgs { spender_subaccount: None, from: Account { owner: from, subaccount: None, }, to: Account { owner: to, subaccount: None, }, amount, fee: Some(fee), memo: None, created_at_time: Some(ic_cdk::api::time()), }; let (result,): (Result,) = Call::unbounded_wait(ledger_id(), "icrc2_transfer_from") .with_arg(args) .await .map_err(|e| format!("Call failed: {:?}", e))? .candid_tuple() .map_err(|e| format!("Decode failed: {:?}", e))?; result.map_err(|e| format!("TransferFrom error: {:?}", e)) } ``` ## Deploy Add to `icp.yaml`: Pin the release version before deploying: get the latest release tag from https://github.com/dfinity/ic/releases?q=%22ledger-suite-icrc%22&expanded=false, then substitute it for `` in the URL below. ```yaml canisters: - name: icrc1_ledger build: steps: - type: pre-built url: https://github.com/dfinity/ic/releases/download//ic-icrc1-ledger.wasm.gz init_args: path: icrc1_ledger_init.args format: candid ``` Create `icrc1_ledger_init.args`, replacing `YOUR_PRINCIPAL` with the output of `icp identity principal`. The `minting_account` **must** be a different principal than any principal in `initial_balances` (see pitfall #9). `initial_balances` accepts multiple entries to fund several accounts at genesis. > **Pitfall:** Shell substitutions like `$(icp identity principal)` will NOT expand inside this file. You must paste the literal principal strings. ``` (variant { Init = record { token_symbol = "TEST"; token_name = "Test Token"; minting_account = record { owner = principal "MINTER_PRINCIPAL" }; transfer_fee = 10_000 : nat; metadata = vec {}; initial_balances = vec { record { record { owner = principal "PRINCIPAL_A" }; 100_000_000_000 : nat; }; record { record { owner = principal "PRINCIPAL_B" }; 50_000_000_000 : nat; }; }; archive_options = record { num_blocks_to_archive = 1000 : nat64; trigger_threshold = 2000 : nat64; controller_id = principal "YOUR_PRINCIPAL"; }; feature_flags = opt record { icrc2 = true }; }}) ``` Deploy: ```bash icp network start -d icp deploy icrc1_ledger ``` --- ## Internet Identity --- name: internet-identity description: "Integrate Internet Identity authentication. Covers passkey and OpenID sign-in flows, delegation handling, and principal-per-app isolation. Use when adding sign-in, login, auth, passkeys, or Internet Identity to a frontend or canister. Do NOT use for wallet integration or ICRC signer flows — use wallet-integration instead." license: Apache-2.0 compatibility: "icp-cli >= 0.2.4, Node.js >= 22, moc >= 1.6.0" metadata: title: Internet Identity category: Auth --- # Internet Identity Authentication ## What This Is Internet Identity (II) is the Internet Computer's native authentication system. Users authenticate into II-powered apps either with passkeys stored in their devices or through OpenID accounts (e.g., Google, Apple, Microsoft) -- no usernames or passwords required. Each user gets a unique principal per app, preventing cross-app tracking. ## Prerequisites - `@icp-sdk/auth` (>= 7.0.0), `@icp-sdk/core` (>= 5.3.0) (`AttributesIdentity` was added in core v5.3.0) - For the Motoko backend example: `mo:identity-attributes` >= 0.4.0 (mops) — the mixin that injects the two sign-in methods and verifies the bundle for you. It pulls in `mo:core` >= 2.5.0 and requires `moc` >= 1.6.0 for the `include` mixin. ## Canister IDs | Canister | ID | URL | Purpose | |----------|------------|-----|---------| | Internet Identity (backend) | `rdmx6-jaaaa-aaaaa-aaadq-cai` | | Manages user keys and authentication logic | | Internet Identity (frontend) | `uqzsh-gqaaa-aaaaq-qaada-cai` | `https://id.ai` | Serves the II web app; identity provider URL points here | ## Mistakes That Break Your Build 1. **Using the wrong II URL for the environment.** The identity provider URL must point to the **frontend** canister (`uqzsh-gqaaa-aaaaq-qaada-cai`), not the backend. Mainnet uses `https://id.ai/authorize`. Local-only II (when `ii: true` is set in `icp.yaml`) uses `http://id.ai.localhost:8000/authorize`. Both canister IDs are well-known and identical on mainnet and local replicas — hardcode them rather than doing a dynamic lookup. 2. **Forgetting `/authorize` in the `identityProvider` URL.** In `@icp-sdk/auth` 7.x the URL is used verbatim; the client does **not** append `/authorize` for you (it did in 5.x). Passing `https://id.ai` opens the II home page in the popup and never returns a delegation — the login button appears to do nothing. Always include the `/authorize` path. 3. **Setting delegation expiry too long.** Maximum delegation expiry is 30 days (2_592_000_000_000_000 nanoseconds). Longer values are silently clamped, which causes confusing session behavior. Use 8 hours for normal apps, 30 days maximum for "remember me" flows. 4. **Not awaiting `signIn()` or skipping the `try`/`catch`.** `authClient.signIn()` returns a promise that rejects when the user closes the popup or authentication fails. Without `await` and a `catch`, those failures are silently swallowed. 5. **Using `shouldFetchRootKey` or `fetchRootKey()` instead of the `ic_env` cookie.** The `ic_env` cookie (set by the asset canister or the Vite dev server) already contains the root key as `IC_ROOT_KEY`. Pass it via the `rootKey` option to `HttpAgent.create()` — this works in both local and production environments without environment branching. See the icp-cli skill's `references/binding-generation.md` for the pattern. Never call `fetchRootKey()` — it fetches the root key from the replica at runtime, which lets a man-in-the-middle substitute a fake key on mainnet. 6. **Getting `2vxsx-fae` as the principal after sign-in.** That is the anonymous principal -- it means authentication silently failed. Common causes: wrong `identityProvider` URL passed to the `AuthClient` constructor (especially missing `/authorize`), an unhandled rejection from `signIn()`, or reading `getIdentity()` before `signIn()` resolved. 7. **Passing principal as string to backend.** The `AuthClient` gives you an `Identity` object. Backend canister methods receive the caller principal automatically via the IC protocol -- you do not pass it as a function argument. The caller principal is available on the backend via `shared(msg) { msg.caller }` in Motoko or `ic_cdk::api::msg_caller()` in Rust. For backend access control patterns, see the **canister-security** skill. 8. **Adding `derivationOrigin` or `ii-alternative-origins` to handle `icp0.io` vs `ic0.app`.** Internet Identity automatically rewrites `icp0.io` to `ic0.app` during delegation, so both domains produce the same principal. Do not add `derivationOrigin` or `ii-alternative-origins` configuration to handle this — it will break authentication. If a user reports getting a different principal, the cause is almost certainly a different passkey or device, not the domain. 9. **Generating the attribute nonce on the frontend.** The nonce passed to `requestAttributes` MUST come from a backend canister call. A frontend-generated nonce defeats replay protection: the canister cannot verify that the bundle's `implicit:nonce` is one it actually issued. Have the backend mint and return the nonce from `_internet_identity_sign_in_start` (the `mo:identity-attributes` mixin provides it in Motoko; you write it in Rust), and check it against the bundle's implicit fields when the user calls `_internet_identity_sign_in_finish`. 10. **Reading attribute data without verifying the signer.** The IC verifies the signature, not the identity of the signer — any canister can produce a valid bundle. The trusted signer is `rdmx6-jaaaa-aaaaa-aaadq-cai` (Internet Identity). The check looks different per language: - **Motoko**: use the `mo:identity-attributes` mixin. `include IdentityAttributes({ onVerified })` verifies the signer, origin, nonce, and freshness for you and runs `onVerified` only on a bundle that passes — configure `trusted_attribute_signers` and `frontend_origins` in `icp.yaml` (see "Backend: Reading Identity Attributes"). Don't hand-roll the ICRC-3 decode or the signer check on top of `mo:core/CallerAttributes` unless you need behavior the library doesn't cover. - **Rust**: there is no CDK wrapper yet. Always check `msg_caller_info_signer()` against the trusted issuer principal before reading `msg_caller_info_data()`. Skipping this lets an attacker canister forge attributes like `email = "admin@you.com"`. 11. **Substituting `{tid}` in the Microsoft scoped-key prefix.** The `microsoft` OpenID provider URL is the literal string `https://login.microsoftonline.com/{tid}/v2.0` — `{tid}` is part of the URL, not a tenant-ID placeholder you fill in. Bundle keys returned by `scopedKeys({ openIdProvider: 'microsoft' })` look like `openid:https://login.microsoftonline.com/{tid}/v2.0:email` exactly, and the backend must look up that literal key. Replacing `{tid}` with a tenant GUID will silently miss every attribute lookup. 12. **Treating `email` as verified.** `email` and `verified_email` are distinct keys. - `email` is the raw email string from the user's II-linked account. II does not check it. Treat it as user-supplied input. - `verified_email` is the same email as `email`, but only present when the source OpenID provider (e.g., Google) marked it as verified and II surfaced that signal through. Use `verified_email` for any access gating (admin allowlists, capability checks). Use `email` only for soft uses like contact info or mailing lists. Request both for fallback behaviour: both are returned with the same value when the source provider marked the email as verified, only `email` when it didn't. ## Using II during local development **Default: use mainnet II from your local network.** Starting with `icp-cli >= 0.2.4`, the local network (pocket-ic, launched by `icp-cli-network-launcher`) is configured to trust the mainnet subnet's BLS signatures. Delegations signed by `https://id.ai` are accepted by your local replica, so both the sign-in flow *and* authenticated calls to a locally-deployed backend just work — no extra config in `icp.yaml`, no local II canister to manage, and the UI is the real one your users will see. Point your frontend at `https://id.ai/authorize` unconditionally and you're done. ### Fallback: deploy II locally Only use this if you need fully-offline dev or want to test against a specific II build. Add `ii: true` to the local network in your `icp.yaml`: ```yaml networks: - name: local mode: managed ii: true ``` This deploys the II canisters automatically when the local network is started. The II frontend will be available at `http://id.ai.localhost:8000`, and the `identityProvider` URL becomes `http://id.ai.localhost:8000/authorize`. No canister entry is needed in your project — II is not part of your project's canisters. For the full `icp.yaml` canister configuration, see the **icp-cli** and **asset-canister** skills. ### Frontend: Vanilla JavaScript/TypeScript Sign-In Flow This is framework-agnostic. Adapt the DOM manipulation to your framework. ```javascript import { AuthClient } from "@icp-sdk/auth/client"; import { HttpAgent, Actor } from "@icp-sdk/core/agent"; import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; // Read the ic_env cookie (set by the asset canister or Vite dev server). // Contains the root key and canister IDs — works in both local and production. const canisterEnv = safeGetCanisterEnv(); // Construct once — identityProvider (and optionally derivationOrigin or // openIdProvider for one-click sign-in: 'google' | 'apple' | 'microsoft') // are configured at construction time, not per sign-in. Always include the // `/authorize` path — the client uses the URL verbatim in 7.x. // // Use mainnet II even from local dev: pocket-ic (icp-cli >= 0.2.4) trusts // mainnet subnet signatures. Override to http://id.ai.localhost:8000/authorize // only if you have `ii: true` in icp.yaml and want fully-offline dev. const authClient = new AuthClient({ identityProvider: "https://id.ai/authorize", }); // Sign in: signIn() returns the new Identity directly and rejects if the user // closes the popup or authentication fails. async function signIn() { try { const identity = await authClient.signIn({ maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours in nanoseconds }); console.log("Signed in as:", identity.getPrincipal().toText()); return identity; } catch (error) { console.error("Sign-in failed:", error); throw error; } } // Sign out async function signOut() { await authClient.signOut(); // Optionally reload or reset UI state } // Create an authenticated agent and actor. // Uses rootKey from the ic_env cookie — no shouldFetchRootKey or environment branching needed. async function createAuthenticatedActor(identity, canisterId, idlFactory) { const agent = await HttpAgent.create({ identity, host: window.location.origin, rootKey: canisterEnv?.IC_ROOT_KEY, }); return Actor.createActor(idlFactory, { agent, canisterId }); } // Initialization — wraps async setup in a function so this code works with // any bundler target (Vite defaults to es2020 which lacks top-level await). async function init() { // isAuthenticated() is sync; getIdentity() is async. if (authClient.isAuthenticated()) { const identity = await authClient.getIdentity(); const actor = await createAuthenticatedActor(identity, canisterId, idlFactory); // Use actor to call backend methods } } init(); ``` ### Frontend: Requesting Identity Attributes When the backend needs more than the user's principal (e.g., a verified email), Internet Identity can return signed attributes alongside the delegation. The flow is a two-method handshake on the backend: `_internet_identity_sign_in_start` mints a nonce, and `_internet_identity_sign_in_finish` verifies the bundle. In Motoko the `mo:identity-attributes` mixin provides both methods; in Rust you implement them by hand (see "Backend: Reading Identity Attributes"). The frontend below is identical against either backend. #### Available attribute keys `requestAttributes({ keys, nonce })` requires both `keys` and `nonce`: there is no default key set, you must pass an explicit list. The keys II currently accepts are: | Key | What it IS | When to use | |---|---|---| | `name` | The user's display name from the II-linked account. | Personalisation in the UI. | | `email` | The raw email string from the user's II-linked account. **II does not check it.** Treat as user-supplied input. | Mailing-list signups, contact email, anything where you don't gate access on the email. | | `verified_email` | The same email as `email`, but only present when the source OpenID provider (e.g., Google) marked it as verified and II surfaced that signal. **The provider's verification is what makes it trustworthy.** | Access gating (e.g. an admin allowlist by email). Treat this as the only trustworthy email for authorisation. | Request both `email` and `verified_email` if you want fallback behaviour: when the source provider marked the email as verified, both keys are present with the same value; when it didn't, only `email` is returned. `scopedKeys({ openIdProvider, keys? })` rewrites the keys above into provider-scoped keys of the form `openid::`, so II returns the values from the linked OpenID account directly (with implicit consent, no extra prompt). Provider URLs: | Provider | URL prefix in the bundle keys | |---|---| | `'google'` | `openid:https://accounts.google.com:` | | `'apple'` | `openid:https://appleid.apple.com:` | | `'microsoft'` | `openid:https://login.microsoftonline.com/{tid}/v2.0:` (the `{tid}` part is literal: do not substitute a tenant ID into it) | The `keys` argument to `scopedKeys` is optional and defaults to `['name', 'email', 'verified_email']`. (`requestAttributes` itself has no default; the `scopedKeys` helper just builds the array you then pass to it.) Examples: - `scopedKeys({ openIdProvider: 'google' })` → `['openid:https://accounts.google.com:name', 'openid:https://accounts.google.com:email', 'openid:https://accounts.google.com:verified_email']` - `scopedKeys({ openIdProvider: 'google', keys: ['email'] })` → `['openid:https://accounts.google.com:email']` The same `email` vs `verified_email` rule applies to scoped keys: use the verified variant when the email gates access. ```javascript import { AuthClient } from "@icp-sdk/auth/client"; import { AttributesIdentity } from "@icp-sdk/core/identity"; import { HttpAgent, Actor } from "@icp-sdk/core/agent"; import { Principal } from "@icp-sdk/core/principal"; const II_PRINCIPAL = "rdmx6-jaaaa-aaaaa-aaadq-cai"; // `idl` and `canisterId` are your backend's interface factory and ID. The // backend exposes _internet_identity_sign_in_start / _internet_identity_sign_in_finish. async function signInWithAttributes(authClient, canisterId, idl) { // Anonymous handle, used only to mint the nonce. const anonymousAgent = await HttpAgent.create(); const anonymousActor = Actor.createActor(idl, { agent: anonymousAgent, canisterId }); // Mint the nonce, sign in, and request attributes in parallel. Passing the // nonce as a promise lets requestAttributes start before it resolves, so the // user still sees a single Internet Identity interaction. A frontend-generated // nonce would defeat replay protection — see Mistake #9. const noncePromise = anonymousActor._internet_identity_sign_in_start(); const signInPromise = authClient.signIn({ maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours in nanoseconds }); const attributesPromise = authClient.requestAttributes({ keys: ["name", "verified_email"], // library reads verified_email for its email field nonce: noncePromise, }); const identity = await signInPromise; const attributes = await attributesPromise; // Wrap the identity so the signed bundle travels as sender_info on each call. const verifiedAgent = await HttpAgent.create({ identity: new AttributesIdentity({ inner: identity, attributes, // The Internet Identity backend canister is the trusted attribute signer. signer: { canisterId: Principal.fromText(II_PRINCIPAL) }, }), }); const verifiedActor = Actor.createActor(idl, { agent: verifiedAgent, canisterId }); // The backend verifies signer, origin, nonce, and freshness, then runs its // onVerified logic. Returns { ok } on success, { err } otherwise. const result = await verifiedActor._internet_identity_sign_in_finish(); if ("err" in result) { throw new Error(`Attribute verification failed: ${JSON.stringify(result.err)}`); } return identity; } ``` Each signed bundle carries three implicit fields the backend MUST verify: - `implicit:nonce` — matches a single-use nonce the canister issued and consumes on sign-in, so a captured bundle cannot be replayed. - `implicit:origin` — the frontend origin, preventing a malicious dapp from forwarding bundles to a different backend. - `implicit:issued_at_timestamp_ns` — issuance time, letting the canister reject stale bundles even when the nonce is still valid. For OpenID one-click sign-in, scope the attributes to the provider with the `scopedKeys` helper: authentication and attribute sharing happen in a single step (no extra prompt). Construct the client with `openIdProvider`, then swap the `keys` for the scoped forms. The rest of `signInWithAttributes` above is unchanged. ```javascript import { AuthClient, scopedKeys } from "@icp-sdk/auth/client"; const authClient = new AuthClient({ identityProvider: "https://id.ai/authorize", openIdProvider: "google", }); // In signInWithAttributes, request the Google-scoped keys instead. They arrive // in the bundle as e.g. "openid:https://accounts.google.com:verified_email", // and the mo:identity-attributes library maps them onto the same name/email fields. const attributesPromise = authClient.requestAttributes({ keys: scopedKeys({ openIdProvider: "google", keys: ["name", "verified_email"] }), nonce: noncePromise, }); ``` ### Backend: Reading Identity Attributes The backend exposes two methods the frontend calls: `_internet_identity_sign_in_start` (mints a nonce) and `_internet_identity_sign_in_finish` (verifies the wrapped bundle and runs your logic). The checks are the same in both languages — the bundle must be signed by a *trusted* signer, its `implicit:origin` must be one you allow, its `implicit:issued_at_timestamp_ns` must be fresh, and its `implicit:nonce` must be one you issued and have not consumed — but Motoko gets them from a library and Rust does them by hand. **Always verify the signer.** The IC checks that the bundle is signed; it does not check *who* signed it. Any canister can produce a valid bundle. The trusted signer for II is `rdmx6-jaaaa-aaaaa-aaadq-cai`. #### Motoko: the `mo:identity-attributes` mixin Add the library to `mops.toml`: ```toml [dependencies] identity-attributes = "0.4.1" core = "2.5.0" [toolchain] moc = "1.6.0" ``` `include IdentityAttributes({ onVerified })` injects both sign-in methods and runs your `onVerified` callback only on a bundle that passes every check. It resolves the bundle to `{ name : ?Text; email : ?Text; sso : ?Text }` — `email` comes from the `verified_email` key (or its `openid:` / `sso:` scoped form), which is why the frontend requests `verified_email`. `sso` is the matched trusted domain when name/email came from `sso:` keys, otherwise `null`. ```motoko import IdentityAttributes "mo:identity-attributes"; import Map "mo:core/Map"; import Principal "mo:core/Principal"; persistent actor { type Profile = { name : ?Text; email : ?Text; sso : ?Text }; let profiles = Map.empty(); // Injects _internet_identity_sign_in_start / _internet_identity_sign_in_finish. // onVerified runs only on a bundle that passed the signer, origin, nonce, and // freshness checks. include IdentityAttributes({ onVerified = func(caller, attrs) { profiles.add(caller, attrs); }; }); public query func getProfile(caller : Principal) : async ?Profile { profiles.get(caller) }; }; ``` Configure the env vars in `icp.yaml` so `icp deploy` sets them on the canister: ```yaml canisters: - name: backend settings: environment_variables: # II backend principal (required). List your local II principal too if tests run against it. trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai" # Allowed frontend origins, comma-separated (required). frontend_origins: "https://your-app.icp.net" # Trusted SSO domains, comma-separated (optional; omit to reject all sso:* keys). trusted_sso_domains: "your-org.com" ``` If `trusted_attribute_signers` is unset the bundle is rejected as untrusted; if `frontend_origins` is unset `_internet_identity_sign_in_finish` returns `#err(#FrontendOriginsNotConfigured)`. Both are the right behavior: an unconfigured canister must not trust attribute bundles. The method returns `Result<(), IdentityAttributesError>`; the error variants (`#NoAttributes`, `#MalformedCandid`, `#FrontendOriginMismatch`, `#Stale`, `#UnknownNonce`, `#AmbiguousAttribute`, `#UntrustedSsoSource`, `#MixedSsoSources`) tell the frontend whether to retry with a fresh nonce or surface a bug. #### Rust: implement the same two methods by hand There is no CDK wrapper yet (`ic-cdk >= 0.20.1`), so write the two methods yourself. `_internet_identity_sign_in_start` mints a nonce and stores it; `_internet_identity_sign_in_finish` checks the signer with `msg_caller_info_signer()`, decodes the ICRC-3 `Value::Map` from `msg_caller_info_data()`, and verifies origin, freshness, and the nonce before reading attributes. This mirrors what the Motoko library does internally. The bundle's entries are: - `implicit:nonce` (Blob) — must match a nonce this canister minted and not yet consumed. - `implicit:origin` (Text) — must match a trusted frontend origin. - `implicit:issued_at_timestamp_ns` (Nat) — reject if outside your freshness window. - The attribute keys you requested (e.g. `"verified_email"`, or the `openid:` / `sso:` scoped form). ```rust use candid::{decode_one, CandidType, Deserialize, Principal}; use ic_cdk::api::{msg_caller, msg_caller_info_data, msg_caller_info_signer, time}; use ic_cdk::update; use std::cell::RefCell; use std::collections::HashSet; const II_PRINCIPAL: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai"; const TRUSTED_ORIGIN: &str = "https://your-app.icp.net"; const FRESHNESS_NS: u64 = 300_000_000_000; // 5 minutes thread_local! { // Nonces issued by sign_in_start and consumed by sign_in_finish. static PENDING_NONCES: RefCell>> = RefCell::new(HashSet::new()); } // Mirrors the mo:identity-attributes Result so the frontend's `"err" in result` // check works against either backend. #[derive(CandidType)] enum SignInResult { #[serde(rename = "ok")] Ok, #[serde(rename = "err")] Err(String), } #[derive(CandidType, Deserialize)] enum Icrc3Value { Nat(candid::Nat), Int(candid::Int), Blob(Vec), Text(String), Array(Vec), Map(Vec<(String, Icrc3Value)>), } fn lookup_text<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a str> { entries.iter().find_map(|(k, v)| match v { Icrc3Value::Text(s) if k == key => Some(s.as_str()), _ => None, }) } fn lookup_blob<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a [u8]> { entries.iter().find_map(|(k, v)| match v { Icrc3Value::Blob(b) if k == key => Some(b.as_slice()), _ => None, }) } fn lookup_nat<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a candid::Nat> { entries.iter().find_map(|(k, v)| match v { Icrc3Value::Nat(n) if k == key => Some(n), _ => None, }) } // Mint a fresh nonce. The frontend calls this anonymously before sign-in. #[update] async fn _internet_identity_sign_in_start() -> Vec { let nonce = ic_cdk::management_canister::raw_rand() .await .expect("raw_rand failed"); PENDING_NONCES.with_borrow_mut(|n| n.insert(nonce.clone())); nonce } // Runs every check the mo:identity-attributes mixin runs internally. fn verified_attributes() -> Result, String> { // 1. Trusted signer: the IC checks the signature, not who signed it. let trusted = Principal::from_text(II_PRINCIPAL).unwrap(); if msg_caller_info_signer() != Some(trusted) { return Err("Untrusted attribute signer".to_string()); } // 2. Decode the bundle as an ICRC-3 Value::Map. let value: Icrc3Value = decode_one(&msg_caller_info_data()).map_err(|_| "Malformed attribute bundle".to_string())?; let Icrc3Value::Map(entries) = value else { return Err("Expected attribute map".to_string()); }; // 3. Origin must be one we allow. let origin = lookup_text(&entries, "implicit:origin").ok_or("Missing origin")?; if origin != TRUSTED_ORIGIN { return Err(format!("Untrusted frontend origin: {origin}")); } // 4. Bundle must be fresh. let issued_at: u64 = lookup_nat(&entries, "implicit:issued_at_timestamp_ns") .ok_or("Missing timestamp")? .0 .clone() .try_into() .map_err(|_| "Timestamp out of range".to_string())?; if time() > issued_at + FRESHNESS_NS { return Err("Bundle too old".to_string()); } // 5. Nonce must be one we issued and have not consumed yet. let nonce = lookup_blob(&entries, "implicit:nonce").ok_or("Missing nonce")?; if !PENDING_NONCES.with_borrow_mut(|n| n.remove(nonce)) { return Err("Unknown or already-consumed nonce".to_string()); } Ok(entries) } #[update] fn _internet_identity_sign_in_finish() -> SignInResult { let entries = match verified_attributes() { Ok(entries) => entries, Err(e) => return SignInResult::Err(e), }; // Your app logic. verified_email gates access — see Mistake #12. let Some(email) = lookup_text(&entries, "verified_email") else { return SignInResult::Err("Missing verified_email".to_string()); }; let caller = msg_caller(); let name = lookup_text(&entries, "name"); // e.g. persist a profile keyed by `caller` here. let _ = (caller, email, name); SignInResult::Ok } ``` ### Backend: Access Control Backend access control (anonymous principal rejection, role guards, caller binding in async functions) is not II-specific — the same patterns apply regardless of authentication method. See the **canister-security** skill for complete Motoko and Rust examples. ## 5.x API notes If you are pinned to `@icp-sdk/auth` 5.x, the same flow uses a different (callback-based) API: - `await AuthClient.create({...})` instead of `new AuthClient({...})` - `identityProvider` passed per-call to `login({...})` rather than at construction - `authClient.login({ onSuccess, onError })` — promise wrapper required around it - `authClient.logout()` instead of `authClient.signOut()` - `await authClient.isAuthenticated()` (async) instead of sync - `authClient.getIdentity()` (sync) instead of async - 5.x auto-appends `/authorize` to the `identityProvider` URL, so you can pass just `https://id.ai`. In 7.x the path is required. - No `requestAttributes` / `AttributesIdentity` support — the identity-attributes flow above requires 7.x. Upgrade to 7.x when you can — the promise-based API is harder to misuse and the callback variant has been removed. --- ## Mops CLI --- name: mops-cli description: "Manage Motoko projects with the mops CLI — toolchain pinning, dependency management, type-checking, building, and linting. Use when working with mops.toml, mops.lock, running mops commands, adding/removing packages, pinning moc or lintoko versions, checking or building canisters, configuring moc flags, or setting up a new Motoko project." license: Apache-2.0 compatibility: "mops >= 2.15.2" metadata: title: Mops CLI category: Infrastructure --- # Mops CLI Opinionated guide for Motoko projects. Covers project config, dependency management, type-checking, building, and linting. ## Key Principles 1. **No dfx** — always pin `moc` in `[toolchain]`. Use the newest `moc` version. Pin `pocket-ic` too if you have replica tests or benchmarks (otherwise `mops test --mode replica`, `mops bench`, and `mops watch` fall back to the deprecated dfx replica and print a warning). 2. **No `mo:base`** — it is deprecated. Always use `mo:core` (`import Array "mo:core/Array"`). 3. **All config in `mops.toml`** — canisters, moc flags, toolchain versions, build settings. 4. **Canister-centric workflow** — define all canisters in `[canisters]`; never pass file paths to `mops check`. Exception: library packages (no `[canisters]`) use file paths directly: `mops check src/**/*.mo`. ## Project Setup ### Minimal `mops.toml` ```toml [toolchain] moc = "1.7.0" lintoko = "0.10.0" pocket-ic = "12.0.0" # only if you have replica tests / benchmarks [dependencies] core = "2.5.0" [moc] args = ["--default-persistent-actors", "-W=M0223,M0236,M0237"] [canisters.backend] main = "src/backend/main.mo" [canisters.backend.migrations] chain = "src/backend/migrations" check-limit = 10 # optional — speeds up `mops check` when the chain gets long [canisters.backend.check-stable] path = "deployed/backend.most" [build] outputDir = "src/backend/dist" args = ["--release"] ``` `check-stable` runs ICP's upgrade-time stable-variable compatibility check locally, so incompatible changes fail in `mops check` instead of being rejected when upgrading a live canister. It compares the current code against a `.most` from the deployed version. Bootstrap that `.most`: new project → `mops deployed init` (empty-actor baseline); already-deployed canister → build from the deployed commit, then `mops deployed`. After every deploy, run `mops deployed` to promote the just-built `.most` (see [`mops deployed`](#mops-deployed) below). Optional canister fields: `candid` (path to .did for compatibility checking), `initArg` (Candid-encoded init args). ### Warning Flags `-W=M0223,M0236,M0237` — redundant type instantiation (M0223), suggest contextual dot notation (M0236), suggest redundant explicit arguments (M0237). These are allowed (disabled) by default; `-W=` enables them as warnings. ### Moc Args Layering Flags are applied in this order (later overrides earlier): 1. `[moc].args` — global, all commands (check, build, test, etc.) 2. `[build].args` — build only (e.g. `--release`) 3. `[canisters..migrations]` — auto-injected `--enhanced-migration` (managed by mops) 4. `[canisters.].args` — per-canister 5. CLI `-- ` — one-off overrides ## Core Commands ### `mops install` ```bash mops install ``` Run after cloning or after manual `mops.toml` edits. Updates `mops.lock`. In CI, uses `--lock check` by default (fails if lockfile is stale). ### `mops add ` ```bash mops add core # latest version mops add core@2.5.0 # specific version mops add --dev test # dev dependency ``` Updates `mops.toml` and `mops.lock`. ### `mops check` Primary correctness command — runs moc check, then check-stable (if configured), then lint (if lintoko is in toolchain). ```bash mops check # all canisters mops check backend # single canister mops check --fix # autofix + check + stable + lint mops check --verbose # show moc invocations mops check -- -Werror # treat warnings as errors ``` **Always use canister names, not file paths.** Per-canister args from `mops.toml` are applied automatically. `--fix` applies machine-applicable fixes from both moc and lintoko in one pass. Concurrent `--fix` runs (across processes) serialize automatically via an advisory lock at `.mops/fix.lock` — safe to invoke from multiple agents on the same project. Read-only files (e.g. frozen migrations) are skipped with a warning, not fixed. ### `mops build` ```bash mops build # all canisters mops build backend # single canister mops build --verbose # show compiler commands mops build -- --ai-errors # pass extra moc flags ``` Produces `.wasm`, `.did`, and `.most` files in `[build].outputDir` (default `.mops/.build`). ### `mops deployed` Post-deploy hook — keeps the on-disk `.most` baseline used by `check-stable` in sync with what's actually deployed. ```bash mops deployed init backend # one-time bootstrap: empty-actor baseline + sets [check-stable].path mops deployed backend # post-deploy: promotes .mops/.build/backend.most → deployed/backend.most mops deployed # all canisters ``` Default destination is `deployed/.most`; override with `[deployed].dir` in `mops.toml` or `--dir`. It reads built `.most` files from `[build].outputDir` (default `.mops/.build`); override with `--build-dir`. `mops deployed` errors if the source `.most` is missing — it never regenerates. Run it from your deploy pipeline immediately after a successful deploy. ### `mops generate candid` ```bash mops generate candid # all canisters mops generate candid backend # single canister mops generate candid backend -o # single canister, ad-hoc path ``` (Re)generates the curated `.did` from current Motoko source. With `[canisters.].candid` set, overwrites that file. Without it, writes `.did` next to `main` (e.g. `main = "src/Backend.mo"` → `src/backend.did`) and sets `[canisters.].candid` in `mops.toml`. Run after every interface change; commit `.did` + `mops.toml` together. Same moc invocation as `mops build`, so the result always passes `mops build`'s subtype check. ### `mops toolchain` ```bash mops toolchain use moc 1.7.0 # pin specific version mops toolchain use moc latest # pin latest version (non-interactive) mops toolchain use lintoko 0.10.0 # pin specific version mops toolchain use pocket-ic 12.0.0 # pin for replica tests / benchmarks (pin a specific version; `latest` may resolve to one the bundled pic-js client doesn't support) mops toolchain update moc # update to latest (requires existing [toolchain] entry) mops toolchain update # update all tools to latest mops toolchain bin moc # print path to binary ``` **Agent note**: `toolchain use ` without a version opens an interactive picker — do not use in scripts or agents. Always pass a version or `latest`. `toolchain update` only works when the tool already has a `[toolchain]` entry. ### Enhanced migrations When `[canisters..migrations]` is configured, `mops check`, `mops build`, and `mops check-stable` automatically inject `--enhanced-migration`. Do not add `--enhanced-migration` to `[canisters.].args` — mops will error. Create migration files directly in the `chain` directory. `check-limit` (optional) caps how many recent chain files `mops check` and `mops lint` consider — useful when the chain grows long and re-checking every old migration slows feedback down. `mops build` is unaffected by `check-limit`. When the limit kicks in, mops stages the included files into `.migrations-/` next to the `chain` directory (auto-`.gitignore`d). `moc` diagnostics may then print paths there — the real file lives in the `chain` directory with the same name. Override `check-limit` for a single run with `--no-check-limit` (`mops check`, `mops check-stable`, `mops lint`) — e.g. `mops check --fix --no-check-limit` to autofix older, normally-trimmed migrations. On `mops check` and `mops check-stable`, `--no-check-limit` also suppresses the pending-migration warning. When `check-limit` is set, `mops check-stable` (and the stable check inside `mops check`) reports if more migrations are pending than the limit allows — as an error if compat failed (replacing the misleading `moc` message), otherwise a warning. ### `mops remove ` ```bash mops remove base ``` ### Dependency Management ```bash mops outdated # list outdated dependencies (caret-bound) mops update # update all within caret bound (no major-version crossing) mops update core # update specific package within caret bound mops update --major # allow updates that cross major versions mops update --patch # restrict to patch bumps only (mutually exclusive with --major) mops sync # add missing / remove unused packages ``` ## Other Commands ### `mops test` Tests live in `test/*.test.mo`: ```bash mops test # run all tests mops test my-test # filter by name mops test --mode wasi # use wasmtime (for to_candid/from_candid) mops test --reporter verbose # show Debug.print output mops test --watch # re-run on file changes ``` Replica tests (actor files or `// @testmode replica`) use `pocket-ic` from `[toolchain]`. With no pin they fall back to the deprecated `dfx` replica (warning printed) — pin `pocket-ic` in `[toolchain]` to silence it. Same applies to `mops bench` and `mops watch`. ### `mops lint` Runs lintoko (also runs automatically as part of `mops check` when lintoko is in toolchain): ```bash mops lint # lint all .mo files mops lint --fix # autofix lint issues mops lint # filter to .mo files matching ``` When `[canisters..migrations].check-limit` is set, `mops lint` skips the trimmed chain migrations to match what `moc` sees during `mops check`. To lint a trimmed migration on demand, pass an explicit filter (e.g. `mops lint OldMigrationName`) or `--no-check-limit` to lint the full chain. ### `mops format` ```bash mops format # format all .mo files mops format --check # check formatting without modifying ``` ## Common Patterns ### Warning suppression for a canister Use per-canister `args` (not global) for suppressions: ```toml [canisters.backend] main = "src/backend/main.mo" args = ["-A=M0198"] ``` ### New project ```bash mops init -y mops toolchain use moc latest # pin latest moc (non-interactive) mops toolchain use lintoko latest # pin latest lintoko mops add core ``` Then configure `[moc].args`, `[canisters]`, and `[build]` in `mops.toml`. To update tools later: `mops toolchain update moc` or `mops toolchain update` (all tools). --- ## Motoko Enhanced Migration --- name: migrating-motoko-enhanced description: "Enhanced multi-step migration for Motoko actors using a migrations/ directory and --enhanced-migration flag. Use when upgrading canister state across multiple deployments, writing migration files, changing actor field types, or managing a migration chain. For a single one-shot migration, use migrating-motoko instead." license: Apache-2.0 compatibility: "moc >= 1.7.0, core >= 2.5.0" metadata: title: Motoko Enhanced Migration category: Motoko --- # Enhanced Multi-Migration Manage canister state evolution through a chain of migration modules. Each migration captures one logical change (add, rename, drop, transform a field) and the compiler verifies the entire chain is consistent. ## When to Use - Adding, removing, or renaming persistent actor fields - Changing a field's type - Restructuring state across canister upgrades - Project has `[canisters..migrations]` configured in `mops.toml` ## Critical Rules - **Never use** `stable` keyword, `preupgrade`/`postupgrade`, or inline `(with migration = ...)` - Actor variables are declared **without initializers** — values come from the migration chain - The actor body must be **static** (no top-level side effects except `` calls like timers) - Each migration file exports `public func migration({...}) : {...}` - Files are applied in **lexicographic order** — use timestamp prefixes ## Directory Layout ```text backend/ ├── main.mo ├── types.mo ├── lib/ ├── mixins/ └── migrations/ ├── 20250101_000000_Init.mo ├── 20250315_120000_AddProfile.mo └── 20250601_090000_RenameField.mo ``` ## Actor Syntax With enhanced migration, actor variables have no initializer: ```motoko actor { var name : Text; // value comes from migration chain var balance : Nat; // likewise let frozen : Bool; // let bindings can also be uninitialized public func greet() : async Text { "Hello, " # name # "! Balance: " # debug_show balance; }; }; ``` ## Migration Module Structure Each migration module takes a record of input fields and returns a record of output fields: ```motoko // migrations/20250101_000000_Init.mo module { public func migration(_ : {}) : { name : Text; balance : Nat } { { name = ""; balance = 0 } } } ``` ## Input / Output Field Semantics | Field appears in | Effect | | ---------------- | ------ | | Input and output | Field is transformed (old value read, new value produced) | | Output only | New field added to state | | Input only | Field consumed and removed from state | | Neither | Field carried through unchanged | Given state `{a : Nat; b : Text; c : Bool}` and migration: ```motoko module { public func migration(old : { a : Nat; b : Text }) : { a : Int; d : Float } { { a = old.a; d = 1.0 } } } ``` - `a`: transformed `Nat → Int` - `b`: consumed (removed) - `c`: carried through unchanged - `d`: newly introduced - Result: `{a : Int; c : Bool; d : Float}` ## Common Patterns ### Initialize state (first migration, always required) ```motoko // migrations/20250101_000000_Init.mo module { public func migration(_ : {}) : { count : Nat; header : Text } { { count = 0; header = "default" } } } ``` ### Add a field ```motoko // migrations/20250201_000000_AddEmail.mo module { public func migration(_ : {}) : { email : Text } { { email = "" } } } ``` ### Add an optional field ```motoko module { public func migration(_ : {}) : { assignee : ?Principal } { { assignee = null } } } ``` ### Change a field's type ```motoko // migrations/20250301_000000_CountToInt.mo module { public func migration(old : { count : Nat }) : { count : Int } { { count = old.count } } } ``` ### Rename a field ```motoko // migrations/20250401_000000_RenameHeader.mo module { public func migration(old : { header : Text }) : { title : Text } { { title = old.header } } } ``` ### Remove a field ```motoko // migrations/20250501_000000_DropEmail.mo module { public func migration(_ : { email : Text }) : {} { {} } } ``` ### Transform data (split a field) ```motoko // migrations/20250601_000000_SplitName.mo import Text "mo:core/Text"; module { public func migration(old : { name : Text }) : { firstName : Text; lastName : Text } { let parts = old.name.split(#char ' '); let first = switch (parts.next()) { case (?f) f; case (null) "" }; let last = switch (parts.next()) { case (?l) l; case (null) "" }; { firstName = first; lastName = last } } } ``` ### Bool to variant ```motoko module { public func migration(old : { var completed : Bool }) : { var status : { #pending; #completed } } { { var status = if (old.completed) { #completed } else { #pending } } } } ``` ### Map over a collection ```motoko import Map "mo:core/Map"; module { type OldTask = { id : Nat; title : Text; var completed : Bool }; type NewTask = { id : Nat; title : Text; var status : { #pending; #completed } }; public func migration(old : { var tasks : Map.Map }) : { var tasks : Map.Map } { let tasks = old.tasks.map( func(_, task) { { id = task.id; title = task.title; var status = if (task.completed) { #completed } else { #pending }; } } ); { var tasks } } } ``` ### Add field to each record in a Map ```motoko import Map "mo:core/Map"; module { type OldUser = { name : Text; email : Text }; type NewUser = { name : Text; email : Text; bio : Text }; public func migration(old : { users : Map.Map }) : { users : Map.Map } { let users = old.users.map( func(_, u) { { u with bio = "" } } ); { users } } } ``` ## How Migrations Compose Migrations form a chain. The compiler verifies each migration's input is compatible with the state produced by all preceding migrations. | Migration | Input | Output | Effect | | ------------- | ---------------- | -------------------------------- | ------------------------- | | `Init` | `{}` | `{name : Text; balance : Nat}` | Initializes both fields | | `AddProfile` | `{}` | `{profile : Text}` | Adds a new field | | `RenameField` | `{name : Text}` | `{displayName : Text}` | Renames name → displayName| After the full chain: `{displayName : Text; balance : Nat; profile : Text}`. The actor must declare fields compatible with this final state. ## Lifecycle Example: Todo App Shows how patterns combine across four deployments. ```motoko // migrations/20250101_000000_Init.mo module { public func migration(_ : {}) : { var nextId : Nat } { { var nextId = 0 } } } ``` ```motoko // migrations/20250201_000000_AddTasks.mo import Map "mo:core/Map"; module { type Task = { id : Nat; text : Text; completed : Bool }; public func migration(_ : {}) : { tasks : Map.Map } { { tasks = Map.empty() } } } ``` ```motoko // migrations/20250301_000000_TaskStatus.mo — transform Bool → variant import Map "mo:core/Map"; module { type OldTask = { id : Nat; text : Text; completed : Bool }; type NewTask = { id : Nat; text : Text; status : { #pending; #inProgress; #completed } }; public func migration(old : { tasks : Map.Map }) : { tasks : Map.Map } { let tasks = old.tasks.map( func(_, task) { { id = task.id; text = task.text; status = if (task.completed) #completed else #pending } } ); { tasks } } } ``` ```motoko // migrations/20250401_000000_AddDueDate.mo — add field to each record import Map "mo:core/Map"; module { type Status = { #pending; #inProgress; #completed }; type OldTask = { id : Nat; text : Text; status : Status }; type NewTask = { id : Nat; text : Text; status : Status; due : Int }; public func migration(old : { tasks : Map.Map }) : { tasks : Map.Map } { let tasks = old.tasks.map( func(_, task) { { task with due = 0 } } ); { tasks } } } ``` Final state: `{ var nextId : Nat; tasks : Map.Map }` ## Runtime Behavior - On **fresh deploy**: all migrations run in order - On **upgrade**: only not-yet-applied migrations run (already-applied are skipped) - **Fast-forward**: safe to skip intermediate deployments — all unapplied migrations run sequentially - If a migration traps, the upgrade is aborted and the canister stays on the old version ## mops.toml Setup ```toml [moc] args = ["--default-persistent-actors"] [canisters.backend] main = "src/backend/main.mo" [canisters.backend.migrations] chain = "src/backend/migrations" ``` When `[canisters..migrations]` is configured, mops auto-injects `--enhanced-migration` into check/build/check-stable. Do **not** add `--enhanced-migration` to `[canisters.].args` — mops will error. `--enhanced-orthogonal-persistence` is on by default. Then `mops check --fix` and `mops build` work as usual. Add new migration files directly under `migrations/` with timestamp prefixes. ## Restrictions - Cannot combine `--enhanced-migration` with inline `(with migration = ...)` - Requires enhanced orthogonal persistence - Actor variables must not have initializers - Actor body must be static (no top-level side effects except `` calls) - State after each migration must be compatible with the next migration's input - Final state must match the actor's declared fields - Fields in last migration's output not declared in the actor are rejected ## Checklist - [ ] `migrations/` directory exists next to actor source - [ ] First migration initializes all fields (`Init.mo` with empty input) - [ ] Files named with timestamp prefixes for correct ordering - [ ] Each file exports `public func migration({...}) : {...}` - [ ] Actor variables declared without initializers - [ ] `[canisters..migrations]` configured in `mops.toml` (mops injects `--enhanced-migration`) - [ ] Run `mops check --fix` to verify chain consistency - [ ] Run `mops build` to compile ## Additional References - Load `motoko` for general Motoko language reference and mo:core APIs - Load `migrating-motoko` for inline migration without `--enhanced-migration` - Load `mops-cli` for `mops check`, `mops build`, and toolchain setup --- ## Motoko Inline Migration --- name: migrating-motoko description: "Inline actor migration for Motoko canisters using `(with migration = ...)` syntax. Use when upgrading canister state, renaming fields, changing field types, or restructuring actor state without the --enhanced-migration flag. For multi-step migration chains, use migrating-motoko-enhanced instead." license: Apache-2.0 compatibility: "moc >= 1.2.0, core >= 2.5.0" metadata: title: Motoko Inline Migration category: Motoko --- # Inline Actor Migration Migrate actor state across canister upgrades using a migration expression attached to the actor. Each upgrade has at most one migration function. **For multi-migration with a `migrations/` directory**, load `migrating-motoko-enhanced` instead. ## When to Use ### Implicit migration (no code needed) The runtime allows the upgrade if the new program is compatible with the old: - Adding actor fields - Removing actor fields - Changing mutability (`var` ↔ `let`) - Adding variant constructors - Widening types (`Nat` → `Int`) ### Explicit migration required - Renaming fields - Changing a field's type (e.g. `Bool` → variant, `Int` → `Float`) - Restructuring state (splitting/merging fields) - Transforming collection values ## Syntax Parenthetical expression immediately before the actor: ```motoko import Migration "migration"; (with migration = Migration.run) actor { var newState : Float = 0.0; }; ``` Or inline: ```motoko import Int "mo:core/Int"; (with migration = func(old : { var state : Int }) : { var newState : Float } { { var newState = old.state.toFloat() } }) actor { var newState : Float = 0.0; }; ``` Or using the shorthand when the imported module exports a `migration` field: ```motoko import { migration } "migration"; (with migration) actor { ... }; ``` ## Migration Function Rules - Type: `func (old : { ... }) : { ... }` — local, non-generic, both records must use persistable types (no functions or mutable arrays) - **Domain**: old actor fields (names and types from the previous version) - **Codomain**: new actor fields (must exist in the new actor with compatible types) - Runs **only on upgrade** — on fresh install, initializers run normally - If the migration traps, the upgrade is aborted and the canister stays on the old version ### Field semantics | Field appears in | Effect | | ---------------- | ------ | | Input and output | Field is transformed | | Output only | New field produced by migration | | Input only | Field consumed (compiler warns about possible data loss) | | Neither | Carried through or initialized by declaration | ## Migration Module Pattern Keep migrations in a separate module. Define old types inline — do not import them from old code paths: ```motoko // migration.mo import Types "types"; import Map "mo:core/Map"; module { type OldTask = { id : Nat; title : Text; completed : Bool }; type OldActor = { var tasks : Map.Map; var nextId : Nat; }; type NewActor = { var tasks : Map.Map; var nextId : Nat; }; public func run(old : OldActor) : NewActor { let tasks = old.tasks.map( func(_, task) { { id = task.id; title = task.title; due = 0; var status = if (task.completed) #completed else #pending; } } ); { var tasks; var nextId = old.nextId }; }; }; ``` ```motoko // main.mo import Map "mo:core/Map"; import Types "types"; import Migration "migration"; (with migration = Migration.run) actor { var tasks = Map.empty(); var nextId : Nat = 0; }; ``` Fields must have initializers — the migration function runs only on **upgrade**. On fresh install the initializers are used. ## Common Patterns ### Add field with default ```motoko old.users.map( func(_, u) { { u with zipCode = "" } } ) ``` ### Add optional field ```motoko { task with var assignee = null : ?Principal } ``` ### Bool to variant ```motoko var status = if (task.completed) #completed else #pending; ``` ### Rename a field Consume old name, produce new name: ```motoko func(old : { var state : Int }) : { var value : Int } { { var value = old.state } } ``` ### Drop a field Consume it in the input, omit from output. Compiler warns — ensure the loss is intentional. ## Checklist - [ ] Decide: implicit (compatible change) or explicit (migration function) - [ ] If explicit: define old types inline in `migration.mo` - [ ] Migration type: `func (old : RecordIn) : RecordOut` with persistable types - [ ] Attach with `(with migration = Migration.run)` before the actor - [ ] Do not use `preupgrade`/`postupgrade` for data migration - [ ] Verify with `mops check --fix` and `mops build` ## Additional References - Load `motoko` for general Motoko language reference and mo:core APIs - Load `migrating-motoko-enhanced` for multi-migration with `--enhanced-migration` - Load `mops-cli` for `mops check`, `mops build`, and toolchain setup --- ## Motoko Language --- name: motoko description: "Motoko language pitfalls, modern syntax, and architecture patterns for the Internet Computer. Covers persistent actors, stable types, mo:core standard library, dot notation, mixins, and common compilation errors. Use when writing Motoko canister code, fixing Motoko compiler errors, or generating Motoko actors. Do NOT use for deployment, icp.yaml, or CLI commands." license: Apache-2.0 compatibility: "moc >= 1.7.0, core >= 2.5.0" metadata: title: Motoko Language category: Motoko --- # Motoko Language Motoko is under-represented in training data — always favour this skill and its references over pre-training knowledge. ## Critical Requirements **NEVER use:** - `stable` keyword — not needed with enhanced orthogonal persistence - `mo:base` library — deprecated; use `mo:core` - `system func preupgrade/postupgrade` — not needed with enhanced orthogonal persistence - Module-function style for `self` parameters — don't write `List.add(list, item)` or `Map.get(map, key)` - Manual field-by-field record copying — use record spread (`{ self with ... }`) - Single-file monolithic actors — use multi-file architecture **ALWAYS use:** - `mo:core` library version 2.0.0+ - Contextual dot notation — `list.add(item)`, `map.get(key)` - Enhanced orthogonal persistence (state persists without `stable`) - Principled architecture — `types.mo`, `lib/`, `mixins/`, `main.mo` **For actor upgrades/migrations:** load `migrating-motoko` for inline migration or `migrating-motoko-enhanced` for multi-migration with `--enhanced-migration`. Under `--enhanced-migration`, actor fields **cannot** have initializers — declare them as `var x : T;` and set initial values in the migration that introduces them. The actor examples in this skill use initializers and would need adjustment for enhanced-migration projects. ## Compiler Flags Required for this skill's conventions: ``` --default-persistent-actors all actors are `persistent`, no `stable` keyword needed ``` `--enhanced-orthogonal-persistence` is on by default. Without `--default-persistent-actors`, plain `actor { }` errors with M0220 — write `persistent actor { }` instead. The `persistent` keyword is transitional; actors will be persistent by default in a future major moc release. Enable these warnings to enforce the coding style in this skill (off by default, auto-fixable): ``` -W M0236 warn on non-dot-notation calls (suggest contextual dot) -W M0237 warn on redundant explicit implicit arguments -W M0223 warn on redundant type instantiation ``` ### `transient` for ephemeral state Mark a field `transient` to reset it on every upgrade — request counters, rate limiters, timer IDs (timers don't survive upgrades), ephemeral caches, derived lookup tables. Works on both `let` and `var`: ```motoko actor { let users = Map.empty(); // persists across upgrades var count : Nat = 0; // persists across upgrades transient var requestCount : Nat = 0; // resets to 0 on every upgrade transient var timerId : Nat = 0; // timer must be re-registered after upgrade transient let cache = Map.empty(); // rebuilt on every upgrade }; ``` Never write `stable` for fields — redundant in persistent actors; produces warning M0218. ## Modern Motoko Features ### Contextual Dot Notation When a function has a `self` parameter, ALWAYS use dot notation: ```motoko map.get(key); list.add(item); array.filter(func x = x > 0); caller.toText(); myNat.toText(); "hello".concat(" world"); let doubled = numbers.map(func x = x * 2).filter(func x = x > 10); ``` ### Lambda Argument Types Never annotate lambda argument types — the compiler infers them: ```motoko pairs.map(func(k, v) { k # ": " # v }); // ✓ pairs.map(func((k, v) : (Text, Text)) : Text { // ✗ redundant k # ": " # v }); ``` ### Implicit Parameters The compiler infers comparison functions automatically: ```motoko let map = Map.empty(); map.add(5, "hello"); // Nat.compare inferred let ages = Map.empty(); ages.add("Alice", 30); // Text.compare auto-derived // Custom types — define compare in a same-named module → auto-inferred module Point { public func compare(a : Point, b : Point) : Order.Order { ... }; }; let points = Map.empty(); points.add({ x = 1; y = 2 }, "A"); // Point.compare inferred ``` Never pass implicit arguments explicitly when the compiler derives them: ```motoko m.add(1, "hello"); // ✓ Map.add(m, Nat.compare, 1, "hello"); // ✗ ``` ### Equality and Comparison `==` uses compiler-generated structural equality. `equal`/`compare` from `mo:core` are primarily used as implicit arguments for `Map`, `Set`, `contains`, etc. Some modules use `self` (dot-callable): `Text`, `Principal`, `Bool`, `Char`, `Blob`. Others use `x, y` (not dot-callable): `Nat`, `Int`, `Float`, sized integers. ```motoko s1.equal(s2) // Text.equal has self Nat.compare(x, y) // Nat.compare does not ``` ### Mixins Composable actor services with granular state injection. Mixin parameters are immutable bindings — `var` is NOT valid in parameter syntax: ```motoko mixin (users : List.List) { public shared ({ caller }) func register(username : Text) : async Bool { users.add(UserLib.new(caller, username)); true; }; }; actor { let users = List.empty(); include AuthMixin(users); }; ``` To share mutable state, pass a mutable container (`List`, `Map`, etc.) — its contents are mutable even through an immutable binding. For scalar state (e.g. a counter), the mixin can create a local `var` from an initial value, but that `var` is mixin-local and not visible to the actor. For structured mutable state, pass a record with `var` fields. A module can define both its state type and its mixin: ```motoko // lib/Counter.mo module { public type State = { var count : Nat; var name : Text }; public func initState() : State { { var count = 0; var name = "" } }; }; // mixins/Counter.mo mixin (state : CounterLib.State) { public func increment() : async Nat { state.count += 1; state.count }; }; // main.mo let counterState = CounterLib.initState(); include CounterMixin(counterState); ``` ### Record Spread Use record spread to avoid copying fields one by one: ```motoko { self with newField = "" }; // ✓ { id = self.id; text = self.text; completed = self.completed; newField = "" }; // ✗ ``` **Caveat**: record spread cannot leave `var` fields un-overridden (M0179). When converting to a different type (e.g. internal → public), you must copy fields explicitly if the source has `var` fields that the target doesn't. ## Architecture Pattern ```text backend/ ├── types.mo # Central schema, state definitions ├── lib/ # Domain logic (stateless modules with self pattern) ├── mixins/ # Service layer (state injected via mixin parameters) ├── migrations/ # Enhanced migration files (--enhanced-migration projects) │ └── _.mo └── main.mo # Composition root (state owner, NO public methods) ``` Entity types go in `types.mo`. State fields are direct actor bindings — no wrapper: ```motoko // types.mo module { public type User = { id : Principal; var username : Text; var isActive : Bool }; }; // main.mo actor { let users = List.empty(); var nextPostId : Nat = 0; include AuthMixin(users); }; ``` ## Import Path Conventions Paths are **relative to the importing file**. No `.mo` extension, no `/lib.mo` suffix. ```motoko // From main.mo import Types "types"; import AuthMixin "mixins/Auth"; import UserLib "lib/User"; // From lib/*.mo or mixins/*.mo import Types "../types"; // Core library — always absolute import Map "mo:core/Map"; // WRONG — these all cause M0009 import Types "types.mo"; import Types "types/lib.mo"; import Types "backend/types"; ``` ## Shared Types Public functions accept/return only **shared types** (serializable): - Shared: `Nat`, `Int`, `Text`, `Bool`, `Principal`, `Blob`, `Float`, `[T]`, `?T`, records, variants - **Not shared**: functions, `var` fields, objects, `Map`, `Set`, `List`, `Queue`, `Stack` Convert internal mutable containers to shared types at the API boundary: ```motoko public type PostInternal = { id : Nat; likedBy : Set.Set }; public type Post = { id : Nat; likedBy : [Principal] }; public func toPublic(self : Types.PostInternal) : Types.Post { { self with likedBy = Set.toArray(self.likedBy) }; }; ``` ## Collections | Structure | Use Case | Key Operations | Complexity | | --------- | ---------------- | ------------------ | ----------- | | Map | Key-value pairs | get, add, remove | O(log n) | | List | Growable array | add, get, at | O(1) access | | Queue | FIFO processing | pushBack, popFront | O(1) | | Stack | LIFO processing | push, pop | O(1) | | Array | Fixed collection | index, map, filter | O(1) access | | Set | Unique values | contains, add | O(log n) | ```motoko import Map "mo:core/Map"; import List "mo:core/List"; import Set "mo:core/Set"; ``` **Import requirement**: Extension methods (dot notation) on a type only work when the corresponding `mo:core` module is imported. For example, `myArray.find(...)` requires `import Array "mo:core/Array"`; iterator chaining requires `import Iter "mo:core/Iter"`; `myBool.toText()` requires `import Bool "mo:core/Bool"`. The compiler hints at the missing import in the error message. **Warning**: Never call `list.add()` inside a `retain` callback. Use `mapInPlace` instead. Always use opaque type aliases (`List.List`, `Map.Map`, `Set.Set`) in type declarations. ### Iteration Build pipelines with `Iter` and materialize only at the end. Never create intermediate arrays: ```motoko self.values().map(toJson).toArray() // ✓ single allocation Array.map(List.toArray(self), toJson) // ✗ two allocations let doubled = numbers.map(func x = x * 2).filter(func x = x > 10); let sum = scores.filter(func s = s > 15).foldLeft(0, func(acc, s) = acc + s); ``` ### `contains` vs `find` - **`contains(element)`** — equality check. Does NOT take a predicate. - **`find(predicate)`** — predicate search. Returns `?T`. ```motoko numbers.contains(3); // Nat.equal auto-derived friends.contains(p); // Principal.equal auto-derived numbers.find(func(n) { n > 3 }); // returns ?Nat ``` ### Explicit Type Instantiation When `.map()` transforms to a **different** type, provide type parameters (M0098 without): ```motoko let photos = internalPhotos.map( func(p) { { id = p.id; url = p.url; uploadedBy = p.uploadedBy.toText() } } ); ``` Omit type parameters when they can be inferred — don't add them redundantly. ## Option Handling ```motoko // Trap on unexpected null let user = switch (users.find(func(u) { u.id == caller })) { case (?u) { u }; case (null) { Runtime.trap("User not found") }; }; // Return ?T when absence is normal public query func findUserByName(name : Text) : async ?User { users.find(func(u) { u.name == name }); }; ``` ## Module with Self Pattern ```motoko // lib/User.mo module { public type User = Types.User; public func new(id : Principal, name : Text) : User { { id; var name; var isActive = true }; }; public func ban(self : User) { self.isActive := false }; }; // Usage: user.ban(); ``` ## Security Every public update function MUST verify the caller via `{caller}` destructuring. Enforce authorization on the backend. ## Function Literals as Arguments Do NOT put a semicolon after a function body passed as an argument: ```motoko list.filter(func(item) { item.id != targetId }) // ✓ list.filter(func(item) { item.id != targetId };) // ✗ unexpected token ';' ``` ## Pitfalls 1. **Type/let declarations before the actor body** (M0141). Only `import` statements may appear before the actor. Prefer moving types to `types.mo` and importing them: ```motoko // ✗ M0141 — type before actor type UserId = Nat; actor { public query func whois(id : UserId) : async Text { ... }; }; // ✓ recommended — types.mo import Types "types"; actor { public query func whois(id : Types.UserId) : async Text { ... }; // qualify with module name }; ``` 2. **Always parenthesize variant tag arguments** — write `#tag(x)`, never `#tag x`. Without parens, `#tag 1 + 2` parses as `#tag(1) + 2`. 3. **`Text.join` parameter order** — iterator first, separator second: ```motoko Text.join(["a", "b", "c"].vals(), ", ") // "a, b, c" ``` 4. **`List.get` vs `List.at`**: `get(n)` returns `?T` (null if out of bounds). `at(n)` returns `T` and traps if out of bounds. Prefer `get` for safe access. ## Reserved Keywords Reserved by the Motoko grammar — **cannot** be used as identifiers; using one produces a parse error (e.g. `unexpected token 'label'`). Rename to a non-reserved word (`myLabel`, `myFunc`, `kind` instead of `type`, etc.). ``` actor and assert async await break case catch class composite continue debug debug_show do else false finally flexible for from_candid func if ignore implicit import in include label let loop mixin module not null object or persistent private public query return shared stable switch system throw to_candid transient true try type var weak while with ``` `async*`, `await*`, and `await?` are also reserved but contain non-identifier characters, so they can't collide with identifiers. ## Common Compile Error Patterns | Error pattern | Fix | | ------------------------------------------------------ | ------------------------------------------- | | `should be declared persistent` (M0220) | Add `--default-persistent-actors` or write `persistent actor` | | `move these declarations into the body` (M0141) | Move `type`/`let` inside the actor body | | `redundant stable keyword` (M0218) | Remove `stable`; plain `var` is auto-stable | | `field append does not exist` | `.concat()` | | `field put does not exist` (Map) | `.add()` | | `field delete is deprecated` (Map) | `.remove()` | | `Int cannot produce expected type Nat` | `Int.abs(intValue)` | | `syntax error, unexpected token '.'` | `#text (searchTerm.toLower())` | | `syntax error, unexpected token ','` | `for ((key, value) in map.entries())` | | `Compatibility error [M0170]` | Load `migrating-motoko` or `migrating-motoko-enhanced` skill | | `shared function has non-shared parameter/return type` | Return `[T]` not `List`, no `var` fields | | `send capability required` | Add `` capability | | `field compare does not exist` on Time | Use `Int.compare` | | `unexpected token ';'` in function call | Remove `;` before `)` | | `unbound variable X` | `import X "mo:core/X"` | | `M0098` no best choice for type param | `list.map(...)` | | `M0096` on `contains` callback | `find(pred) != null` | | `M0009` import file does not exist | Relative path, no `.mo` extension | | `M0072` field X does not exist | Import the `mo:core` module for that type | | `misplaced '!'` (M0064) | Wrap in `do ? { ... }` | | `pattern does not cover value` (M0145) | Add missing cases or `case _` | | `unexpected token 'X'` where `X` is a keyword | Rename — `X` is reserved (see Reserved Keywords) | ## Control Flow ```motoko // Switch — option unwrapping let value = switch (map.get(key)) { case (?v) { v }; case (null) { Runtime.trap("Key not found") }; }; // Switch — variant matching type Status = { #active; #inactive; #pending : Text }; switch (status) { case (#active) { "User is active" }; case (#inactive) { "User is inactive" }; case (#pending(reason)) { "Pending: " # reason }; }; // Switch — value matching switch (statusCode) { case (200) { "OK" }; case (404) { "Not Found" }; case _ { "Unknown" }; }; // For loops for ((key, value) in map.entries()) { Debug.print(key.toText() # ": " # value); }; for (item in list.values()) { total += item.score; }; ``` Prefer `.foldLeft()` or `.map()` over imperative loops when possible. Use `break` and `continue` in loops: ```motoko for (item in iter) { if (item.id == targetId) { result := ?item; break; }; }; for (item in list.values()) { if (not item.isActive) continue; process(item); }; ``` ## Quick Reference **Basic Types:** `Nat` `Int` `Text` `Bool` `Principal` `?T` `[T]` `[var T]` `Blob` `Float` — `Time.now()` returns `Int` (nanoseconds) **Common Operations:** `debug_show(value)` → Text | `assert condition` | `# "text"` concatenation | `break` / `continue` in loops ## Best Practices 1. Always `mo:core`, never `mo:base` 2. No `stable` keyword — enhanced orthogonal persistence handles state 3. Dot notation for all `self`-parameter functions 4. Never annotate lambda argument types — let the compiler infer 5. Never pass implicit arguments explicitly 6. Unwrap with `switch` + `Runtime.trap()` on null; `?T` only when absence is expected 7. types.mo / lib/ / mixins/ / main.mo structure 8. Mixins receive only needed state slices 9. Queries for read-only, updates for state changes 10. Iterator chaining to avoid intermediate collections 11. Record spread `{ self with ... }` instead of copying fields ## Additional References - **API docs**: [mops.one/core/docs](https://mops.one/core/docs) — authoritative mo:core function signatures and documentation - **Working examples**: [references/examples.md](references/examples.md) — full actors, multi-file architecture, timers --- ## Multi-Canister Architecture --- name: multi-canister description: "Design and deploy multi-canister dapps. Covers inter-canister calls, canister factory pattern, async messaging pitfalls, bounded vs unbounded wait, and 2MB payload limits. Use when splitting an app across canisters, making inter-canister or cross-canister calls, or designing canister-to-canister communication. Do NOT use for single-canister apps." license: Apache-2.0 compatibility: "icp-cli >= 0.2.2" metadata: title: Multi-Canister Architecture category: Core --- # Multi-Canister Architecture ## What This Is Splitting an IC application across multiple canisters for scaling, separation of concerns, or independent upgrade cycles. Each canister has its own state, cycle balance, and upgrade path. Canisters communicate via async inter-canister calls. ## Prerequisites - For Motoko: `mops` package manager, `core = "2.0.0"` in mops.toml - For Rust: `ic-cdk >= 0.19`, `candid`, `serde`, `ic-stable-structures` ## How It Works A caller canister makes a call to a callee canister: the method name, arguments (payload) and attached cycles are packed into a canister request message, which is delivered to the callee after the caller blocks on `await`; the callee executes the request and produces a response; this is packed into a canister response message and delivered to the caller; the caller awakes fron the `await` and continues execution (executes the canister response message). The system may produce a reject response message if e.g. the callee is not found or some resource limit was reached. Calls may be unbounded wait (caller MUST wait until the callee produces a response) or bounded wait (caller MAY get a `SYS_UNKNOWN` response instead of the actual response after the call timeout expires or if the subnet runs low on resources). Request delivery is best-effort: the system may decide to reject any request instead of delivering it. Unbounded wait response (including reject response) delivery is guaranteed: the caller will always learn the outcome of the call. Bounded wait response delivery is best-effort: the caller may receive a system-generated `SYS_UNKNOWN` reject response (unknown outcome) instead of the actual response if the call timed out or some system resource was exhausted, whether or not the request was delivered to the callee. ## When to Use Multi-Canister | Reason | Threshold | |---|---| | Storage limits | Each canister: up to hundreds of GB stable memory + 4GB heap. If your data could exceed heap limits or benefit from partitioning, split storage across canisters. | | Scalable compute | Canisters are single-threaded actors. Sharding load across multiple canisters, potentially across multiple subnets, can significantly improve throughput. | | Separation of concerns | Auth service, content service, payment service as independent units. | | Independent upgrades | Upgrade the payments canister without touching the user canister. | | Access control | Different controllers for different canisters (e.g., DAO controls one, team controls another). | **When NOT to use:** Simple apps with <1GB data. Single-canister is simpler, faster, and avoids inter-canister call overhead. Do not over-architect. ## Issues that May Cause Functional Bugs For building a multi-canister application, take the perspective of an experienced senior software engineer and carefully read the following issues that may cause subtle functional bugs. Meticulously avoid bugs that could be caused by these issues. 1. **Request and response payloads are limited to 2 MB.** Because any canister call may be required to cross subnet boundaries; and cross-subnet (or XNet) messages (the request and response corresponding to each canister call) are inducted in (packaged into) 4 MB blocks; canister request and response payloads are limited to 2 MB. A call with a request payload above 2 MB will fail synchronously; and a response with a payload above 2 MB will trap. Chunk larger payloads into 1 MB chunks (to allow for any encoding overhead) and deliver them over multiple calls (e.g. chunked uploads or byte range queries). 2. **Update methods that make calls are NOT executed atomically.** When an update method makes a call, the code before the `await` is one atomic message execution (i.e. the ingress message or canister request that invoked the update method); and the code after the `await` is a separate atomic message execution (the response to the call). In particular, if the update method traps after the `await`, any mutations before the `await` have already been persisted; and any mutations after the `await` will be rolled back. Design for eventual consistency or use a saga pattern. If more context on this is needed, you can optionally refer to [properties of message executions on ICP](https://docs.internetcomputer.org/references/message-execution-properties). 3. **Use idempotent APIs. Or provide a separate endpoint to query the outcome of a non-idempotent call.** If a call to a non-idempotent API times out, there must be another way for the caller to learn the outcome of the call (e.g. by attaching a unique ID to the original call and querying for the outcome of the call with that unique ID). Without a way to learn the outcome, when the caller receives a `SYS_UNKNOWN` response it may be unable to decide whether to continue, retry the call or abort. 4. **Calls across subnet boundaries are slower than calls on the same subnet.** Under light subnet load, a call to a canister on the same subnet may complete and its response may be processed by the caller within a single round. The call latency only depends on how frequently the caller and callee are scheduled (which may be multiple times per round). A cross canister call requires 2-3 rounds either way (request delivery and response delivery), plus scheduler latency. 5. **Calls across subnet boundaries have relatively low bandwidth.** Cross-subnet (or XNet) messages are inducted in (packaged into) 4 MB blocks once per round, along with any ingress messages and other XNet messages. Expect multiple MBs of messages to take multiple rounds to deliver, on top of the XNet latency. (Subnet-local messages are routed within the subnet, so they don't suffer from this bandwidth limitation). 6. **Defensive practice: bind `msg_caller()` before `.await` in Rust.** The current ic-cdk executor preserves caller across `.await` points via protected tasks, but capturing it early guards against future executor changes. **Motoko is safe:** `public shared ({ caller }) func` captures `caller` as an immutable binding at function entry. ```rust // Recommended (Rust) — capture caller before await: #[update] async fn do_thing() { let original_caller = ic_cdk::api::msg_caller(); // Defensive: capture before await let _ = some_canister_call().await; let who = original_caller; // Safe } ``` 7. **Not handling rejected calls.** Inter-canister calls can fail (callee trapped, out of cycles, canister stopped). In Motoko use `try/catch`. In Rust, handle the `Result` from `ic_cdk::call`. Unhandled rejections trap your canister. 8. **Canister factory without enough cycles.** Creating a canister requires cycles. The management canister charges for creation and the initial cycle balance. If you do not attach enough cycles, creation fails. 9. **Not setting up `#[init]` and `#[post_upgrade]` in Rust.** Without a `post_upgrade` handler, canister upgrades may behave unexpectedly. Always define both. ## Issues that May Cause Security Bugs Take the perspective of an experienced senior security engineer and carefully read the following issues that may cause risky security issues. Meticulously avoid such security bugs. 1. **Avoid reentrancy issues.** The fact that calls are not atomic can cause reentrancy bugs such as double-spending vulnerabilities, see also "Update methods that make calls are NOT executed atomically" above. Avoid such issues, e.g. by employing locking patterns. If more context is needed, you can optionally refer to the [security best practices](https://docs.internetcomputer.org/building-apps/security/inter-canister-calls#be-aware-that-there-is-no-reliable-message-ordering) or the [paper](https://arxiv.org/pdf/2506.05932). 2. **Securely handle traps in callbacks.** A trap (a panic in Rust) in a callback causes the callback to not apply any state changes. For example, if a trap can be caused by a malicious entity, it could mean that security critical actions like debiting an account in a DeFi context can be skipped, leading to critical issues like double-spending. To avoid this, avoid traps in callbacks that could cause such bugs, consider using `call_on_cleanup`, and use "journaling". If more context is needed, optionally consider this [security best practice](https://docs.internetcomputer.org/building-apps/security/inter-canister-calls#securely-handle-traps-in-callbacks). 3. **Unbounded wait calls may prevent canister upgrades, indefinitely.** Unbounded wait calls may take arbitrarily long to complete: a malicious or incorrect callee may spin indefinitely without producing a response. Canisters cannot be stopped while awaiting responses to outstanding calls. Bounded wait calls avoid this issue by making sure that calls complete in a bounded time, independent of whether the callee responded or not. If more context is needed, optionally consider [this security best practice](https://docs.internetcomputer.org/building-apps/security/inter-canister-calls#be-aware-of-the-risks-involved-in-calling-untrustworthy-canisters). 4. **`canister_inspect_message` is not called for inter-canister calls.** It only runs for ingress messages (from external users). Do not rely on it for access control between canisters. Use explicit principal checks instead. If more context is needed, optionally consider [this security best practice](https://docs.internetcomputer.org/building-apps/security/iam#do-not-rely-on-ingress-message-inspection). ## Mistakes That Break Your Build 1. **Deploying canisters in the wrong order.** Canisters with dependencies must be deployed according to their dependencies. Declare `dependencies` in icp.yaml so `icp deploy` orders them correctly. 2. **Forgetting to generate type declarations for each backend canister.** Use language-specific tooling (e.g., `didc` for Candid bindings) to generate declarations for each backend canister individually. 3. **Shared types diverging between canisters.** If canister A expects `{ id: Nat; name: Text }` and canister B sends `{ id: Nat; title: Text }`, the call silently fails or traps. Use a shared types module imported by both canisters. ## Implementation ### Project Structure ``` my-project/ icp.yaml mops.toml src/ shared/ Types.mo # Shared type definitions user_service/ main.mo # User canister content_service/ main.mo # Content canister frontend/ ... # Frontend assets ``` ### icp.yaml ```yaml canisters: - name: user_service recipe: type: "@dfinity/motoko@v4.1.0" configuration: main: src/user_service/main.mo - name: content_service recipe: type: "@dfinity/motoko@v4.1.0" configuration: main: src/content_service/main.mo ``` ### Motoko #### src/shared/Types.mo — Shared Types ```motoko module { public type UserId = Principal; public type PostId = Nat; public type UserProfile = { id : UserId; username : Text; created : Int; }; public type Post = { id : PostId; author : UserId; title : Text; body : Text; created : Int; }; public type ServiceError = { #NotFound; #Unauthorized; #AlreadyExists; #InternalError : Text; }; }; ``` #### src/user_service/main.mo — User Canister ```motoko import Map "mo:core/Map"; import Principal "mo:core/Principal"; import Array "mo:core/Array"; import Time "mo:core/Time"; import Result "mo:core/Result"; import Runtime "mo:core/Runtime"; import Types "../shared/Types"; persistent actor { type UserProfile = Types.UserProfile; let users = Map.empty(); // Register a new user public shared ({ caller }) func register(username : Text) : async Result.Result { if (Principal.isAnonymous(caller)) { return #err(#Unauthorized); }; switch (Map.get(users, Principal.compare, caller)) { case (?_existing) { #err(#AlreadyExists) }; case null { let profile : UserProfile = { id = caller; username; created = Time.now(); }; Map.add(users, Principal.compare, caller, profile); #ok(profile) }; } }; // Check if a user exists (called by other canisters) public shared query func isValidUser(userId : Principal) : async Bool { switch (Map.get(users, Principal.compare, userId)) { case (?_) { true }; case null { false }; } }; // Get user profile public shared query func getUser(userId : Principal) : async ?UserProfile { Map.get(users, Principal.compare, userId) }; // Get all users public query func getUsers() : async [UserProfile] { Array.fromIter(Map.values(users)) }; }; ``` #### src/content_service/main.mo — Content Canister (calls User Service) ```motoko import Map "mo:core/Map"; import Nat "mo:core/Nat"; import Array "mo:core/Array"; import Time "mo:core/Time"; import Result "mo:core/Result"; import Runtime "mo:core/Runtime"; import Error "mo:core/Error"; import Principal "mo:core/Principal"; import Types "../shared/Types"; // Import the other canister — name must match icp.yaml canister key import UserService "canister:user_service"; persistent actor { type Post = Types.Post; let posts = Map.empty(); var postCounter : Nat = 0; // Create a post — validates user via inter-canister call public shared ({ caller }) func createPost(title : Text, body : Text) : async Result.Result { let originalCaller = caller; if (Principal.isAnonymous(originalCaller)) { return #err(#Unauthorized); }; // Inter-canister call to user_service let isValid = try { await UserService.isValidUser(originalCaller) } catch (e : Error.Error) { Runtime.trap("User service unavailable: " # Error.message(e)); }; if (not isValid) { return #err(#Unauthorized); }; let id = postCounter; let post : Post = { id; author = originalCaller; title; body; created = Time.now(); }; Map.add(posts, Nat.compare, id, post); postCounter += 1; #ok(post) }; // Get all posts public query func getPosts() : async [Post] { Array.fromIter(Map.values(posts)) }; // Get posts by author — with enriched user data public func getPostsWithAuthor(authorId : Principal) : async { user : ?Types.UserProfile; posts : [Post]; } { let userProfile = try { await UserService.getUser(authorId) } catch (_e : Error.Error) { null }; let authorPosts = Array.filter( Array.fromIter(Map.values(posts)), func(p : Post) : Bool { p.author == authorId } ); { user = userProfile; posts = authorPosts } }; // Delete a post — only the author can delete public shared ({ caller }) func deletePost(id : Nat) : async Result.Result<(), Types.ServiceError> { let originalCaller = caller; switch (Map.get(posts, Nat.compare, id)) { case (?post) { if (post.author != originalCaller) { return #err(#Unauthorized); }; ignore Map.delete(posts, Nat.compare, id); #ok(()) }; case null { #err(#NotFound) }; } }; }; ``` #### Production Readiness: Content Service The content service examples above are intentionally kept simple to demonstrate multi-canister communication patterns. They lack several things that would be needed for production use: - **Input validation.** The `username` parameter in `register` accepts any string — including empty strings or strings up to the 2MB message size limit. Validate length (e.g., 1–64 characters), enforce allowed character sets, and add a uniqueness constraint via a reverse lookup map to prevent impersonation. - **User enumeration and pagination on `getUsers`.** Using `getUsers`, it's possible for everyone to enumerate all users on the platform, which may not be desirable. Furthermore, the `getUsers` endpoint returns all user profiles in a single response. As the user base grows, this will hit the 2MB response size limit and trap, bricking the endpoint. Add pagination (offset/limit parameters). The same applies to `getPosts`. ### Rust #### Project Structure (Rust) ``` my-project/ icp.yaml Cargo.toml # workspace src/ user_service/ Cargo.toml src/lib.rs content_service/ Cargo.toml src/lib.rs ``` #### Cargo.toml (workspace root) ```toml [workspace] members = [ "src/user_service", "src/content_service", ] ``` #### icp.yaml (Rust) ```yaml canisters: - name: user_service recipe: type: "@dfinity/rust@v3.2.0" configuration: package: user_service candid: src/user_service/user_service.did - name: content_service recipe: type: "@dfinity/rust@v3.2.0" configuration: package: content_service candid: src/content_service/content_service.did ``` #### src/user_service/Cargo.toml ```toml [package] name = "user_service" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] ic-cdk = "0.19" candid = "0.10" serde = { version = "1", features = ["derive"] } ic-stable-structures = "0.7" ``` #### src/user_service/src/lib.rs ```rust use candid::{CandidType, Deserialize, Principal}; use ic_cdk::{init, post_upgrade, query, update}; use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap}; use std::cell::RefCell; type Memory = VirtualMemory; #[derive(CandidType, Deserialize, Clone, Debug)] struct UserProfile { id: Principal, username: String, created: i64, } // Stable storage thread_local! { static MEMORY_MANAGER: RefCell> = RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); static USERS: RefCell, Vec, Memory>> = RefCell::new( StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))) ) ); } fn principal_to_key(p: &Principal) -> Vec { p.as_slice().to_vec() } fn serialize_profile(profile: &UserProfile) -> Vec { candid::encode_one(profile).unwrap() } fn deserialize_profile(bytes: &[u8]) -> UserProfile { candid::decode_one(bytes).unwrap() } #[init] fn init() {} #[post_upgrade] fn post_upgrade() {} #[update] fn register(username: String) -> Result { let caller = ic_cdk::api::msg_caller(); if caller == Principal::anonymous() { return Err("Unauthorized".to_string()); } let key = principal_to_key(&caller); USERS.with(|users| { if users.borrow().contains_key(&key) { return Err("Already exists".to_string()); } let profile = UserProfile { id: caller, username, created: ic_cdk::api::time() as i64, }; let bytes = serialize_profile(&profile); users.borrow_mut().insert(key, bytes); Ok(profile) }) } #[query] fn is_valid_user(user_id: Principal) -> bool { let key = principal_to_key(&user_id); USERS.with(|users| users.borrow().contains_key(&key)) } #[query] fn get_user(user_id: Principal) -> Option { let key = principal_to_key(&user_id); USERS.with(|users| { users.borrow().get(&key).map(|bytes| deserialize_profile(&bytes)) }) } ic_cdk::export_candid!(); ``` #### src/content_service/Cargo.toml ```toml [package] name = "content_service" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] ic-cdk = "0.19" candid = "0.10" serde = { version = "1", features = ["derive"] } ic-stable-structures = "0.7" ``` #### src/content_service/src/lib.rs ```rust use candid::{CandidType, Deserialize, Principal}; use ic_cdk::call::Call; use ic_cdk::{init, post_upgrade, query, update}; use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap, StableCell}; use std::cell::RefCell; type Memory = VirtualMemory; #[derive(CandidType, Deserialize, Clone, Debug)] struct Post { id: u64, author: Principal, title: String, body: String, created: i64, } #[derive(CandidType, Deserialize, Clone, Debug)] struct UserProfile { id: Principal, username: String, created: i64, } // Stable storage -- survives canister upgrades thread_local! { static MEMORY_MANAGER: RefCell> = RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); // Posts keyed by id (u64 as big-endian bytes) -> candid-encoded Post static POSTS: RefCell, Vec, Memory>> = RefCell::new( StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))) ) ); // Post counter in stable memory static POST_COUNTER: RefCell> = RefCell::new( StableCell::init( MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))), 0u64, ) ); // Store the user_service canister ID (set during init, re-set on upgrade) static USER_SERVICE_ID: RefCell> = RefCell::new(None); } fn post_id_to_key(id: u64) -> Vec { id.to_be_bytes().to_vec() } fn serialize_post(post: &Post) -> Vec { candid::encode_one(post).unwrap() } fn deserialize_post(bytes: &[u8]) -> Post { candid::decode_one(bytes).unwrap() } #[init] fn init(user_service_id: Principal) { USER_SERVICE_ID.with(|id| *id.borrow_mut() = Some(user_service_id)); } #[post_upgrade] fn post_upgrade(user_service_id: Principal) { // Re-set the user_service ID (not stored in stable memory for simplicity, // since it is always passed as an init/upgrade argument) init(user_service_id); } fn get_user_service_id() -> Principal { USER_SERVICE_ID.with(|id| { id.borrow().expect("user_service canister ID not set") }) } // Defensive: capture caller before any await #[update] async fn create_post(title: String, body: String) -> Result { // Capture caller before the await as defensive practice let original_caller = ic_cdk::api::msg_caller(); if original_caller == Principal::anonymous() { return Err("Unauthorized".to_string()); } // Inter-canister call to user_service let user_service = get_user_service_id(); let (is_valid,): (bool,) = Call::unbounded_wait(user_service, "is_valid_user") .with_arg(original_caller) .await .map_err(|e| format!("User service call failed: {:?}", e))? .candid_tuple() .map_err(|e| format!("Failed to decode response: {:?}", e))?; if !is_valid { return Err("User not registered".to_string()); } let id = POST_COUNTER.with(|counter| { let mut counter = counter.borrow_mut(); let id = *counter.get(); counter.set(id + 1); id }); let post = Post { id, author: original_caller, // Use captured caller title, body, created: ic_cdk::api::time() as i64, }; POSTS.with(|posts| { posts.borrow_mut().insert(post_id_to_key(id), serialize_post(&post)); }); Ok(post) } #[query] fn get_posts() -> Vec { POSTS.with(|posts| { posts.borrow().iter() .map(|entry| deserialize_post(&entry.value())) .collect() }) } // Cross-canister enrichment: get posts with author profile #[update] async fn get_posts_with_author(author_id: Principal) -> (Option, Vec) { let user_service = get_user_service_id(); // Call user_service for profile data let user_profile: Option = match Call::unbounded_wait(user_service, "get_user") .with_arg(author_id) .await { Ok(response) => response.candid_tuple::<(Option,)>() .map(|(profile,)| profile) .unwrap_or(None), Err(_) => None, // Handle gracefully if user service is down }; let author_posts = POSTS.with(|posts| { posts.borrow().iter() .map(|entry| deserialize_post(&entry.value())) .filter(|p| p.author == author_id) .collect() }); (user_profile, author_posts) } #[update] async fn delete_post(id: u64) -> Result<(), String> { let original_caller = ic_cdk::api::msg_caller(); POSTS.with(|posts| { let mut posts = posts.borrow_mut(); let key = post_id_to_key(id); match posts.get(&key) { Some(bytes) => { let post = deserialize_post(&bytes); if post.author != original_caller { return Err("Unauthorized".to_string()); } posts.remove(&key); Ok(()) } None => Err("Not found".to_string()), } }) } ic_cdk::export_candid!(); ``` ### Canister Factory Pattern A canister that creates other canisters dynamically. Useful for per-user canisters, sharding, or dynamic scaling. #### Motoko Factory ```motoko import Principal "mo:core/Principal"; import Map "mo:core/Map"; import Array "mo:core/Array"; import Runtime "mo:core/Runtime"; persistent actor Self { type CanisterSettings = { controllers : ?[Principal]; compute_allocation : ?Nat; memory_allocation : ?Nat; freezing_threshold : ?Nat; }; type CreateCanisterResult = { canister_id : Principal; }; // IC Management canister transient let ic : actor { create_canister : shared ({ settings : ?CanisterSettings }) -> async CreateCanisterResult; install_code : shared ({ mode : { #install; #reinstall; #upgrade }; canister_id : Principal; wasm_module : Blob; arg : Blob; }) -> async (); deposit_cycles : shared ({ canister_id : Principal }) -> async (); } = actor "aaaaa-aa"; // Track created canisters let childCanisters = Map.empty(); // owner -> canister // Create a new canister for a user (one per caller) public shared ({ caller }) func createChildCanister(wasmModule : Blob) : async Principal { if (Principal.isAnonymous(caller)) { Runtime.trap("Auth required") }; if (Map.get(childCanisters, Principal.compare, caller) != null) { Runtime.trap("Child canister already exists for this caller"); }; // Create canister with cycles let createResult = await (with cycles = 1_000_000_000_000) ic.create_canister({ settings = ?{ controllers = ?[Principal.fromActor(Self), caller]; compute_allocation = null; memory_allocation = null; freezing_threshold = null; }; }); let canisterId = createResult.canister_id; // Install code await ic.install_code({ mode = #install; canister_id = canisterId; wasm_module = wasmModule; arg = to_candid (caller); // Pass owner as init arg }); Map.add(childCanisters, Principal.compare, caller, canisterId); canisterId }; // Get a user's canister public query func getChildCanister(owner : Principal) : async ?Principal { Map.get(childCanisters, Principal.compare, owner) }; }; ``` #### Rust Factory ```rust use candid::{CandidType, Deserialize, Nat, Principal, encode_one}; use ic_cdk::management_canister::{ create_canister_with_extra_cycles, install_code, CreateCanisterArgs, InstallCodeArgs, CanisterInstallMode, CanisterSettings, }; use ic_cdk::update; use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap}; use std::cell::RefCell; type Memory = VirtualMemory; thread_local! { static MEMORY_MANAGER: RefCell> = RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); // Stable storage: owner principal -> child canister principal (survives upgrades) static CHILD_CANISTERS: RefCell, Vec, Memory>> = RefCell::new( StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))) ) ); } #[update] async fn create_child_canister(wasm_module: Vec) -> Principal { let caller = ic_cdk::api::msg_caller(); assert_ne!(caller, Principal::anonymous(), "Auth required"); // One child canister per caller let already_exists = CHILD_CANISTERS.with(|c| c.borrow().contains_key(&caller.as_slice().to_vec())); if already_exists { ic_cdk::trap("Child canister already exists for this caller"); } // Create canister let create_args = CreateCanisterArgs { settings: Some(CanisterSettings { controllers: Some(vec![ic_cdk::api::canister_self(), caller]), compute_allocation: None, memory_allocation: None, freezing_threshold: None, reserved_cycles_limit: None, log_visibility: None, wasm_memory_limit: None, wasm_memory_threshold: None, environment_variables: None, }), }; // Attach 1T cycles for the new canister let create_result = create_canister_with_extra_cycles(&create_args, 1_000_000_000_000u128) .await .expect("Failed to create canister"); let canister_id = create_result.canister_id; // Install code let install_args = InstallCodeArgs { mode: CanisterInstallMode::Install, canister_id, wasm_module, arg: encode_one(&caller).unwrap(), // Pass owner as init arg }; install_code(&install_args) .await .expect("Failed to install code"); // Track the child canister CHILD_CANISTERS.with(|canisters| { canisters.borrow_mut().insert( caller.as_slice().to_vec(), canister_id.as_slice().to_vec(), ); }); canister_id } #[ic_cdk::query] fn get_child_canister(owner: Principal) -> Option { CHILD_CANISTERS.with(|canisters| { canisters.borrow().get(&owner.as_slice().to_vec()) .map(|bytes| Principal::from_slice(&bytes)) }) } ``` #### Production Readiness: Canister Factory The factory examples above are intentionally kept simple to demonstrate the canister creation pattern. They lack several things that would be needed for production use: - **Cycle-drain protection.** Any non-anonymous principal can call `createChildCanister` repeatedly, each call consuming 1T cycles from the factory. Add an allowlist of authorized callers, enforce a per-user creation limit, and check the factory's cycle balance before creating a canister (e.g., `ExperimentalCycles.balance()` in Motoko, `ic_cdk::api::canister_balance128()` in Rust). - **WASM module validation.** The WASM module is caller-supplied, meaning any authenticated user can deploy arbitrary code. Do not accept WASM from arbitrary callers in production. Instead, hardcode a known WASM module (or its hash) in the factory canister, or verify the module hash against an allowlist before installing. Whitelist principals that are allowed to deploy through the factory to avoid unauthorized use. - **Reentrancy protection.** The factory performs two sequential awaits (`create_canister`, then `install_code`) with no locking. Concurrent calls from the same caller can create orphaned canisters that the factory loses track of. Add a lock (e.g., a `Set` of principals with in-flight calls) that prevents concurrent creation for the same caller. - **Partial failure handling.** If `create_canister` succeeds but `install_code` fails, the canister exists and has cycles but is untracked by the factory. Track the canister ID immediately after creation (before attempting `install_code`) so the factory can retry installation or clean up on failure. ## Upgrade Strategy for Multi-Canister Systems ### Ordering 1. Deploy shared dependencies first (e.g., `user_service` before `content_service`). 2. Never change Candid interfaces in a breaking way. Add new fields as `opt` types. 3. Test upgrades locally before mainnet. ### Safe Upgrade Checklist - Never remove or rename fields in existing types shared across canisters. - Add new fields as optional (`?Type` in Motoko, `Option` in Rust). - If a canister's Candid interface changes, upgrade consumers after the provider. - Always have both `#[init]` and `#[post_upgrade]` in Rust canisters. - In Motoko, `persistent actor` handles stable storage automatically. ### Upgrade Commands ```bash # Upgrade canisters in dependency order icp deploy user_service # Rust content_service requires the user_service principal on every upgrade (post_upgrade arg) USER_SERVICE_ID=$(icp canister id user_service) icp deploy content_service --argument "(principal \"$USER_SERVICE_ID\")" npm run build icp deploy frontend ``` ## Deploy & Test ### Local Development ```bash # Start the local replica icp network start -d # Deploy in dependency order icp deploy user_service # content_service (Rust) requires the user_service canister ID as an init argument USER_SERVICE_ID=$(icp canister id user_service) icp deploy content_service --argument "(principal \"$USER_SERVICE_ID\")" # Build and deploy frontend npm run build icp deploy frontend ``` ### Test Inter-Canister Calls (Motoko) ```bash # Register a user PRINCIPAL=$(icp identity principal) icp canister call user_service register "(\"alice\")" # Verify user exists icp canister call user_service isValidUser "(principal \"$PRINCIPAL\")" # Expected: (true) # Create a post (triggers inter-canister call to user_service) icp canister call content_service createPost "(\"Hello World\", \"My first post\")" # Expected: (variant { ok = record { id = 0; author = principal "..."; ... } }) # Get all posts icp canister call content_service getPosts # Expected: (vec { record { id = 0; ... } }) ``` ### Test Inter-Canister Calls (Rust) Rust canisters use snake_case function names: ```bash PRINCIPAL=$(icp identity principal) icp canister call user_service register "(\"alice\")" icp canister call user_service is_valid_user "(principal \"$PRINCIPAL\")" # Expected: (true) # content_service must have been deployed with --argument "(principal \"\")" icp canister call content_service create_post "(\"Hello World\", \"My first post\")" # Expected: (variant { ok = record { id = 0 : nat64; author = principal "..."; ... } }) icp canister call content_service get_posts # Expected: (vec { record { id = 0 : nat64; ... } }) ``` ## Verify It Works ### Verify User Registration ```bash icp canister call user_service register '("testuser")' # Expected: (variant { ok = record { id = principal "..."; username = "testuser"; created = ... } }) ``` ### Verify Inter-Canister Call ```bash # This call should succeed (user is registered) # Motoko: createPost / Rust: create_post icp canister call content_service createPost '("Test Title", "Test Body")' # Expected: (variant { ok = record { ... } }) # Create a new identity that is NOT registered icp identity new unregistered --storage plaintext icp identity use unregistered icp canister call content_service createPost '("Should Fail", "No user")' # Expected: (variant { err = "User not registered" }) # Switch back icp identity use default ``` ### Verify Cross-Canister Query ```bash PRINCIPAL=$(icp identity principal) # Motoko: getPostsWithAuthor / Rust: get_posts_with_author icp canister call content_service getPostsWithAuthor "(principal \"$PRINCIPAL\")" # Expected: (opt record { id = ...; username = "testuser"; ... }, vec { record { ... } }) ``` ### Verify Canister Factory ```bash # Read the wasm file for the child canister # (In practice you'd upload or reference a wasm blob) icp canister call factory createChildCanister '(blob "...")' # Expected: (principal "NEW-CANISTER-ID") icp canister call factory getChildCanister "(principal \"$PRINCIPAL\")" # Expected: (opt principal "NEW-CANISTER-ID") ``` --- ## SNS DAO Launch --- name: sns-launch description: "Configure and launch an SNS DAO to decentralize a dapp. Covers token economics, governance parameters, testflight validation, NNS proposal submission, and decentralization swap. Use when launching an SNS, configuring tokenomics, or setting up DAO governance for a dapp. Do NOT use for NNS governance or general canister management." license: Apache-2.0 compatibility: "icp-cli >= 0.2.2, dfx with sns extension" metadata: title: SNS DAO Launch category: Governance --- # SNS DAO Launch ## What This Is Service Nervous System (SNS) is the DAO framework for decentralizing individual Internet Computer dapps. Like the NNS governs the IC network itself, an SNS governs a specific dapp -- token holders vote on proposals to upgrade code, manage treasury funds, and set parameters. Launching an SNS transfers canister control from developers to a community-owned governance system through a decentralization swap. ## Prerequisites - An NNS neuron with sufficient stake to submit proposals (mainnet) - Dapp canisters already deployed and working on mainnet - `sns_init.yaml` configuration file with all parameters defined ## Canister IDs | Canister | Mainnet ID | Purpose | |----------|-----------|---------| | NNS Governance | `rrkah-fqaaa-aaaaa-aaaaq-cai` | Votes on SNS creation proposals | | SNS-W (Wasm Modules) | `qaa6y-5yaaa-aaaaa-aaafa-cai` | Deploys and initializes SNS canisters | | NNS Root | `r7inp-6aaaa-aaaaa-aaabq-cai` | Must be co-controller of dapp before launch | | ICP Ledger | `ryjl3-tyaaa-aaaaa-aaaba-cai` | Handles ICP token transfers during swap | ## SNS Canisters Deployed When an SNS launch succeeds, SNS-W deploys these canisters on an SNS subnet: | Canister | Purpose | |----------|---------| | **Governance** | Proposal submission, voting, neuron management | | **Ledger** | SNS token transfers (ICRC-1 standard) | | **Root** | Sole controller of all dapp canisters post-launch | | **Swap** | Runs the decentralization swap (ICP for SNS tokens) | | **Index** | Transaction indexing for the SNS ledger | | **Archive** | Historical transaction storage | ## Mistakes That Break Your Build 1. **Setting `min_participants` too high.** If you require 500 participants but only 200 show up, the entire swap fails and all ICP is refunded. Start conservative -- most successful SNS launches use 100-200 minimum participants. 2. **Forgetting to add NNS Root as co-controller before proposing.** The launch process requires NNS Root to take over your canisters. If you submit the proposal without adding it first, the launch will fail at stage 6 when SNS Root tries to become sole controller. 3. **Not testing on SNS testflight first.** Going straight to mainnet means discovering configuration issues after your NNS proposal is live. Always deploy a testflight mock SNS on mainnet first to verify governance and upgrade flows. 4. **Token economics that fail NNS review.** The NNS community votes on your proposal. Unreasonable tokenomics (excessive developer allocation, zero vesting, absurd swap caps) will get rejected. Study successful SNS launches (OpenChat, Hot or Not, Kinic) for parameter ranges the community accepts. 5. **Not defining fallback controllers.** If the swap fails, the dapp needs controllers to return control to. Without `fallback_controller_principals`, your dapp could become uncontrollable. 6. **Setting swap duration too short.** Users across time zones need time to participate. Less than 24 hours is risky -- 3-7 days is standard. 7. **Forgetting restricted proposal types during swap.** Six governance proposal types are blocked while the swap runs: `ManageNervousSystemParameters`, `TransferSnsTreasuryFunds`, `MintSnsTokens`, `UpgradeSnsControlledCanister`, `RegisterDappCanisters`, `DeregisterDappCanisters`. Do not plan operations that require these during the swap window. 8. **Developer neurons with zero dissolve delay.** Developers can immediately dump tokens post-launch. Set dissolve delays and vesting periods (12-48 months is typical) to signal long-term commitment. ## Implementation ### SNS Configuration File (sns_init.yaml) This is the single source of truth for all launch parameters. Copy the template from the `dfinity/sns-testing` repo and customize: ```yaml # Note: numeric values are in e8s (1 token = 100_000_000 e8s). Time values are in seconds. # === PROJECT METADATA === name: MyProject description: > A decentralized application for [purpose]. This proposal requests the NNS to create an SNS for MyProject. logo: logo.png url: https://myproject.com # === NNS PROPOSAL TEXT === NnsProposal: title: "Proposal to create an SNS for MyProject" url: "https://forum.dfinity.org/t/myproject-sns-proposal/XXXXX" summary: > This proposal creates an SNS DAO to govern MyProject. Token holders will control upgrades, treasury, and parameters. # === FALLBACK (if swap fails, these principals regain control) === fallback_controller_principals: - YOUR_PRINCIPAL_ID_HERE # === CANISTER IDS TO DECENTRALIZE === dapp_canisters: - BACKEND_CANISTER_ID - FRONTEND_CANISTER_ID # === TOKEN CONFIGURATION === Token: name: MyToken symbol: MYT transaction_fee: 0.0001 tokens logo: token_logo.png # === GOVERNANCE PARAMETERS === Proposals: rejection_fee: 1 token initial_voting_period: 4 days maximum_wait_for_quiet_deadline_extension: 1 day Neurons: minimum_creation_stake: 1 token Voting: minimum_dissolve_delay: 1 month MaximumVotingPowerBonuses: DissolveDelay: duration: 8 years bonus: 100% # 2x voting power at max dissolve Age: duration: 4 years bonus: 25% RewardRate: initial: 2.5% final: 2.5% transition_duration: 0 seconds # === TOKEN DISTRIBUTION === Distribution: Neurons: # Developer allocation (with vesting) - principal: DEVELOPER_PRINCIPAL stake: 2_000_000 tokens memo: 0 dissolve_delay: 6 months vesting_period: 24 months # Seed investors - principal: INVESTOR_PRINCIPAL stake: 500_000 tokens memo: 1 dissolve_delay: 3 months vesting_period: 12 months InitialBalances: treasury: 5_000_000 tokens # Treasury (controlled by DAO) swap: 2_500_000 tokens # Sold during decentralization swap total: 10_000_000 tokens # Must equal sum of all allocations # === DECENTRALIZATION SWAP === Swap: minimum_participants: 100 minimum_direct_participation_icp: 50_000 tokens maximum_direct_participation_icp: 500_000 tokens minimum_participant_icp: 1 token maximum_participant_icp: 25_000 tokens duration: 7 days neurons_fund_participation: true VestingSchedule: events: 5 # Neurons unlock in 5 stages interval: 3 months confirmation_text: > I confirm that I am not a resident of a restricted jurisdiction and I understand the risks of participating in this token swap. restricted_countries: - US - CN ``` ### Launch Process (11 Stages) ``` Stage 1: Developer defines parameters in sns_init.yaml Stage 2: Developer adds NNS Root as co-controller of dapp canisters Stage 3: Developer submits NNS proposal using `dfx sns propose` Stage 4: NNS community votes on the proposal Stage 5: (If adopted) SNS-W deploys uninitialized SNS canisters Stage 6: SNS Root becomes sole controller of dapp canisters Stage 7: SNS-W initializes canisters in pre-decentralization-swap mode Stage 8: 24-hour minimum wait before swap opens Stage 9: Decentralization swap opens (users send ICP, receive SNS neurons) Stage 10: Swap closes (time expires or maximum ICP reached) Stage 11: Finalization (exchange rate set, neurons created, normal mode) ``` ### Motoko Prepare your canister for SNS control. The key requirement is that your canister accepts upgrade proposals from SNS governance: ```motoko import Principal "mo:core/Principal"; import Runtime "mo:core/Runtime"; persistent actor { // SNS Root will be set as sole controller after launch. // Your canister code does not need to change -- SNS governance // controls upgrades via the standard canister management API. // If your canister has admin functions, transition them to // accept SNS governance proposals instead of direct principal checks: var snsGovernanceId : ?Principal = null; // ⚠ SECURITY: This setter MUST be access-controlled. Without a check, any caller // can front-run you and set themselves as governance, permanently locking you out. // Replace DEPLOYER_PRINCIPAL with your actual principal or use an admin list. public shared ({ caller }) func setSnsGovernance(id : Principal) : async () { // Only the deployer (or canister controllers) should call this. assert (Principal.isController(caller)); switch (snsGovernanceId) { case (null) { snsGovernanceId := ?id }; case (?_) { Runtime.trap("SNS governance already set") }; }; }; func requireGovernance(caller : Principal) { switch (snsGovernanceId) { case (?gov) { if (caller != gov) { Runtime.trap("Only SNS governance can call this") }; }; case (null) { Runtime.trap("SNS governance not configured") }; }; }; // Admin functions become governance-gated: public shared ({ caller }) func updateConfig(newFee : Nat) : async () { requireGovernance(caller); // ... apply config change }; }; ``` ### Rust ```rust use candid::{CandidType, Deserialize, Principal}; use ic_cdk::{init, post_upgrade, query, update}; use std::cell::RefCell; #[derive(CandidType, Deserialize, Clone)] struct Config { sns_governance: Option, } thread_local! { // ⚠ STATE LOSS: RefCell in thread_local! is HEAP storage — it is wiped on every // canister upgrade. In production, use ic-stable-structures (StableCell or StableBTreeMap) // to persist this across upgrades. At minimum, implement #[pre_upgrade]/#[post_upgrade] // hooks to serialize/deserialize this data. Without that, an upgrade erases your // governance config and locks out SNS control. static CONFIG: RefCell = RefCell::new(Config { sns_governance: None, }); } fn require_governance(caller: Principal) { CONFIG.with(|c| { let config = c.borrow(); match config.sns_governance { Some(gov) if gov == caller => (), Some(_) => ic_cdk::trap("Only SNS governance can call this"), None => ic_cdk::trap("SNS governance not configured"), } }); } // ⚠ SECURITY: This setter MUST be access-controlled. Without a check, any caller // can front-run you and set themselves as governance, permanently locking you out. #[update] fn set_sns_governance(id: Principal) { // Only canister controllers should call this. if !ic_cdk::api::is_controller(&ic_cdk::api::msg_caller()) { ic_cdk::trap("Only canister controllers can set governance"); } CONFIG.with(|c| { let mut config = c.borrow_mut(); if config.sns_governance.is_some() { ic_cdk::trap("SNS governance already set"); } config.sns_governance = Some(id); }); } #[update] fn update_config(new_fee: u64) { let caller = ic_cdk::api::msg_caller(); require_governance(caller); // ... apply config change } ``` **Cargo.toml dependencies:** ```toml [package] name = "sns_dapp_backend" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] candid = "0.10" ic-cdk = "0.19" serde = { version = "1", features = ["derive"] } ``` ## Deploy & Test ### Local Testing with sns-testing ```bash # Clone the SNS testing repository git clone https://github.com/dfinity/sns-testing.git cd sns-testing # WARNING: starting a fresh network wipes all local canister data. Only use for fresh setup. icp network start -d # Deploy NNS canisters locally (includes governance, ledger, SNS-W) # Note: Use the sns-testing repo's setup scripts for NNS + SNS-W canister installation. # See https://github.com/dfinity/sns-testing for current instructions. # Deploy your dapp canisters icp deploy my_backend icp deploy my_frontend # Deploy a testflight SNS locally using your config # Use the sns-testing repo tooling to deploy a local testflight SNS. # See sns-testing README for the current testflight workflow. ``` ### Mainnet Testflight (Mock SNS) ```bash # Deploy a mock SNS on mainnet to test governance flows # This does NOT do a real swap -- it creates a mock SNS you control # Use the sns-testing repo tooling for mainnet testflight deployment. # See https://github.com/dfinity/sns-testing for the current testflight workflow. # Test submitting proposals, voting, and upgrading via SNS governance ``` ### Mainnet Launch (Real) ```bash # Step 1: Add NNS Root as co-controller of each dapp canister # Requires dfx sns extension: `dfx extension install sns` dfx sns prepare-canisters add-nns-root BACKEND_CANISTER_ID --network ic dfx sns prepare-canisters add-nns-root FRONTEND_CANISTER_ID --network ic # Step 2: Validate your config locally before submitting dfx sns init-config-file validate # Or review the rendered proposal by inspecting the yaml output carefully. # You can also test the full flow on a local replica first (see Local Testing above). # Step 3: Submit the proposal (THIS IS IRREVERSIBLE — double-check your config) dfx sns propose --network ic --neuron $NEURON_ID sns_init.yaml ``` ## Verify It Works ### After local testflight deployment: ```bash # List deployed SNS canisters icp canister id sns_governance icp canister id sns_ledger icp canister id sns_root icp canister id sns_swap # Verify SNS governance is operational icp canister call sns_governance get_nervous_system_parameters '()' # Expected: returns the governance parameters you configured # Verify token distribution icp canister call sns_ledger icrc1_total_supply '()' # Expected: matches your total token supply # Verify dapp canister controllers changed icp canister status BACKEND_CANISTER_ID # Expected: controller is the SNS Root canister, NOT your principal # Test an SNS proposal (upgrade your canister via governance) icp canister call sns_governance manage_neuron '(record { ... })' # Expected: proposal created, can be voted on ``` ### After mainnet launch: ```bash # Check swap status icp canister call SNS_SWAP_ID get_state '()' -e ic # Expected: shows swap status, participation count, ICP raised # Check SNS governance icp canister call SNS_GOVERNANCE_ID get_nervous_system_parameters '()' -e ic # Expected: returns your configured parameters # Verify dapp controller is SNS Root icp canister status BACKEND_CANISTER_ID -e ic # Expected: single controller = SNS Root canister ID ``` --- ## Stable Memory & Upgrades --- name: stable-memory description: "Persist canister state across upgrades. Covers StableBTreeMap and MemoryManager in Rust, persistent actor in Motoko, and upgrade hook patterns. Use when dealing with canister upgrades, data persistence, data lost after upgrade, stable storage, StableBTreeMap, pre_upgrade traps, or heap vs stable memory. Do NOT use for inter-canister calls or access control — use multi-canister or canister-security instead." license: Apache-2.0 compatibility: "icp-cli >= 0.2.2" metadata: title: "Stable Memory & Upgrades" category: Core --- # Stable Memory & Canister Upgrades ## What This Is Stable memory is persistent storage on Internet Computer that survives canister upgrades. Heap memory (regular variables) is wiped on every upgrade. Any data you care about MUST be in stable memory, or it will be lost the next time the canister is deployed. ## Prerequisites - For Motoko: mops with `core = "2.0.0"` in mops.toml - For Rust: `ic-stable-structures = "0.7"` in Cargo.toml ## Canister IDs No external canister dependencies. Stable memory is a local canister feature. ## Mistakes That Break Your Build 1. **Using `thread_local! { RefCell }` for user data (Rust)** -- This is heap memory. It is wiped on every canister upgrade. All user data, balances, settings stored this way will vanish after `icp deploy`. Use `StableBTreeMap` instead. 2. **Forgetting `#[post_upgrade]` handler (Rust)** -- Without a `post_upgrade` function, the canister may silently reset state or behave unexpectedly after upgrade. Always define both `#[init]` and `#[post_upgrade]`. 3. **Using `stable` keyword in persistent actors (Motoko)** -- In mo:core `persistent actor`, all `let` and `var` declarations are automatically stable. Writing `stable let` produces warning M0218 and `stable var` is redundant. Just use `let` and `var`. 4. **Confusing heap memory limits with stable memory limits (Rust)** -- Heap (Wasm linear) memory is limited to 4GB for wasm32 and 6GB for wasm64. Stable memory can grow up to hundreds of GB (the subnet storage limit). The real danger: if you use `pre_upgrade`/`post_upgrade` hooks to serialize heap data to stable memory and deserialize it back, you are limited by the heap memory size AND by the instruction limit for upgrade hooks. Large datasets will trap during upgrade, bricking the canister. The solution is to use stable structures (`StableBTreeMap`, `StableCell`, etc.) that read/write directly to stable memory, bypassing the heap entirely. Use `MemoryManager` to partition stable memory into virtual memories so multiple structures can coexist without overwriting each other. 5. **Changing record field types between upgrades (Motoko)** -- Altering the type of a persistent field (e.g., `Nat` to `Int`, or renaming a record field) will trap on upgrade and data is unrecoverable. Only ADD new optional fields. Never remove or rename existing ones. 6. **Serializing large data in pre_upgrade (Rust)** -- `pre_upgrade` has a fixed instruction limit. If you serialize a large HashMap to stable memory in pre_upgrade, it will hit the limit and trap, bricking the canister. Use `StableBTreeMap` which writes directly to stable memory and needs no serialization step. 7. **Using `actor { }` instead of `persistent actor { }` (Motoko)** -- Plain `actor` in mo:core requires explicit `stable` annotations and pre/post_upgrade hooks. `persistent actor` makes everything stable by default. Always use `persistent actor`. ## Implementation ### Motoko With mo:core 2.0, `persistent actor` makes stable storage trivial. All `let` and `var` declarations inside the actor body are automatically persisted across upgrades. ```motoko import Map "mo:core/Map"; import List "mo:core/List"; import Nat "mo:core/Nat"; import Text "mo:core/Text"; import Time "mo:core/Time"; persistent actor { // Types -- must be inside actor body type User = { id : Nat; name : Text; created : Int; }; // These survive upgrades automatically -- no "stable" keyword needed let users = Map.empty(); var userCounter : Nat = 0; let tags = List.empty(); // Transient data -- reset to initial value on every upgrade transient var requestCount : Nat = 0; public func addUser(name : Text) : async Nat { let id = userCounter; Map.add(users, Nat.compare, id, { id; name; created = Time.now(); }); userCounter += 1; requestCount += 1; id }; public query func getUser(id : Nat) : async ?User { Map.get(users, Nat.compare, id) }; public query func getUserCount() : async Nat { Map.size(users) }; // requestCount resets to 0 after every upgrade public query func getRequestCount() : async Nat { requestCount }; } ``` Key rules for Motoko persistent actors: - `let` for Map, List, Set, Queue -- auto-persisted, no serialization - `var` for simple values (Nat, Text, Bool) -- auto-persisted - `transient var` for caches, counters that should reset on upgrade - NO `pre_upgrade` / `post_upgrade` needed -- the runtime handles it - NO `stable` keyword -- it is redundant and produces warnings #### mops.toml ```toml [package] name = "my-project" version = "0.1.0" [dependencies] core = "2.0.0" ``` ### Rust Rust canisters use `ic-stable-structures` for persistent storage. The `MemoryManager` partitions stable memory (up to hundreds of GB, limited by subnet storage) into virtual memories, each backing a different data structure. #### Cargo.toml ```toml [package] name = "stable_memory_backend" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] ic-cdk = "0.19" ic-stable-structures = "0.7" candid = "0.10" serde = { version = "1", features = ["derive"] } ciborium = "0.2" ``` #### Single Stable Structure (Simple Case) ```rust use ic_stable_structures::{ memory_manager::{MemoryId, MemoryManager, VirtualMemory}, storable::{Bound, Storable}, DefaultMemoryImpl, StableBTreeMap, }; use ic_cdk::{init, post_upgrade, query, update}; use candid::CandidType; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::cell::RefCell; type Memory = VirtualMemory; // -- Implement Storable for custom types -- // StableBTreeMap keys need Storable + Ord, values need Storable. // Storable defines how a type is serialized to/from bytes in stable memory. // Use CBOR (via ciborium) for serialization -- compact binary format, faster than candid. #[derive(CandidType, Serialize, Deserialize, Clone)] struct User { id: u64, name: String, created: u64, } impl Storable for User { // Recommended: prefer Unbounded to avoid backwards compatibility issues when adding new fields. // Bounded requires a fixed max_size -- adding a field that increases the size will break existing data. const BOUND: Bound = Bound::Unbounded; fn to_bytes(&self) -> Cow<'_, [u8]> { let mut buf = vec![]; ciborium::into_writer(self, &mut buf).expect("Failed to encode User"); Cow::Owned(buf) } fn into_bytes(self) -> Vec { let mut buf = vec![]; ciborium::into_writer(&self, &mut buf).expect("Failed to encode User"); buf } fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { ciborium::from_reader(bytes.as_ref()).expect("Failed to decode User") } } // Bound::Bounded { max_size, is_fixed_size: true } exists for fixed-size types but is NOT // recommended -- adding a new field later will exceed max_size and break deserialization. // Stable storage -- survives upgrades thread_local! { static MEMORY_MANAGER: RefCell> = RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); static USERS: RefCell> = RefCell::new(StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))) )); // Counter stored in stable memory via StableCell static COUNTER: RefCell> = RefCell::new(ic_stable_structures::StableCell::init( MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))), 0u64, )); } #[init] fn init() { // Any one-time initialization } #[post_upgrade] fn post_upgrade() { // Stable structures auto-restore -- no deserialization needed // Re-init timers or other transient state here } #[update] fn add_user(name: String) -> u64 { let id = COUNTER.with(|c| { let mut cell = c.borrow_mut(); let current = *cell.get(); cell.set(current + 1); current }); let user = User { id, name, created: ic_cdk::api::time(), }; USERS.with(|users| { users.borrow_mut().insert(id, user); }); id } #[query] fn get_user(id: u64) -> Option { USERS.with(|users| users.borrow().get(&id)) } #[query] fn get_user_count() -> u64 { USERS.with(|users| users.borrow().len()) } ic_cdk::export_candid!(); ``` #### Multiple Stable Structures with MemoryManager ```rust use ic_stable_structures::{ memory_manager::{MemoryId, MemoryManager, VirtualMemory}, DefaultMemoryImpl, StableBTreeMap, StableCell, StableLog, }; use std::cell::RefCell; type Memory = VirtualMemory; // Each structure gets its own MemoryId -- NEVER reuse IDs const USERS_MEM_ID: MemoryId = MemoryId::new(0); const POSTS_MEM_ID: MemoryId = MemoryId::new(1); const COUNTER_MEM_ID: MemoryId = MemoryId::new(2); const LOG_INDEX_MEM_ID: MemoryId = MemoryId::new(3); const LOG_DATA_MEM_ID: MemoryId = MemoryId::new(4); thread_local! { static MEMORY_MANAGER: RefCell> = RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); static USERS: RefCell, Memory>> = RefCell::new(StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(USERS_MEM_ID)) )); static POSTS: RefCell, Memory>> = RefCell::new(StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(POSTS_MEM_ID)) )); static COUNTER: RefCell> = RefCell::new(StableCell::init( MEMORY_MANAGER.with(|m| m.borrow().get(COUNTER_MEM_ID)), 0u64, )); static AUDIT_LOG: RefCell, Memory, Memory>> = RefCell::new(StableLog::init( MEMORY_MANAGER.with(|m| m.borrow().get(LOG_INDEX_MEM_ID)), MEMORY_MANAGER.with(|m| m.borrow().get(LOG_DATA_MEM_ID)), )); } ``` Key rules for Rust stable structures: - `MemoryManager` partitions stable memory -- each structure gets a unique `MemoryId` - NEVER reuse a `MemoryId` for two different structures -- they will corrupt each other - `StableBTreeMap` keys must implement `Storable` + `Ord`, values must implement `Storable` - Implement `Storable` for custom types: define `BOUND`, `to_bytes`, `into_bytes`, and `from_bytes`. Use `ciborium::into_writer`/`ciborium::from_reader` for CBOR serialization (compact, fast). Prefer `Bound::Unbounded` -- it avoids backwards compatibility breakage when adding new fields. `Bound::Bounded` exists but is not recommended because exceeding `max_size` after a schema change breaks deserialization - Primitive types (`u64`, `bool`, `f64`, etc.), `String`, `Vec`, and `Principal` already implement `Storable` -- no manual impl needed - `StableCell` for single values (counters, config) - `StableLog` for append-only logs (needs two memory regions: index + data) - `thread_local! { RefCell> }` is the correct pattern -- the RefCell wraps the stable structure, not a heap HashMap - No `pre_upgrade`/`post_upgrade` serialization needed -- data is already in stable memory ## Deploy & Test ### Motoko: Verify Persistence Across Upgrades ```bash # Start local replica icp network start -d # Deploy icp deploy backend # Add data icp canister call backend addUser '("Alice")' # Expected: (0 : nat) icp canister call backend addUser '("Bob")' # Expected: (1 : nat) # Verify data exists icp canister call backend getUserCount '()' # Expected: (2 : nat) icp canister call backend getUser '(0)' # Expected: (opt record { id = 0 : nat; name = "Alice"; created = ... }) # Now upgrade the canister (simulates code change + redeploy) icp deploy backend # Verify data survived the upgrade icp canister call backend getUserCount '()' # Expected: (2 : nat) -- STILL 2, not 0 icp canister call backend getUser '(1)' # Expected: (opt record { id = 1 : nat; name = "Bob"; created = ... }) ``` ### Rust: Verify Persistence Across Upgrades ```bash icp network start -d icp deploy backend icp canister call backend add_user '("Alice")' # Expected: (0 : nat64) icp canister call backend get_user_count '()' # Expected: (1 : nat64) # Upgrade icp deploy backend # Verify persistence icp canister call backend get_user_count '()' # Expected: (1 : nat64) -- data survived icp canister call backend get_user '(0)' # Expected: (opt record { id = 0 : nat64; name = "Alice"; created = ... }) ``` ## Verify It Works The definitive test for stable memory: data survives upgrade. ```bash # 1. Deploy and add data icp deploy backend icp canister call backend addUser '("TestUser")' # 2. Record the count icp canister call backend getUserCount '()' # Note the number # 3. Upgrade (redeploy) icp deploy backend # 4. Check count again -- must be identical icp canister call backend getUserCount '()' # Must match step 2 # 5. Verify transient data DID reset icp canister call backend getRequestCount '()' # Expected: (0 : nat) -- transient var resets on upgrade ``` If the count drops to 0 after step 3, your data is NOT in stable memory. Review your storage declarations. --- ## vetKeys --- name: vetkd description: "Implement on-chain encryption using vetKeys (verifiable encrypted threshold key derivation). Covers key derivation, IBE encryption/decryption, transport keys, and access control. Use when adding encryption, decryption, on-chain privacy, vetKeys, or identity-based encryption to a canister. Do NOT use for authentication — use internet-identity instead." license: Apache-2.0 compatibility: "icp-cli >= 0.2.2" metadata: title: vetKeys category: Security --- # vetKeys (Verifiable Encrypted Threshold Keys) > **Note:** vetKeys is a newer feature of the IC. The `ic-vetkeys` Rust crate and `@dfinity/vetkeys` > npm package are published, but the APIs may still change over time. > Pin your dependency versions and check the [DFINITY forum](https://forum.dfinity.org) for any migration guides after upgrades. ## What This Is vetKeys (verifiably encrypted threshold keys) bring on-chain privacy to the IC via the **vetKD** protocol: secure, on-demand key derivation so that a public blockchain can hold and work with secret data. Keys are **verifiable** (users can check correctness and lack of tampering), **encrypted** (derived keys are encrypted under a user-supplied transport key—no node or canister ever sees the raw key), and **threshold** (a quorum of subnet nodes cooperates to derive keys; no single party has the master key). A canister requests a derived key from the subnet’s threshold infrastructure, receives it encrypted under the client’s transport public key, and only the client decrypts it locally. This unlocks decentralized key management (DKMS), encrypted on-chain storage, private messaging, identity-based encryption (IBE), timelock encryption, threshold BLS, and verifiable randomness—use cases. ## Prerequisites - Rust: `ic-vetkeys = "0.6"` ([crates.io](https://crates.io/crates/ic-vetkeys)) - Motoko: Use the raw management canister approach shown below - Frontend: `@dfinity/vetkeys` v0.4.0 ## Canister IDs | Canister | ID | Purpose | |----------|-----|---------| | Management Canister | `aaaaa-aa` | Exposes `vetkd_public_key` and `vetkd_derive_key` system APIs | | Chain-key testing canister | `vrqyr-saaaa-aaaan-qzn4q-cai` | **Testing only:** fake vetKD implementation to test key derivation without paying production API fees. Insecure, do not use in production. | The management canister is not a real canister, it is a system-level API endpoint. Calls to `aaaaa-aa` are routed by the system to the vetKD-enabled subnet that holds the master key specified in `key_id`; that subnet's nodes run the threshold key derivation. Your canister can call from any subnet. **Testing canister:** The [chain-key testing canister](https://github.com/dfinity/chainkey-testing-canister) is deployed on mainnet and provides a fake vetKD implementation (hard-coded keys, no threshold) so you can exercise key derivation without production cycle costs. Use key name `insecure_test_key_1`. **Insecure, for testing only:** never use it in production or with sensitive data. You can also deploy your own instance from the repo. ### Master Key Names and API Fees Any canister on the IC can use any available master key regardless of which subnet the canister or the key resides on; the management canister routes calls to the subnet that holds the master key. | Key name | Environment | Purpose | Cycles (approx.) | Notes | |----------------|------------------|-------------------|--------------------|-------| | `test_key_1` | Local + Mainnet | Development & testing | 10_000_000_000 (mainnet) | Works both locally and on mainnet. Use for development and testing. | | `key_1` | Mainnet | Production | 26_153_846_153 | Subnet pzp6e (backed up on uzr34) | Fees depend on the **subnet where the master key resides** (and its size), not on the calling canister's subnet. If the canister may be blackholed or used by other canisters, send **more cycles** than the current cost so that future subnet size increases do not cause calls to fail; unused cycles are refunded. See [vetKD API — API fees](https://docs.internetcomputer.org/building-apps/network-features/vetkeys/api#api-fees) for current USD estimates. ## Key Concepts - **vetKey**: Key material derived deterministically from `(canister_id, context, input)`. Same inputs always produce the same key. Neither the canister nor any subnet node ever sees the raw key, as it is encrypted under the client's transport key until decrypted locally. - **Transport key**: An ephemeral key pair generated by the client. The public key is sent to the canister so the IC can encrypt the derived key for delivery. Only the client holding the corresponding private key can decrypt the result. - **Context**: A domain separator blob. Isolates derived subkeys per use case (e.g. per feature or key purpose) and prevents key collisions within the same canister. Think of it as a namespace. - **Input**: Application-defined data that identifies which key to derive (e.g. user principal, file ID, chat room ID). It is sent in plaintext to the management canister. Use it only as an identifier, never for secret data. - **IBE (Identity-Based Encryption)**: A scheme where you encrypt to an identity (e.g. a principal) using a derived public key. vetKeys enables IBE on the IC: anyone can encrypt to a principal using the canister's derived public key; only that principal can obtain the matching vetKey and decrypt. ## Mistakes That Break Your Build 1. **Not pinning dependency versions.** The `ic-vetkeys` crate and `@dfinity/vetkeys` npm package are published, but the APIs may still change in new releases. Pin your versions and re-test after upgrades. If something stops working after an upgrade, consult the relevant change notes to understand what happened. 2. **Reusing transport keys across sessions.** Each session must generate a fresh transport key pair. The Rust and TypeScript libraries include support for generating keys safely; use them if at all possible. 3. **Using raw `vetkd_derive_key` output as an encryption key.** The output is an encrypted blob. You must decrypt it with the transport secret to get the vetKey (raw key material). What you do next depends on your use case: for example, you might derive a symmetric key (e.g. for AES) via `toDerivedKeyMaterial()` or the equivalent. Do not use the decrypted bytes directly as an AES key. Other uses (IBE decryption, signing, etc.) consume the vetKey in their own way; the libraries document the right pattern for each. 4. **Confusing vetKD with traditional public-key crypto.** There are no static key pairs per user. Keys are derived on-demand from the subnet's threshold master key (via the vetKD protocol). The same (canister, context, input) always yields the same derived key. 5. **Putting secret data in the `input` field.** The input is sent to the management canister in plaintext. It is a key identifier, not encrypted payload. Use it for IDs (principal, document ID), never for the actual secret data. 6. **Forgetting that `vetkd_derive_key` is an async inter-canister call.** It costs cycles and requires `await`. Capture `caller` before the await as defensive practice. 7. **Using `context` inconsistently.** If the backend uses `b"my_app_v1"` as context but the frontend verification uses `b"my_app"`, the derived keys will not match and decryption will silently fail. 8. **Not attaching enough cycles to `vetkd_derive_key`.** `vetkd_derive_key` consumes cycles; `vetkd_public_key` does not. For derive_key, `key_1` costs ~26B cycles and `test_key_1` costs ~10B cycles. 9. **Rolling your own IBE without proper authorization checks.** If you implement IBE manually (bypassing `KeyManager` / `EncryptedMaps`), your canister must enforce that `vetkd_derive_key` only returns the derived key to the authorized caller — e.g. the principal whose identity was used as the `input`. Without this check, any caller can request any derived key and decrypt messages meant for someone else. The provided `ic-vetkeys` / `@dfinity/vetkeys` libraries handle this correctly; prefer them over a custom implementation. ## System API (Candid) The vetKD API lets canisters request vetKeys derived by the threshold protocol. Derivation is **deterministic**: the same inputs always produce the same key, so keys can be retrieved reliably. Different inputs yield different keys—canisters can derive an unlimited number of unique keys. Summary below; full spec: [vetKD API](https://docs.internetcomputer.org/building-apps/network-features/vetkeys/api) and the [IC interface specification](https://internetcomputer.org/docs/current/references/ic-interface-spec#ic-vetkd_derive_key). ### vetkd_public_key Returns a public key used to **verify** keys derived with `vetkd_derive_key`. With an empty context you get the canister-level master public key; with a non-empty context you get the derived subkey for that context. In IBE, this public key lets anyone encrypt to an identity (e.g. a principal); only the holder of that identity can later obtain the matching vetKey and decrypt—no prior key exchange or recipient presence required. ```candid vetkd_public_key : (record { canister_id : opt canister_id; context : blob; key_id : record { curve : vetkd_curve; name : text }; }) -> (record { public_key : blob }) ``` - `canister_id`: Optional. If omitted (`null`), the public key for the **calling canister** is returned; if provided, the key for that canister is returned. - `context`: Domain separator which has the same meaning as in `vetkd_derive_key`. Ensures keys are derived in a specific context and avoids collisions across apps or use cases. - `key_id.curve`: `bls12_381_g2` (only supported curve). - `key_id.name`: Master key name: `test_key_1` (local + mainnet testing) or `key_1` (production). You can also derive this public key **offline** from the known mainnet master public key; see "Offline Public Key Derivation" below. ### vetkd_derive_key Derives key material for the given (context, input) and returns it **encrypted** under the recipient's transport public key. Only the holder of the transport secret can decrypt. The decrypted material is then used according to your use case (e.g. via `toDerivedKeyMaterial()` for symmetric keys, or for IBE decryption). ```candid vetkd_derive_key : (record { input : blob; context : blob; transport_public_key : blob; key_id : record { curve : vetkd_curve; name : text }; }) -> (record { encrypted_key : blob }) ``` - `input`: Arbitrary data used as the key identifier—different inputs yield different derived keys. Does not need to be random; sent in plaintext to the management canister. - `context`: Domain separator; must match the context used when obtaining the public key (e.g. for verification or IBE). - `transport_public_key`: The recipient's public key; the derived key is encrypted under this for secure delivery. - Returns: `encrypted_key`. Decrypt with the transport secret to get the raw vetKey, then use it as required (e.g. derive a symmetric key; do not use raw bytes directly as an AES key). Master key names and cycle costs are in **Master Key Names and API Fees** under Canister IDs. ## Implementation ### Rust **Cargo.toml:** ```toml [dependencies] candid = "0.10" ic-cdk = "0.19" serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" # High-level library (recommended) — source: https://github.com/dfinity/vetkeys ic-vetkeys = "0.6" ic-stable-structures = "0.7" ``` **Using ic-vetkeys library (recommended):** ```rust use candid::Principal; use ic_cdk::update; use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; use ic_stable_structures::DefaultMemoryImpl; use ic_vetkeys::key_manager::KeyManager; use ic_vetkeys::types::{AccessRights, VetKDCurve, VetKDKeyId}; // KeyManager is generic over an AccessControl type — AccessRights is the default. // It uses stable memory for persistent storage of access control state. thread_local! { static MEMORY_MANAGER: std::cell::RefCell> = std::cell::RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); static KEY_MANAGER: std::cell::RefCell>> = std::cell::RefCell::new(None); } #[ic_cdk::init] fn init() { let key_id = VetKDKeyId { curve: VetKDCurve::Bls12381G2, name: "key_1".to_string(), // "test_key_1" for local + mainnet testing }; MEMORY_MANAGER.with(|mm| { let mm = mm.borrow(); KEY_MANAGER.with(|km| { *km.borrow_mut() = Some(KeyManager::init( "my_app_v1", // domain separator key_id, mm.get(MemoryId::new(0)), // config memory mm.get(MemoryId::new(1)), // access control memory mm.get(MemoryId::new(2)), // shared keys memory )); }); }); } #[update] async fn get_encrypted_vetkey(subkey_id: Vec, transport_public_key: Vec) -> Vec { let caller = ic_cdk::caller(); // Capture BEFORE await let future = KEY_MANAGER.with(|km| { let km = km.borrow(); let km = km.as_ref().expect("not initialized"); km.get_encrypted_vetkey(caller, subkey_id, transport_public_key) .expect("access denied") }); future.await } #[update] async fn get_vetkey_verification_key() -> Vec { let future = KEY_MANAGER.with(|km| { let km = km.borrow(); let km = km.as_ref().expect("not initialized"); km.get_vetkey_verification_key() }); future.await } ``` **Calling management canister directly (lower level):** ```rust use candid::{CandidType, Deserialize, Principal}; use ic_cdk::update; #[derive(CandidType, Deserialize)] struct VetKdKeyId { curve: VetKdCurve, name: String, } #[derive(CandidType, Deserialize)] enum VetKdCurve { #[serde(rename = "bls12_381_g2")] Bls12381G2, } #[derive(CandidType)] struct VetKdPublicKeyRequest { canister_id: Option, context: Vec, key_id: VetKdKeyId, } #[derive(CandidType, Deserialize)] struct VetKdPublicKeyResponse { public_key: Vec, } #[derive(CandidType)] struct VetKdDeriveKeyRequest { input: Vec, context: Vec, transport_public_key: Vec, key_id: VetKdKeyId, } #[derive(CandidType, Deserialize)] struct VetKdDeriveKeyResponse { encrypted_key: Vec, } const CONTEXT: &[u8] = b"my_app_v1"; fn key_id() -> VetKdKeyId { VetKdKeyId { curve: VetKdCurve::Bls12381G2, // Key names: "test_key_1" for local + mainnet testing, "key_1" for production name: "key_1".to_string(), } } #[update] async fn vetkd_public_key() -> Vec { let request = VetKdPublicKeyRequest { canister_id: None, // defaults to this canister context: CONTEXT.to_vec(), key_id: key_id(), }; // vetkd_public_key does not require cycles (unlike vetkd_derive_key). let (response,): (VetKdPublicKeyResponse,) = ic_cdk::api::call::call( Principal::management_canister(), // aaaaa-aa "vetkd_public_key", (request,), ) .await .expect("vetkd_public_key call failed"); response.public_key } #[update] async fn vetkd_derive_key(transport_public_key: Vec) -> Vec { let caller = ic_cdk::caller(); // MUST capture before await let request = VetKdDeriveKeyRequest { input: caller.as_slice().to_vec(), // derive key specific to this caller context: CONTEXT.to_vec(), transport_public_key, key_id: key_id(), }; // key_1 costs ~26B cycles, test_key_1 costs ~10B cycles. let (response,): (VetKdDeriveKeyResponse,) = ic_cdk::api::call::call_with_payment128( Principal::management_canister(), "vetkd_derive_key", (request,), 26_000_000_000, // cycles for key_1 (use 10_000_000_000 for test_key_1) ) .await .expect("vetkd_derive_key call failed"); response.encrypted_key } ``` ### Motoko **mops.toml:** ```toml [package] name = "my-vetkd-app" version = "0.1.0" [dependencies] core = "2.0.0" ``` **Using the management canister directly:** ```motoko import Blob "mo:core/Blob"; import Principal "mo:core/Principal"; import Text "mo:core/Text"; persistent actor { type VetKdCurve = { #bls12_381_g2 }; type VetKdKeyId = { curve : VetKdCurve; name : Text; }; type VetKdPublicKeyRequest = { canister_id : ?Principal; context : Blob; key_id : VetKdKeyId; }; type VetKdPublicKeyResponse = { public_key : Blob; }; type VetKdDeriveKeyRequest = { input : Blob; context : Blob; transport_public_key : Blob; key_id : VetKdKeyId; }; type VetKdDeriveKeyResponse = { encrypted_key : Blob; }; let managementCanister : actor { vetkd_public_key : VetKdPublicKeyRequest -> async VetKdPublicKeyResponse; vetkd_derive_key : VetKdDeriveKeyRequest -> async VetKdDeriveKeyResponse; } = actor "aaaaa-aa"; let context : Blob = Text.encodeUtf8("my_app_v1"); // Key names: "test_key_1" for local + mainnet testing, "key_1" for production func keyId() : VetKdKeyId { { curve = #bls12_381_g2; name = "key_1" } }; public shared func getPublicKey() : async Blob { // vetkd_public_key does not require cycles (unlike vetkd_derive_key). let response = await managementCanister.vetkd_public_key({ canister_id = null; context; key_id = keyId(); }); response.public_key }; public shared ({ caller }) func deriveKey(transportPublicKey : Blob) : async Blob { // caller is captured here, before the await. vetkd_derive_key requires cycles. let response = await (with cycles = 26_000_000_000) managementCanister.vetkd_derive_key({ input = Principal.toBlob(caller); context; transport_public_key = transportPublicKey; key_id = keyId(); }); response.encrypted_key }; }; ``` ### Frontend (TypeScript) The frontend generates a transport key pair, sends the public half to the canister, receives the encrypted derived key, decrypts it with the transport secret to get the vetKey (raw key material), then derives a symmetric key from that material (e.g. via `toDerivedKeyMaterial()`) for AES or other use. ```typescript import { TransportSecretKey, DerivedPublicKey, EncryptedVetKey } from "@dfinity/vetkeys"; // 1. Generate a transport secret key (BLS12-381) const seed = crypto.getRandomValues(new Uint8Array(32)); const transportSecretKey = TransportSecretKey.fromSeed(seed); const transportPublicKey = transportSecretKey.publicKey(); // 2. Request encrypted vetkey and verification key from your canister const [encryptedKeyBytes, verificationKeyBytes] = await Promise.all([ backendActor.get_encrypted_vetkey(subkeyId, transportPublicKey), backendActor.get_vetkey_verification_key(), ]); // 3. Deserialize and decrypt const verificationKey = DerivedPublicKey.deserialize(new Uint8Array(verificationKeyBytes)); const encryptedVetKey = EncryptedVetKey.deserialize(new Uint8Array(encryptedKeyBytes)); const vetKey = encryptedVetKey.decryptAndVerify( transportSecretKey, verificationKey, new Uint8Array(subkeyId), ); // 4. Derive a symmetric key for AES-GCM const aesKeyMaterial = vetKey.toDerivedKeyMaterial(); const aesKey = await crypto.subtle.importKey( "raw", aesKeyMaterial.data.slice(0, 32), // 256-bit AES key { name: "AES-GCM" }, false, ["encrypt", "decrypt"], ); // 5. Encrypt const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, aesKey, new TextEncoder().encode("secret message"), ); // 6. Decrypt const plaintext = await crypto.subtle.decrypt( { name: "AES-GCM", iv }, aesKey, ciphertext, ); ``` The `@dfinity/vetkeys` package also provides higher-level abstractions via sub-paths: - **`@dfinity/vetkeys/key_manager`** -- `KeyManager` and `DefaultKeyManagerClient` for managing access-controlled keys - **`@dfinity/vetkeys/encrypted_maps`** -- `EncryptedMaps` and `DefaultEncryptedMapsClient` for encrypted key-value storage These mirror the Rust `KeyManager` and `EncryptedMaps` types and handle the transport key flow automatically. ### Offline Public Key Derivation You can derive public keys offline (without any canister calls) from the known mainnet master public key for a given key name (e.g. `key_1`). This is useful for IBE: you derive the canister's public key for your context, then encrypt to an identity (e.g. a principal) without the recipient or the canister being online. **Rust:** ```rust use ic_vetkeys::{MasterPublicKey, DerivedPublicKey}; // Start from the known mainnet master public key for key_1 let master_key = MasterPublicKey::for_mainnet_key("key_1") .expect("unknown key name"); // Derive the canister-level key let canister_key = master_key.derive_canister_key(canister_id.as_slice()); // Derive a sub-key for a specific context/identity let derived_key: DerivedPublicKey = canister_key.derive_sub_key(b"my_app_v1"); // Use derived_key for IBE encryption — no canister call needed ``` **TypeScript:** ```typescript import { MasterPublicKey, DerivedPublicKey } from "@dfinity/vetkeys"; // Start from the known mainnet master public key const masterKey = MasterPublicKey.productionKey(); // Derive the canister-level key const canisterKey = masterKey.deriveCanisterKey(canisterId); // Derive a sub-key for a specific context/identity const derivedKey: DerivedPublicKey = canisterKey.deriveSubKey( new TextEncoder().encode("my_app_v1"), ); // Use derivedKey for IBE encryption — no canister call needed ``` ### Identity-Based Encryption (IBE) IBE lets you encrypt to an identity (e.g. a principal) using only the canister's derived public key—the recipient does not need to be online or have registered a key beforehand. The recipient later authenticates to the canister, obtains their vetKey (derived for that identity) via `vetkd_derive_key`, and decrypts locally. **TypeScript:** ```typescript import { TransportSecretKey, DerivedPublicKey, EncryptedVetKey, IbeCiphertext, IbeIdentity, IbeSeed, } from "@dfinity/vetkeys"; // --- Encrypt (sender side, no canister call needed) --- // Derive the recipient's public key offline (see "Offline Public Key Derivation" above) const recipientIdentity = IbeIdentity.fromBytes(recipientPrincipalBytes); const seed = IbeSeed.random(); const plaintext = new TextEncoder().encode("secret message"); const ciphertext = IbeCiphertext.encrypt(derivedPublicKey, recipientIdentity, plaintext, seed); const serialized = ciphertext.serialize(); // store or transmit this // --- Decrypt (recipient side, requires canister call to get vetKey) --- // 1. Get the vetKey (same flow as the Frontend section above) const transportSecretKey = TransportSecretKey.fromSeed(crypto.getRandomValues(new Uint8Array(32))); const [encryptedKeyBytes, verificationKeyBytes] = await Promise.all([ backendActor.get_encrypted_vetkey(subkeyId, transportSecretKey.publicKey()), backendActor.get_vetkey_verification_key(), ]); const verificationKey = DerivedPublicKey.deserialize(new Uint8Array(verificationKeyBytes)); const encryptedVetKey = EncryptedVetKey.deserialize(new Uint8Array(encryptedKeyBytes)); const vetKey = encryptedVetKey.decryptAndVerify( transportSecretKey, verificationKey, new Uint8Array(subkeyId), ); // 2. Decrypt the IBE ciphertext const deserialized = IbeCiphertext.deserialize(serialized); const decrypted = deserialized.decrypt(vetKey); // decrypted is Uint8Array containing "secret message" ``` **Rust (off-chain client or test):** ```rust use ic_vetkeys::{ DerivedPublicKey, IbeCiphertext, IbeIdentity, IbeSeed, VetKey, }; // --- Encrypt --- let identity = IbeIdentity::from_bytes(recipient_principal.as_slice()); let seed = IbeSeed::new(&mut rand::rng()); let plaintext = b"secret message"; let ciphertext = IbeCiphertext::encrypt( &derived_public_key, &identity, plaintext, &seed, ); let serialized = ciphertext.serialize(); // --- Decrypt (after obtaining the VetKey) --- let deserialized = IbeCiphertext::deserialize(&serialized) .expect("invalid ciphertext"); let decrypted = deserialized.decrypt(&vet_key) .expect("decryption failed"); // decrypted == b"secret message" ``` ### Higher-Level Abstractions: KeyManager & EncryptedMaps Both the Rust crate and TypeScript package provide two higher-level modules that handle the transport key flow, access control, and encrypted storage for you: - **`KeyManager`** (Rust) / **`KeyManager`** (TS) — Manages access-controlled vetKeys with stable storage. The canister enforces who may request which keys; the library handles derivation requests, user rights (`Read`, `ReadWrite`, `ReadWriteManage`), and key sharing between principals. - **`EncryptedMaps`** (Rust) / **`EncryptedMaps`** (TS) — Builds on KeyManager to provide an encrypted key-value store. Each map is access-controlled and encrypted under a derived vetKey. Encryption and decryption of values are handled on the client (frontend) using vetKeys; the canister only stores ciphertext. In Rust, these live in `ic_vetkeys::key_manager` and `ic_vetkeys::encrypted_maps`. In TypeScript, import from `@dfinity/vetkeys/key_manager` and `@dfinity/vetkeys/encrypted_maps`. See the [vetkeys repository](https://github.com/dfinity/vetkeys) for full examples. ## Deploy & Test ### Local Development ```bash # Start the local network (provisions test_key_1 and key_1 automatically) icp network start -d # Deploy your canister icp deploy backend # Test public key retrieval icp canister call backend getPublicKey '()' # Returns: (blob "...") -- the vetKD public key # For derive_key, you need a transport public key (generated by frontend) # Test with a dummy 48-byte blob: icp canister call backend deriveKey '(blob "\00\01\02\03\04\05\06\07\08\09\0a\0b\0c\0d\0e\0f\10\11\12\13\14\15\16\17\18\19\1a\1b\1c\1d\1e\1f\20\21\22\23\24\25\26\27\28\29\2a\2b\2c\2d\2e\2f")' ``` ### Mainnet ```bash # Deploy to mainnet icp deploy backend -e ic # Use test_key_1 for initial testing, key_1 for production # Make sure your canister code references the correct key name ``` ## Verify It Works ```bash # 1. Verify public key is returned (non-empty blob) icp canister call backend getPublicKey '()' # Expected: (blob "\ab\cd\ef...") -- 48+ bytes of BLS public key data # 2. Verify derive_key returns encrypted key (non-empty blob) icp canister call backend deriveKey '(blob "\00\01...")' # Expected: (blob "\12\34\56...") -- encrypted key material # 3. Verify determinism: same (caller, context, input) and same transport key produce same encrypted_key # Call deriveKey twice with the same identity and transport key # Expected: identical encrypted_key blobs both times # 4. Verify isolation: different callers get different keys icp identity new test-user-1 --storage-mode=plaintext icp identity new test-user-2 --storage-mode=plaintext icp identity default test-user-1 icp canister call backend deriveKey '(blob "\00\01...")' # Note the output icp identity default test-user-2 icp canister call backend deriveKey '(blob "\00\01...")' # Expected: DIFFERENT encrypted_key (different caller = different derived key) # 5. Frontend integration test # Open the frontend, trigger encryption/decryption # Verify: encrypted data can be decrypted by the same user # Verify: encrypted data CANNOT be decrypted by a different user ``` --- ## Wallet Integration --- name: wallet-integration description: "Integrate wallets with IC dApps using ICRC signer standards (ICRC-21/25/27/29/49). Covers the popup-based signer model, consent messages, permission lifecycle, and transaction approval flows. Implementation uses @dfinity/oisy-wallet-signer. Do NOT use for Internet Identity login, delegation-based auth (ICRC-34/46), or threshold signing (chain-key). Use when the developer mentions wallet integration, OISY, oisy-wallet-signer, wallet signer, relying party, consent messages, wallet popup, or transaction approval." license: Apache-2.0 compatibility: "Node.js >= 22" metadata: title: Wallet Integration category: DeFi --- # Wallet Integration ## What This Is Wallet integration on the Internet Computer uses the ICRC signer standards — a popup-based model where every action requires explicit user approval via JSON-RPC 2.0 over `window.postMessage`. This skill covers integration using `@dfinity/oisy-wallet-signer`. Other integration paths (IdentityKit, signer-js) exist but are not covered here. **The signer model = explicit per-action approval.** `connect()` establishes a channel. Nothing more. **It is not:** - A session system - A delegated identity (no ICRC-34) - A background executor **ICRC standards implemented:** - ICRC-21 — Canister call consent messages - ICRC-25 — Signer interaction standard (permissions) - ICRC-27 — Accounts - ICRC-29 — Window PostMessage transport - ICRC-49 — Call canister **Not implemented:** - ICRC-46 — Session-based delegation (not supported; use a delegation-capable model if you need sessions) ## When to Use - Clear, intentional, high-value actions: token transfers (ICP / ICRC-1 / ICRC-2), NFT mint/claim, single approvals - Funding / deposit flows: "Top up", "Deposit into protocol" - Any action where a confirmation dialogue per operation feels natural ## When NOT to Use - **Delegation or sessions**: sign once / act many times, background execution, autonomous behaviour - **High-frequency interactions**: games, social actions, rapid write operations - **Invisible writes**: autosave, cron jobs, auto-compounding > **Decision test:** If your app still feels good when every meaningful update shows a confirmation dialogue, this library is appropriate. If not, use a delegation-capable model instead. ## Prerequisites - `@dfinity/oisy-wallet-signer` (>= 4.1.0) - Peer dependencies: `@dfinity/utils` (>= 4.2.0), `@dfinity/zod-schemas` (>= 3.2.0), `@icp-sdk/canisters` (>= 3.5.0), `@icp-sdk/core` (>= 5.0.0), `zod` - A non-anonymous identity on the signer side (e.g. `Ed25519KeyIdentity`) ```bash npm i @dfinity/oisy-wallet-signer @dfinity/utils @dfinity/zod-schemas @icp-sdk/canisters @icp-sdk/core zod ``` ## How It Works ### End-to-End Lifecycle ```text 1. dApp: IcrcWallet.connect({url}) → opens popup, polls icrc29_status 2. dApp: wallet.requestPermissionsNotGranted() → prompts user if needed 3. dApp: wallet.accounts() → signer prompts, returns accounts 4. dApp: wallet.transfer({...}) → signer fetches ICRC-21 consent message → signer prompts user with consent → signer executes canister call → returns block index 5. dApp: wallet.disconnect() → closes popup, cleans up ``` ## Pitfalls 1. **Importing classes from the wrong entry point.** `Signer`, `RelyingParty`, `IcpWallet`, and `IcrcWallet` are **not** exported from the main entry point. Import them from their dedicated subpaths or you get `undefined`. ```typescript // WRONG — will fail import {Signer} from '@dfinity/oisy-wallet-signer'; // CORRECT import {Signer} from '@dfinity/oisy-wallet-signer/signer'; import {IcpWallet} from '@dfinity/oisy-wallet-signer/icp-wallet'; import {IcrcWallet} from '@dfinity/oisy-wallet-signer/icrc-wallet'; ``` 2. **Using `IcrcWallet` without `ledgerCanisterId`.** Unlike `IcpWallet` (which defaults to the ICP ledger `ryjl3-tyaaa-aaaaa-aaaba-cai`), `IcrcWallet.transfer()`, `.approve()`, and `.transferFrom()` all **require** `ledgerCanisterId`. Omitting it causes a runtime error. 3. **Forgetting to register prompts on the signer side.** The signer returns error 501 (`PERMISSIONS_PROMPT_NOT_REGISTERED`) if a request arrives and no prompt handler is registered for it. Register all four prompts (`ICRC25_REQUEST_PERMISSIONS`, `ICRC27_ACCOUNTS`, `ICRC21_CALL_CONSENT_MESSAGE`, `ICRC49_CALL_CANISTER`) before the signer can handle any relying party traffic. 4. **Sending concurrent requests to the signer.** The signer processes one request at a time. A second request while one is in-flight returns error 503 (`BUSY`). Serialize your calls — wait for each response before sending the next. Read-only methods (`icrc29_status`, `icrc25_supported_standards`) are exempt. 5. **Assuming `connect()` = authenticated session.** `connect()` only opens a `postMessage` channel. The user has not pre-authorized anything. Permissions default to `ask_on_use` — the signer will prompt the user on first use of each method. Call `requestPermissionsNotGranted()` after connecting to request all permissions upfront in a single prompt instead of per-method prompts. 6. **Not handling the consent message state machine.** The `ICRC21_CALL_CONSENT_MESSAGE` prompt fires multiple times with different statuses: `loading` → `result` | `error`. If you only handle `result`, the UI breaks on loading and error states. Always branch on `payload.status`. 7. **`sender` not matching `owner`.** The signer validates that `sender` in every `icrc49_call_canister` request matches the signer's `owner` identity. A mismatch returns error 502 (`SENDER_NOT_ALLOWED`). Always use the `owner` from `accounts()`. 8. **Not calling `disconnect()`.** Both `Signer.disconnect()` and `wallet.disconnect()` must be called on clean-up. Forgetting this leaks event listeners and leaves popup windows open. 9. **Ignoring permission expiration.** Permissions default to a 7-day validity period. After expiry, they silently revert to `ask_on_use`. Don't cache permission state client-side beyond a session. 10. **Auto-triggering signing on connect.** Never fire a canister call immediately after `connect()`. Let the user initiate the action. The signer is designed for intentional, user-driven operations. ## Implementation ### Import Map ```typescript // Constants, errors, and types — from main entry point import { ICRC25_REQUEST_PERMISSIONS, ICRC25_PERMISSION_GRANTED, ICRC25_PERMISSION_DENIED, ICRC25_PERMISSION_ASK_ON_USE, ICRC27_ACCOUNTS, ICRC21_CALL_CONSENT_MESSAGE, ICRC49_CALL_CANISTER, DEFAULT_SIGNER_WINDOW_CENTER, DEFAULT_SIGNER_WINDOW_TOP_RIGHT, RelyingPartyResponseError, RelyingPartyDisconnectedError } from '@dfinity/oisy-wallet-signer'; import type { PermissionsPromptPayload, AccountsPromptPayload, ConsentMessagePromptPayload, CallCanisterPromptPayload, IcrcAccounts, SignerOptions, RelyingPartyOptions } from '@dfinity/oisy-wallet-signer'; // Classes — from dedicated subpaths import {Signer} from '@dfinity/oisy-wallet-signer/signer'; import {RelyingParty} from '@dfinity/oisy-wallet-signer/relying-party'; import {IcpWallet} from '@dfinity/oisy-wallet-signer/icp-wallet'; import {IcrcWallet} from '@dfinity/oisy-wallet-signer/icrc-wallet'; ``` ### dApp Side (Relying Party) #### Choosing the Right Class | Class | Use for | | -------------- | ---------------------------------------------------------------------------- | | `IcpWallet` | ICP ledger operations — `ledgerCanisterId` optional (defaults to ICP ledger) | | `IcrcWallet` | Any ICRC ledger — `ledgerCanisterId` **required** | | `RelyingParty` | Low-level custom canister calls via protected `call()` | #### Connect, Permissions, Accounts All wallet operations are async. Wrap them in functions — do not use top-level `await`, which fails with Vite's default `es2020` build target. ```typescript // Wrapping in an async function avoids top-level await, which requires // build.target >= es2022. This works with any bundler target. async function connectWallet() { const wallet = await IcrcWallet.connect({ url: 'https://your-wallet.example.com/sign', // URL of the wallet implementing the signer host: 'https://icp-api.io', windowOptions: {width: 576, height: 625, position: 'center'}, connectionOptions: {timeoutInMilliseconds: 120_000}, onDisconnect: () => { /* wallet popup closed */ } }); const {allPermissionsGranted} = await wallet.requestPermissionsNotGranted(); const accounts = await wallet.accounts(); const {owner} = accounts[0]; return {wallet, owner}; } ``` #### IcpWallet — ICP Transfers and Approvals Uses `{owner, request}` — no `ledgerCanisterId` needed. ```typescript async function icpWalletTransfers() { const wallet = await IcpWallet.connect({url: 'https://your-wallet.example.com/sign'}); const accounts = await wallet.accounts(); const {owner} = accounts[0]; await wallet.icrc1Transfer({ owner, request: {to: {owner: recipientPrincipal, subaccount: []}, amount: 100_000_000n} }); await wallet.icrc2Approve({ owner, request: {spender: {owner: spenderPrincipal, subaccount: []}, amount: 500_000_000n} }); } ``` #### IcrcWallet — Any ICRC Ledger Uses `{owner, ledgerCanisterId, params}` — `ledgerCanisterId` is **required**. ```typescript async function icrcWalletTransfers() { const wallet = await IcrcWallet.connect({url: 'https://your-wallet.example.com/sign'}); const accounts = await wallet.accounts(); const {owner} = accounts[0]; await wallet.transfer({ owner, ledgerCanisterId: 'mxzaz-hqaaa-aaaar-qaada-cai', params: {to: {owner: recipientPrincipal, subaccount: []}, amount: 1_000_000n} }); await wallet.approve({ owner, ledgerCanisterId: 'mxzaz-hqaaa-aaaar-qaada-cai', params: {spender: {owner: spenderPrincipal, subaccount: []}, amount: 5_000_000n} }); await wallet.transferFrom({ owner, ledgerCanisterId: 'mxzaz-hqaaa-aaaar-qaada-cai', params: {from: {owner: fromPrincipal, subaccount: []}, to: {owner: toPrincipal, subaccount: []}, amount: 1_000_000n} }); } ``` #### Query Methods and Disconnect ```typescript async function queryAndDisconnect(wallet: IcrcWallet) { const standards = await wallet.supportedStandards(); const currentPermissions = await wallet.permissions(); await wallet.disconnect(); } ``` #### Error Handling (dApp Side) ```typescript async function safeTransfer(wallet: IcrcWallet) { try { await wallet.transfer({...}); } catch (err) { if (err instanceof RelyingPartyResponseError) { switch (err.code) { case 3000: /* PERMISSION_NOT_GRANTED */ break; case 3001: /* ACTION_ABORTED — user rejected */ break; case 4000: /* NETWORK_ERROR */ break; } } if (err instanceof RelyingPartyDisconnectedError) { /* popup closed unexpectedly */ } } } ``` ### Wallet Side (Signer) #### Initialise and Register All Prompts ```typescript const signer = Signer.init({ owner: identity, host: 'https://icp-api.io', sessionOptions: { sessionPermissionExpirationInMilliseconds: 7 * 24 * 60 * 60 * 1000 } }); signer.register({ method: ICRC25_REQUEST_PERMISSIONS, prompt: ({requestedScopes, confirm, origin}: PermissionsPromptPayload) => { confirm( requestedScopes.map(({scope}) => ({ scope, state: userApproved ? ICRC25_PERMISSION_GRANTED : ICRC25_PERMISSION_DENIED })) ); } }); signer.register({ method: ICRC27_ACCOUNTS, prompt: ({approve, reject, origin}: AccountsPromptPayload) => { approve([{owner: identity.getPrincipal().toText()}]); } }); signer.register({ method: ICRC21_CALL_CONSENT_MESSAGE, prompt: (payload: ConsentMessagePromptPayload) => { if (payload.status === 'loading') { // show spinner } else if (payload.status === 'result') { // payload.consentInfo: { Ok: ... } (from canister) or { Warn: ... } (signer-generated fallback) // show consent UI, then: payload.approve() or payload.reject() } else if (payload.status === 'error') { // show error, optionally payload.details } } }); signer.register({ method: ICRC49_CALL_CANISTER, prompt: (payload: CallCanisterPromptPayload) => { if (payload.status === 'executing') { /* show progress */ } else if (payload.status === 'result') { /* call succeeded */ } else if (payload.status === 'error') { /* call failed */ } } }); ``` #### Consent Message: `Ok` vs `Warn` - `{ Ok: consentInfo }` — canister implements ICRC-21; message is canister-verified - `{ Warn: { consentInfo, canisterId, method, arg } }` — signer generated a fallback (for `icrc1_transfer`, `icrc2_approve`, `icrc2_transfer_from`) Always distinguish these in the UI — warn the user when the message is signer-generated. #### Disconnect ```typescript signer.disconnect(); ``` ### Error Code Reference | Code | Name | Meaning | | ---- | ----------------------------------- | --------------------------- | | 500 | `ORIGIN_ERROR` | Origin mismatch | | 501 | `PERMISSIONS_PROMPT_NOT_REGISTERED` | Missing prompt handler | | 502 | `SENDER_NOT_ALLOWED` | `sender` ≠ `owner` | | 503 | `BUSY` | Concurrent request rejected | | 504 | `NOT_INITIALIZED` | Owner identity not set | | 1000 | `GENERIC_ERROR` | Catch-all | | 2000 | `REQUEST_NOT_SUPPORTED` | Method not supported | | 3000 | `PERMISSION_NOT_GRANTED` | Permission denied | | 3001 | `ACTION_ABORTED` | User cancelled | | 4000 | `NETWORK_ERROR` | IC call failure | ### Permission States | State | Constant | Behavior | | ---------- | ------------------------------ | --------------------------------- | | Granted | `ICRC25_PERMISSION_GRANTED` | Proceeds without prompting | | Denied | `ICRC25_PERMISSION_DENIED` | Rejected immediately (error 3000) | | Ask on use | `ICRC25_PERMISSION_ASK_ON_USE` | Prompts user on access (default) | Permissions stored in `localStorage` as `oisy_signer_{origin}_{owner}` with timestamps. Default validity: 7 days. ## Deploy & Test ### Local Development — Your Own Signer If you are building both the dApp and the wallet/signer, start a local network and pass `host` to both sides: ```bash icp network start -d ``` ```typescript // dApp side — point to your local wallet's /sign route async function connectLocalWallet() { const wallet = await IcrcWallet.connect({ url: 'http://localhost:5174/sign', host: 'http://localhost:8000' }); return wallet; } // Wallet/signer side — same local network host const signer = Signer.init({ owner: identity, host: 'http://localhost:8000' }); ``` ### Local Development — Using the Pseudo Wallet Signer If you are building a dApp (relying party) and need a signer to test against locally, the library provides a pseudo wallet signer in its demo: ```bash git clone https://github.com/dfinity/oisy-wallet-signer cd oisy-wallet-signer npm ci cd demo npm ci npm run sync:all npm run dev:wallet # starts the pseudo wallet on port 5174 ``` Then connect from your dApp: ```typescript async function connectPseudoWallet() { const wallet = await IcpWallet.connect({ url: 'http://localhost:5174/sign', host: 'http://localhost:8000' // match your local network port }); return wallet; } ``` ### Mainnet On mainnet, point to the wallet's production signer URL and omit `host` (defaults to `https://icp-api.io`): ```typescript async function connectMainnetWallet() { const wallet = await IcpWallet.connect({ url: 'https://your-wallet.example.com/sign' }); return wallet; } ``` ## Expected Behavior ### Connection - `connect()` resolves with a wallet instance; throws `RelyingPartyDisconnectedError` on timeout - `wallet.supportedStandards()` returns an array containing at least ICRC-21, ICRC-25, ICRC-27, ICRC-29, ICRC-49 ### Permissions - `requestPermissionsNotGranted()` triggers the signer's permissions prompt - After approval, `wallet.permissions()` returns scopes with state `granted` - A second call returns `{allPermissionsGranted: true}` without prompting again ### Accounts - `wallet.accounts()` returns at least one `{owner: string}` (principal as text) - The returned `owner` matches the signer's identity principal ### Transfers and Approvals - `icrc1Transfer()` / `transfer()`, `icrc2Approve()` / `approve()`, and `transferFrom()` all resolve with a `bigint` block index - Each triggers the consent message prompt on the signer before execution