Skip to content

Latest commit

 

History

History
548 lines (411 loc) · 23.3 KB

File metadata and controls

548 lines (411 loc) · 23.3 KB

Agent Instructions

Guidelines for AI agents (Claude, Codex, Cursor, Copilot, etc.) working in this repository.

Skills — fetch before working

ICP skills are live reference documents maintained by DFINITY. Always fetch the relevant skills before making changes — do not rely on cached or training-data versions.

Index: https://skills.internetcomputer.org/.well-known/skills/index.json

Fetch the skill content at its url field. Skills relevant to this repo:

Task Skill to fetch
Any ICP project work, icp.yaml, canister lifecycle icp-cli
Migrating an example from dfx to icp-cli icp-cli + its references/dfx-migration.md file
Motoko canister code motoko
mops.toml, toolchain pinning, moc flags mops-cli
Internet Identity integration internet-identity
Frontend asset canister asset-canister

Skills take precedence over general knowledge when both cover the same topic.


Repository overview

This repo contains canonical ICP examples, each available in both Motoko and Rust. Every example lives under two sibling directories:

motoko/<example_name>/
rust/<example_name>/

Both implement the same Candid interface so readers can compare language implementations side by side.

Reference examples to study first:

  • hello_world — canonical full-stack example (Motoko backend + Vite frontend); use as the structural template for new examples
  • who_am_i — Internet Identity integration; reference for II-authenticated examples

Toolchain


Canonical example structure

Follow the hello_world layout. New examples and migrations should use this pattern:

<language>/<example_name>/
├── icp.yaml                  # canister definitions (icp-cli project file)
├── test.sh                   # executable bash test script
├── README.md
├── package.json              # npm workspaces root pointing to frontend/
├── mops.toml                 # Motoko only
├── Cargo.toml                # Rust only (workspace)
├── rust-toolchain.toml       # Rust only
├── backend/
│   ├── app.mo or lib.rs      # canister entry point
│   └── backend.did           # Candid interface (source of truth)
└── frontend/
    ├── index.html
    ├── package.json
    ├── vite.config.js
    ├── src/
    │   ├── actor.js          # icp-sdk actor wiring
    │   ├── App.jsx
    │   └── main.jsx
    └── dist/                 # build output (gitignored, rebuilt by icp deploy)

Note: who_am_i uses src/backend/ and src/frontend/ for historical reasons. New examples and migrations should use the flat backend/ / frontend/ layout above.

What NOT to include

  • dfx.json — dfx project file, not used with icp-cli
  • BUILD.md — ICP Ninja artifact
  • .dfx/ — dfx state directory
  • src/bindings/ — auto-generated by the bindgen Vite plugin, must be gitignored
  • Committed dist/ output — built by icp deploy; never commit pre-built assets

What NOT to gitignore

  • .icp/data/always commit this. It holds canister ID mappings (e.g. local.ids.json) that map canister names to on-chain principals. Losing this means losing the link between the example's code and its deployed canisters.

The root .gitignore already has **/.icp/cache/ which correctly ignores only the ephemeral build cache. Do not add .icp/ to per-example .gitignore files — it would incorrectly hide data/ as well.

When migrating an example, also remove any existing .dfx/ entries from per-example .gitignore files — dfx is no longer used and these entries are dead weight.


icp.yaml

Motoko

networks:                          # omit if no Internet Identity needed
  - name: local
    mode: managed
    ii: true

canisters:
  - name: backend
    recipe:
      type: "@dfinity/motoko@v5.0.0"

  - name: frontend
    recipe:
      type: "@dfinity/asset-canister@v2.2.1"
      configuration:
        dir: frontend/dist
        build:
          - npm install --prefix frontend
          - npm run build --prefix frontend

Rust

canisters:
  - name: backend
    recipe:
      type: "@dfinity/rust@v3.2.0"
      configuration:
        package: backend
        candid: backend/backend.did   # omit for backend-only examples (no frontend)

  - name: frontend
    recipe:
      type: "@dfinity/asset-canister@v2.2.1"
      configuration:
        dir: frontend/dist
        build:
          - npm install --prefix frontend
          - npm run build --prefix frontend
  • With candid: specified: the recipe reads the committed .did file and embeds it as WASM metadata (no candid-extractor needed).
  • Without candid:: candid-extractor extracts the interface directly from the compiled WASM. For backend-only examples, omit candid: and do not commit backend.did.

Canister names are always backend and frontend. Never use names like <example>_backend, internet_identity_app_backend, etc.

Per-environment canister configuration

When an example interacts with a well-known external canister whose principal differs by environment (e.g. the ICP ledger mainnet vs. TESTICP on staging), use the environments block to inject the right value per deployment rather than hardcoding it:

canisters:
  - name: backend
    recipe:
      type: "@dfinity/rust@v3.3.0"

environments:
  - name: local
    network: local
    settings:
      backend:
        environment_variables:
          ICP_LEDGER_CANISTER_ID: "ryjl3-tyaaa-aaaaa-aaaba-cai"

  - name: staging
    network: ic
    settings:
      backend:
        environment_variables:
          ICP_LEDGER_CANISTER_ID: "xafvr-biaaa-aaaai-aql5q-cai"   # TESTICP

  - name: production
    network: ic
    settings:
      backend:
        environment_variables:
          ICP_LEDGER_CANISTER_ID: "ryjl3-tyaaa-aaaaa-aaaba-cai"

icp-cli applies these as canister settings at deploy time. Read them at runtime via ic_cdk::api::env_var_value("ICP_LEDGER_CANISTER_ID") (Rust) or Runtime.envVar<system>("ICP_LEDGER_CANISTER_ID") (Motoko) — not via env!() or std::env::var().

When to apply this pattern: judge whether staging/production environments make sense for the specific example. Apply it when the example hardcodes an external canister principal that has a test counterpart (TESTICP, testnet URLs, staging governance canisters, etc.). Skip it for self-contained examples that don't call external canisters.

Motoko naming conventions

  • Top-level actor: name it after its logical role, not generically — e.g. actor TodoList, actor CanisterFactory, not actor Backend.
  • Supporting module files: use PascalCase matching the type they export — e.g. Counter.mo exporting actor class Counter, Types.mo exporting type X.
  • Entry point file: always backend/app.mo regardless of actor name.

mops.toml (Motoko)

[toolchain]
moc = "1.9.0"

[dependencies]
core = "2.5.0"

[moc]
# M0236: use context dot notation
# M0237: redundant explicit implicit arguments
# M0223: redundant type instantiation
args = ["--default-persistent-actors", "-W=M0236,M0237,M0223"]

[canisters.backend]
main = "backend/app.mo"
candid = "backend/backend.did"   # omit for backend-only examples (no frontend)

[canisters.<name>] replaces the main, candid, and args fields that were previously in icp.yaml. The @dfinity/motoko@v5.0.0 recipe reads this section directly, so the Motoko canister needs no configuration: block in icp.yaml — the <name> must match the canister name in icp.yaml.

After writing or editing Motoko source files, always run:

mops check          # type-check all canister entry points
mops check --fix    # auto-fix style warnings (M0236 dot notation, M0237, M0223)

Both commands must pass with no errors before committing.

--default-persistent-actors makes the main actor persistent by default, so the persistent keyword can be omitted on the top-level actor declaration. However, persistent actor class declarations that hold mutable state must still carry the persistent keyword explicitly — the flag does not propagate into actor class sub-WASMs.

Management canister (Motoko)

Use the mo:ic mops package instead of ic:aaaaa-aa or inline actor("aaaaa-aa") definitions:

[dependencies]
ic = "4.0.0"
import { ic } "mo:ic";

Breaking change in mo:ic v4.0.0: CanisterSettings gained two new required fields. Always include environment_variables = null and snapshot_visibility = null in settings records passed to ic.create_canister.


Cargo.toml (Rust)

Root workspace:

[workspace]
members = ["backend"]
resolver = "2"

backend/Cargo.toml:

[package]
name = "backend"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
candid = "0.10"
ic-cdk = "0.20"

Always use ic-cdk = "0.20" unless a dependency forces a lower version. When using ic-ledger-types, use "0.16" (requires ic-cdk = "^0.19") — version 0.15 pins ic-cdk = "^0.18" which conflicts with 0.20 via an ic-cdk-executor links constraint.

ic_cdk::export_candid!() is required at the end of every Rust canister lib.rs. Without it, candid-extractor cannot find the get_candid_pointer export and the build fails.

CI image: use ghcr.io/dfinity/icp-dev-env-rust:1.0.1 or later. Earlier images bundle candid-extractor 0.1.4 which fails with Error: unknown import: ic0::cost_call for any WASM compiled with ic-cdk ≥ 0.19.

Environment variables in Rust canisters

Use ic_cdk::api::env_var_value("VAR_NAME") to read canister environment variables at runtime. These are canister settings applied by icp-cli at deploy time, not WASM metadata. This is the Rust equivalent of Runtime.envVar<system> in Motoko:

fn ledger_principal() -> Principal {
    Principal::from_text(ic_cdk::api::env_var_value("ICP_LEDGER_CANISTER_ID"))
        .expect("invalid ICP_LEDGER_CANISTER_ID")
}

Do not use env!() (compile-time macro, fails if the var is not set during cargo build) or std::env::var() (no OS environment in WASM).

Management canister (Rust)

Use the ic-cdk-management-canister crate instead of ic_cdk::api::management_canister (removed in ic-cdk 0.17+):

[dependencies]
ic-cdk-management-canister = "0.1.1"
use ic_cdk_management_canister::raw_rand;

#[ic_cdk::update]
async fn get_randomness() -> Vec<u8> {
    raw_rand().await.expect("raw_rand failed")
}

test.sh

Every example must have a test.sh bash script that exercises the deployed canister via icp canister call. Write a numbered test for every public function. Include state-assertion tests for mutating operations: call the mutating function, then call a read function and assert the stored value changed.

Using test.sh instead of Makefile avoids Make-specific syntax pitfalls ($$, \\ continuations, @ prefix) and works natively in Git Bash on Windows.

#!/usr/bin/env bash
set -e

echo "=== Test 1: <description of what is tested> ==="
result=$(icp canister call backend <method> '<args>')
echo "$result"
echo "$result" | grep -q '<expected>' && echo "PASS" || (echo "FAIL" && exit 1)

echo "=== Test 2: <mutating operation returns expected value> ==="
result=$(icp canister call backend <mutating_method> '<args>')
echo "$result"
echo "$result" | grep -q '<expected_return>' && echo "PASS" || (echo "FAIL" && exit 1)

echo "=== Test 3: <state persisted after mutation> ==="
result=$(icp canister call backend <read_method> '()')
echo "$result"
echo "$result" | grep -q '<expected_persisted_value>' && echo "PASS" || (echo "FAIL" && exit 1)
  • Tests must call the backend canister by that name.
  • Always pass explicit Candid args, including '()' for zero-argument calls — omitting args triggers an interactive prompt that blocks CI.
  • Use grep -q to assert on output content.
  • Number each test (=== Test N: ... ===) so CI logs are easy to scan.
  • For examples that create child canisters, capture the returned principal and call the child directly by ID.
  • Query functions: always pass --query to icp canister call for public query func and public composite query func methods.
  • Balance checks: use delta-based assertions (record before, act, assert delta) rather than checking absolute values — this keeps tests idempotent across re-runs regardless of prior state:
before=$(icp canister call backend get_balance '()' | grep -oE '[0-9_]+' | tr -d '_' | head -1)
icp token transfer 1 "$account_hex"
after=$(icp canister call backend get_balance '()' | grep -oE '[0-9_]+' | tr -d '_' | head -1)
delta=$((after - before))
[ "$delta" -eq 100000000 ] && echo "PASS" || (echo "FAIL: expected +100000000 e8s" && exit 1)
  • Async/time-dependent behavior: if the observable result depends on timers, heartbeats, or polling, use a polling loop:
echo "=== Polling for <condition> (up to 60s) ==="
secs=0
while [ "$secs" -lt 60 ]; do
  result=$(icp canister call --query backend <method> '()')
  echo "$result"
  echo "$result" | grep -q '<expected>' && echo "PASS" && exit 0
  sleep 3
  secs=$((secs + 3))
done
echo "FAIL: condition not met within 60s"; exit 1
  • Canister settings: if the example requires non-default canister settings (e.g. wasm_memory_limit, wasm_memory_threshold), apply them at the top of test.sh before the tests:
icp canister settings update backend --<flag> <value> -f
  • If icp deploy --cycles 30t is required (see below), document icp canister top-up --amount 30t backend in the README so users can replenish cycles when needed. (30t is an example amount — adjust to the example's actual consumption.)

Devcontainer config

A single root devcontainer at .devcontainer/devcontainer.json covers local VS Code usage across all examples. It uses ghcr.io/dfinity/icp-dev-env-all:<version> (Motoko + Rust) with workspaceFolder set to /workspaces/examples. No per-example devcontainer configs exist — do not add them.


CI workflow

Copy .github/workflow-template.yml to .github/workflows/<example_name>.yml and fill in the placeholders. A single workflow file covers both language variants:

name: <example_name>

on:
  push:
    branches: [master]
  pull_request:
    paths:
      - motoko/<example_name>/**
      - rust/<example_name>/**
      - .github/workflows/<example_name>.yml

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  motoko-<example_name>:
    runs-on: ubuntu-24.04
    container: ghcr.io/dfinity/icp-dev-env-motoko:1.0.1
    env:
      ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
      - name: Deploy and test
        working-directory: motoko/<example_name>
        run: |
          icp network start -d
          icp deploy
          bash test.sh

  rust-<example_name>:
    runs-on: ubuntu-24.04
    container: ghcr.io/dfinity/icp-dev-env-rust:1.0.1
    env:
      ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
      - name: Deploy and test
        working-directory: rust/<example_name>
        run: |
          icp network start -d
          icp deploy
          bash test.sh
  • Linux only, no macOS runners.
  • No provision scripts — toolchain comes from the container image.
  • Always include the concurrency block to cancel superseded runs.
  • Pin the actions/checkout SHA and annotate it with the version tag.

When migrating only one language variant, only include the corresponding job. Do not add a stub for the other language — it will be added when that variant is migrated. Example: migrating motoko/hello_cycles → add only motoko-hello_cycles job; leave rust-hello_cycles for a separate PR.

Deleting old workflows during migration: the repo may contain legacy dfx-based workflow files (e.g. motoko-hello_cycles-example.yaml). When migrating an example, delete the old workflow file for the language being migrated. Do not delete workflow files for the other language:

# Migrating Motoko — delete only the Motoko legacy workflow:
git rm .github/workflows/motoko-<example_name>-example.yaml   # or .yml
# Do NOT touch rust-<example_name>-example.yml

README structure

Each example's README should follow this structure:

# <Example Title>

<2-3 sentences describing what the example demonstrates>

## Build and deploy from the command line

### Prerequisites
- Node.js
- icp-cli: `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm`
- ic-mops: `npm install -g ic-mops`

### Install
<git clone + cd>

### Deploy and test
<icp network start -d && icp deploy && bash test.sh && icp network stop>
If the example has a frontend with a Vite dev server: `npm run dev` (hot reload during frontend development)

> If tests fail with an out-of-cycles error, run `icp canister top-up --amount 30t backend` to replenish cycles and retry (`30t` is an example amount). Only relevant for examples that create child canisters.

## Updating the Candid interface
<instructions to regenerate backend.did>
Motoko: `$(mops toolchain bin moc) --idl -o backend/backend.did backend/app.mo`
Rust: `icp build backend && candid-extractor target/wasm32-unknown-unknown/release/backend.wasm > backend/backend.did`

## Security considerations and best practices
<standard disclaimer linking to https://docs.internetcomputer.org/guides/security/overview>
  • Security best practices URL: https://docs.internetcomputer.org/guides/security/overview
  • Each README links to its counterpart in the other language.
  • Backend-only examples: omit the ## Updating the Candid interface section — there is no frontend consuming the .did file, so regeneration instructions add no value.
  • If the original example has a frontend, the migration must include it. Never drop a frontend that existed in the dfx version — check dfx.json for "type": "assets" canisters. Port the frontend to Vite + @icp-sdk/bindgen following the hello_world template. If the original frontend contains important educational logic (e.g. certificate verification, custom cryptography), keep that logic intact and only update the canister interaction layer.
  • Preserve important domain knowledge: the original README may contain non-obvious details about how the example works (memory limits, fee calculations, network-specific behavior, address type explanations, etc.). Read the original README carefully before migrating and carry forward any content that helps a reader understand why the example is structured the way it is — not just how to deploy it. Do not silently drop explanatory paragraphs, even if they reference dfx commands that need updating.
  • Broken links: do not copy anchor links from the original README unless you have verified they resolve on the current docs site. When in doubt, link to the top-level page (e.g. the spec index) rather than a specific anchor.

Pending items (do not resolve prematurely)

Container images

Images are published at ghcr.io/dfinity/icp-dev-env-{motoko,rust,all}. Current pinned version: 1.0.1. All devcontainer configs and CI workflows reference the pinned tag. When a new release is cut, update the tag in:

  • .devcontainer/devcontainer.json
  • .github/workflows/*.yml

Source: https://github.com/dfinity/icp-dev-env


dfx → icp-cli migration checklist

When migrating an existing example:

Before you start: read the original README, Makefile, and any deploy scripts in full. Note any non-obvious configuration (canister settings, special deploy steps, memory limits, key names, etc.) that must be carried forward.

Code and configuration

  • Replace dfx.json with icp.yaml using the canonical structure above
  • Rename canisters to backend and frontend
  • Rename src/<old_name>/ to backend/ (or src/backend/ if keeping the src/ layout)
  • Rename src/<old_frontend>/ to frontend/ (or src/frontend/)
  • Rename .did file to backend.did
  • Update Cargo.toml package name to backend (Rust)
  • Update Cargo.lock package name entry (Rust)
  • Update workspace member path in root Cargo.toml (Rust)
  • Update vite.config.js: canister name, didFile path, env var names, remove dfx fallback
  • Update actor.js: import path, PUBLIC_CANISTER_ID:backend, CANISTER_ID_BACKEND
  • Update root package.json workspace path to frontend/
  • Update .gitignore bindings path to frontend/src/bindings/
  • Update mops.toml to current toolchain versions (Motoko)
  • Run mops check --fix in the example directory and commit any auto-fixes (Motoko)
  • If the example uses the management canister: add ic = "4.0.0" dependency and replace ic:aaaaa-aa / actor("aaaaa-aa") with import { ic } "mo:ic" (Motoko)
  • If the Rust example uses the management canister: add ic-cdk-management-canister = "0.1.1" dependency and replace ic_cdk::api::management_canister with the appropriate function from that crate
  • Judge whether per-environment configuration makes sense: if the example calls an external canister whose principal differs by environment (e.g. ICP ledger vs. TESTICP), add an environments block to icp.yaml. Skip for self-contained examples.
  • If the example creates child canisters: use icp deploy --cycles 30t in the CI workflow and README
  • If the example requires non-default canister settings (memory limits, freezing threshold, etc.): apply them via icp canister settings update at the top of test.sh
  • Delete dfx.json, BUILD.md, .dfx/, .env (dfx-generated)
  • Delete .devcontainer/ inside the example folder if one exists (only the repo-root devcontainer is kept)

test.sh

  • Add test.sh (executable, #!/usr/bin/env bash, set -e) with numbered tests
  • Use --query for all public query func and public composite query func calls
  • Use a polling loop for any behavior that depends on timers, heartbeats, or async system hooks
  • Use delta-based balance assertions (before/after) rather than absolute values for idempotency
  • For backend-only examples, no ## Updating the Candid interface section is needed

CI workflow

  • Add CI workflow under .github/workflows/<example_name>.yml
  • Include only the job(s) for the language being migrated — do not add stubs for the other language
  • Delete the old dfx-based workflow for the language being migrated (e.g. motoko-<name>-example.yaml)
  • Do not delete workflow files for the other language variant — they will be handled in a separate PR

README

  • Update deploy instructions to use icp-cli
  • Preserve important domain-specific content from the original README (memory limits, fee behaviour, address type explanations, network-specific notes, etc.) — update the commands but keep the explanations
  • Omit ## Updating the Candid interface for backend-only examples (no frontend bindings)
  • Verify that all links resolve — do not copy anchors from the old README without checking them