From a0a05da2681e4fd4ff135bb7407f727d28e80daa Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 12 May 2026 16:00:38 +0200 Subject: [PATCH 1/7] docs: port missing best-practices content from portal (issue #241) Five gaps ported from dfinity/portal building-apps/best-practices/: - Extend app-architecture.md with canister-per-user pattern including the honest note that no successful end-to-end implementation exists yet and the misconception correction (per-subnet is more scalable) - New trust-in-canisters.md guide: two-question trust framework, Wasm hash verification, canister history, controller verification, trust spectrum (developer to multi-sig to SNS to black-holed), black hole canister caveat for self-reinstalling code - Extend data-persistence.mdx: Storage recommendations section with heap-vs-stable decision table, mo:core data structure guidance, Rust cautions, state backup, transaction history patterns - New pagination.md guide: cursor-based pagination for mutable datasets, handling deleted cursors, sort order stability - New troubleshooting.md guide: rewritten for icp CLI; latency diagnosis, compute allocation, CSP fix, Wasm import errors; dfx-era content excluded - Cross-links added in reproducible-builds.md and canister-control.md --- docs/concepts/app-architecture.md | 15 +- docs/guides/backends/data-persistence.mdx | 77 ++++++++ docs/guides/canister-calls/pagination.md | 166 ++++++++++++++++++ .../reproducible-builds.md | 1 + .../canister-management/troubleshooting.md | 165 +++++++++++++++++ .../canister-management/trust-in-canisters.md | 85 +++++++++ docs/guides/security/canister-control.md | 4 + 7 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 docs/guides/canister-calls/pagination.md create mode 100644 docs/guides/canister-management/troubleshooting.md create mode 100644 docs/guides/canister-management/trust-in-canisters.md diff --git a/docs/concepts/app-architecture.md b/docs/concepts/app-architecture.md index a7aa6d5..3fda25d 100644 --- a/docs/concepts/app-architecture.md +++ b/docs/concepts/app-architecture.md @@ -71,6 +71,18 @@ For maximum throughput, distribute canisters across multiple [subnets](network-o **Trade-offs:** cross-subnet calls have higher latency and bandwidth limits. You need to design data partitioning carefully. +### Canister-per-user + +Each user gets their own canister that they control. The main application canister orchestrates user canisters to implement the application's functionality. Since users control their canisters, they can install their own code, decide how to participate in the application, and determine what data to share with the main canister. + +**When to use:** only when user sovereignty over data is a core product requirement and you accept the significant development cost. + +**Things to know:** +- The main canister must treat every user canister as potentially malicious. Any code path that interacts with user canisters must assume adversarial behavior and be hardened against it. +- Development cost is very high. Handling all possible actions from potentially malicious user canisters requires expert knowledge of the ICP security and messaging model. +- **There is no known successful end-to-end implementation of the full canister-per-user vision.** A few projects have explored variations, but the architecture remains experimental. +- Common misconception: canister-per-user is not the most scalable pattern. Canister-per-subnet is more performant because it can utilize multiple subnets without the overhead of managing a large number of small canisters. + ## Data storage Canisters store data in heap memory during execution and can persist data across upgrades using [stable memory](orthogonal-persistence.md#stable-memory): there is no external database. Libraries provide familiar data-structure abstractions on top of raw stable memory: @@ -98,6 +110,7 @@ Start with a [single canister](#single-canister): it is the right choice for mos | Does the app have a web UI? | Add an [asset canister](#frontend-options) | Backend-only canister | | Do you need separation of concerns or hit platform limits? | [Canister-per-service](#canister-per-service) | Stay with a single canister | | Do you need to scale beyond one subnet? | [Canister-per-subnet](#canister-per-subnet) | Stay on one subnet | +| Is user sovereignty over data a core requirement and are you prepared for high dev cost? | [Canister-per-user](#canister-per-user) (experimental) | None of the above | Start with the simplest architecture that meets your requirements. You can always split a canister into multiple canisters later: it is much harder to merge canisters that were split prematurely. @@ -108,4 +121,4 @@ Start with the simplest architecture that meets your requirements. You can alway - [Asset canister](../guides/frontends/asset-canister.md): frontend deployment - [Canisters](canisters.md): canister internals - + diff --git a/docs/guides/backends/data-persistence.mdx b/docs/guides/backends/data-persistence.mdx index 43312ee..779dffa 100644 --- a/docs/guides/backends/data-persistence.mdx +++ b/docs/guides/backends/data-persistence.mdx @@ -571,11 +571,88 @@ icp canister call backend get_user '(0 : nat64)' If the count drops to 0 after upgrade, the data is not in stable memory. Review your storage declarations. +## Storage recommendations + +### Choose the right storage type + +| Memory type | Max size | Persists across upgrades | Best for | +|-------------|----------|--------------------------|----------| +| Heap | 4 GiB | No (Rust) / Yes (Motoko `persistent actor`) | Frequently accessed data, caches, ephemeral computation | +| Stable | 500 GiB | Yes | All important data, large datasets, anything that must survive upgrades | + +The practical rule: **use stable structures directly for any data that matters**. Avoid relying on `pre_upgrade` / `post_upgrade` hooks to serialize heap data to stable memory. Serializing large heap state during an upgrade can hit the instruction limit and trap, leaving the canister on the old code. Data in stable structures is already in stable memory from the first write — no serialization step required on upgrade. + +For Motoko, `persistent actor` makes all `let` and `var` declarations persistent automatically. There is no need to choose manually between heap and stable memory. + +### Language-specific recommendations + + + + +**Choose efficient data structures.** + +The `mo:core` library provides stable-friendly, performant data structures. Use these in preference to the legacy `mo:base` equivalents: + +| Use case | `mo:core` type | Replaces (`mo:base`) | +|----------|----------------|----------------------| +| Key-value map | `Map` | `HashMap`, `TrieMap`, `Trie`, `RBTree` | +| Dynamic sequence | `List` | `Buffer` | +| Double-ended queue | `Queue` | `Deque` | +| Ordered map | `pure/Map` | `OrderedMap` | +| Ordered set | `pure/Set` | `OrderedSet` | + +`Map` avoids the automatic resizing overhead that `HashMap` incurs on growth. `List` handles dynamic sequences without the fragile array-copy pattern of `Buffer`. + +**Prefer `Blob` over `[Nat8]` for binary data.** + +`Blob` is 4× more compact than `[Nat8]` and produces significantly less GC pressure. Use `Blob` for binary assets, cryptographic values, and anywhere you would send or receive `vec nat8` in Candid. Store large `Blob`s in stable memory. + +**Use `compacting-gc` for append-only workloads (classical persistence only).** + +If your canister grows the heap by appending data without frequent deletions, the `--compacting-gc` flag allows the GC to handle larger heaps and reduces the cost of copying large, stationary objects. Enable it in `icp.yaml` under canister build args. Note: `--compacting-gc` applies only to the legacy classical persistence mode (`--legacy-persistence`); it is not used with the default enhanced orthogonal persistence. + + + + +**Exercise caution with `Vec` and `String` in state serialization.** + +If you serialize/deserialize state that contains `Vec` or `String` values, Rust's memory layout requires copying each value during encoding and decoding. For large state, this increases the instruction cost significantly. Prefer `StableBTreeMap, ...>` (or a typed key) backed directly by stable memory over serializing heap collections on upgrade. + +**Use `ic-stable-structures` for all persistent state.** + +Put all important data in `StableBTreeMap`, `StableCell`, or `StableLog` from the start. This avoids the `pre_upgrade` serialization problem entirely. See [Implementing Storable for custom types](#store-data-durably) above for the correct pattern. + +For reference on effective Rust canister patterns, see [Effective Rust Canisters](https://mmapped.blog/posts/01-effective-rust-canisters.html) and [How to audit an Internet Computer canister](https://www.joachim-breitner.de/blog/788-How_to_audit_an_Internet_Computer_canister). + + + + +### Implement state backup mechanisms + +Even with stable memory, consider implementing explicit backup mechanisms for state that would be catastrophic to lose. This protects against: + +- Accidental reinstall (which wipes stable memory) +- Bugs in upgrade hooks that corrupt the stable layout +- Application-level errors that require rollback + +Common approaches include exporting a snapshot of canister state to a Blob that can be stored externally, or using canister [snapshots](../canister-management/snapshots.md) to checkpoint state before an upgrade. + +### Transaction history storage + +If your application needs to maintain a history of transactions or events, avoid storing unbounded logs in the same canister as your main application state. Options: + +- **Dedicated logging canister.** A separate canister that accepts append-only log entries reduces load on the main canister and keeps the history size from affecting upgrade cost. +- **`StableLog` (Rust).** For canisters that can accommodate history growth, `StableLog` from `ic-stable-structures` provides an append-only log directly in stable memory. +- **External history services.** Services like [CAP](https://cap.ooo/) maintain transaction provenance records that integrate with explorers and wallets, which is useful for token standards compliance. + +Be aware that inter-canister calls to a logging service add latency and cycle cost. Size the logging approach to the transaction volume you expect. + ## Related - [Orthogonal Persistence](../../concepts/orthogonal-persistence.md): conceptual explanation of heap vs. stable memory - [Canister Lifecycle](../canister-management/lifecycle.md#what-happens-during-an-upgrade): upgrade hooks and canister lifecycle - [Stable Structures (Rust)](../../languages/rust/stable-structures.md): deep dive into `ic-stable-structures` +- [Canister snapshots](../canister-management/snapshots.md): checkpoint canister state before upgrades - [Motoko](../../languages/motoko/index.md): Motoko language overview and persistence model {/* Upstream: informed by dfinity/portal docs/building-apps/canister-management/storage.mdx, docs/building-apps/best-practices/storage.mdx, docs/building-apps/best-practices/idempotency.mdx */} diff --git a/docs/guides/canister-calls/pagination.md b/docs/guides/canister-calls/pagination.md new file mode 100644 index 0000000..5ebff1c --- /dev/null +++ b/docs/guides/canister-calls/pagination.md @@ -0,0 +1,166 @@ +--- +title: "Paginating query results" +description: "How to implement reliable pagination for canister query methods, including cursor-based patterns for mutable datasets" +--- + +Many canisters expose query methods that return lists of items: messages, transactions, tokens, users. When the list grows large, returning all items in a single response is impractical. Pagination splits results into pages, but the approach matters: a naive offset-based implementation produces incorrect results as soon as the underlying dataset changes. + +## The problem with offset-based pagination + +The simplest pagination approach passes an `offset` (number of items to skip) and a `limit` (maximum items to return). This works correctly when the dataset is immutable, but breaks as soon as items are added or removed between pages. + +**Example:** a user fetches page 1 (items 0-9). Before they fetch page 2, a new item is inserted at position 0. Page 2 now returns items 10-19, but item 9 has shifted to position 10 and is returned again. Item 0 from the original ordering is never seen. + +This is a common source of bugs in applications that allow concurrent writes. + +## Cursor-based pagination + +Cursor-based pagination identifies the last item the caller received, rather than its position in the list. The caller passes a cursor (typically the ID or key of the last item they received), and the canister returns the next batch of items that come after that cursor. + +Because the cursor is tied to an item identity rather than a position, insertions and deletions before the cursor position do not affect the correctness of subsequent pages. + +### Motoko example + +```motoko +import Map "mo:core/Map"; +import Nat "mo:core/Nat"; +import Text "mo:core/Text"; +import Iter "mo:core/Iter"; +import Array "mo:core/Array"; + +persistent actor { + + type Item = { id : Nat; name : Text }; + type Page = { items : [Item]; nextCursor : ?Nat }; + + var nextId : Nat = 0; + let items = Map.empty(); + + public func insert(name : Text) : async Nat { + let id = nextId; + Map.add(items, Nat.compare, id, { id; name }); + nextId += 1; + id + }; + + // Returns up to `limit` items with IDs strictly greater than `afterId`. + // Pass `null` for `afterId` to start from the beginning. + // Returns `nextCursor = null` when there are no more items. + public query func listItems(afterId : ?Nat, limit : Nat) : async Page { + let threshold = switch afterId { case null 0; case (?n) n + 1 }; + var collected : [Item] = []; + var count = 0; + label scan for ((id, item) in Map.entries(items, Nat.compare)) { + if (id < threshold) continue scan; + if (count >= limit) break scan; + collected := Array.append(collected, [item]); + count += 1; + }; + let nextCursor = if (count < limit) null else ?(collected[count - 1].id); + { items = collected; nextCursor } + }; + +} +``` + +### Rust example + +```rust +use ic_stable_structures::{StableBTreeMap, memory_manager::{MemoryId, MemoryManager, VirtualMemory}, DefaultMemoryImpl}; +use ic_cdk::{query, update}; +use candid::{CandidType, Deserialize}; +use std::cell::RefCell; + +type Memory = VirtualMemory; + +#[derive(CandidType, Deserialize, Clone)] +struct Item { + id: u64, + name: String, +} + +#[derive(CandidType)] +struct Page { + items: Vec, + next_cursor: Option, +} + +thread_local! { + static MEMORY_MANAGER: RefCell> = + RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); + + static ITEMS: RefCell> = + RefCell::new(StableBTreeMap::init( + MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))) + )); + + static NEXT_ID: RefCell = RefCell::new(0); +} + +#[update] +fn insert(name: String) -> u64 { + let id = NEXT_ID.with(|n| { + let current = *n.borrow(); + *n.borrow_mut() = current + 1; + current + }); + ITEMS.with(|items| items.borrow_mut().insert(id, Item { id, name })); + id +} + +/// Returns up to `limit` items with IDs strictly greater than `after_id`. +/// Pass `None` for `after_id` to start from the beginning. +/// Returns `next_cursor = None` when there are no more items. +#[query] +fn list_items(after_id: Option, limit: u64) -> Page { + let start = after_id.map(|id| id + 1).unwrap_or(0); + let mut result = Vec::new(); + + ITEMS.with(|items| { + for (_, item) in items.borrow().range(start..) { + if result.len() as u64 >= limit { + break; + } + result.push(item.clone()); + } + }); + + let next_cursor = if result.len() as u64 < limit { + None + } else { + result.last().map(|item| item.id) + }; + + Page { items: result, next_cursor } +} + +ic_cdk::export_candid!(); +``` + +## Handling deleted cursor items + +When a caller resumes pagination using a cursor that no longer exists (the item was deleted), the canister should return items that come after where the cursor item would have been, based on sort order. In both examples above, the cursor is a monotonically increasing integer ID. Because deleted IDs are never reused, a query with `after_id = 42` will always return items with IDs greater than 42, even if ID 42 no longer exists. + +If your dataset uses non-integer cursors or allows ID reuse, you need to handle this case explicitly in your query method. Return the next batch that would logically follow the cursor position in the current sort order. + +## Sort order stability + +Cursor-based pagination requires a consistent sort order. If the sort order can change between pages (for example, items are re-ranked by score while the user is paginating), cursor-based pagination can still produce gaps or duplicates. + +For datasets with a stable natural sort order (by insertion time, by monotonic ID, or by an immutable attribute), cursor pagination is reliable. For datasets sorted by frequently changing attributes, consider whether pagination is the right interface at all, or whether the caller should re-fetch from the beginning when the sort order changes. + +## Cycle cost considerations + +Each call to a query method on a canister on the IC mainnet costs cycles. When paginating a large dataset, the total cycle cost is proportional to the number of pages fetched. If callers frequently paginate through the entire dataset, consider: + +- Increasing the page size to reduce the number of round trips +- Caching recent results at the frontend layer +- Exposing a bulk export method for callers that need the full dataset + +## Related + +- [Inter-canister calls](./inter-canister-calls.md): calling query methods from other canisters +- [Calling from clients](./calling-from-clients.md): making query calls from a browser or CLI +- [Data persistence](../backends/data-persistence.md): storage patterns for canister state + + diff --git a/docs/guides/canister-management/reproducible-builds.md b/docs/guides/canister-management/reproducible-builds.md index c8ff89e..0dffc08 100644 --- a/docs/guides/canister-management/reproducible-builds.md +++ b/docs/guides/canister-management/reproducible-builds.md @@ -264,5 +264,6 @@ Maintaining a reproducible build over years requires more than getting it workin - [Canister lifecycle](lifecycle.md): deploy and upgrade workflow - [Canister settings](settings.md): configure controllers and make canisters immutable - [Cycles management](cycles-management.md): top up canisters before long-term deployment +- [Trust in canisters](trust-in-canisters.md): how users can use reproducible build verification to assess whether a canister is safe to interact with diff --git a/docs/guides/canister-management/troubleshooting.md b/docs/guides/canister-management/troubleshooting.md new file mode 100644 index 0000000..4ed81e9 --- /dev/null +++ b/docs/guides/canister-management/troubleshooting.md @@ -0,0 +1,165 @@ +--- +title: "Troubleshooting" +description: "Diagnose and resolve common issues: latency problems, frontend errors, Wasm build failures, and security policy warnings" +--- + +This guide covers common issues encountered when developing and deploying canisters on ICP. For language-specific issues, see the [Motoko](../../languages/motoko/index.md) and [Rust](../../languages/rust/index.md) language docs. + +## Problem: High query or update call latency + +On subnets with low load, query calls return in approximately 100 milliseconds and update calls complete in approximately 2 seconds. If your application experiences higher latency than this, the subnet may be under load or the canister may need tuning. + +### Identify your canister's subnet load + +1. Find your canister's subnet on the [ICP dashboard](https://dashboard.internetcomputer.org/canisters) by searching for the canister ID. +2. Navigate to the subnet details and check the "Million Instructions Executed Per Second" metric. +3. Compare this to other subnets. If your subnet consistently shows high instruction throughput relative to others, it may be a source of latency. + +You can also retrieve subnet metrics programmatically using an [HTTPS outcall](../../guides/backends/https-outcalls.md) from a canister to the system state tree, which includes canister count and subnet state. + +### Consider compute allocation + +A compute allocation of 1% guarantees your canister is scheduled for execution in at least 1 out of every 100 consensus rounds, which prevents latency spikes caused by competing canisters on the same subnet. + +Set compute allocation in `icp.yaml`: + +```yaml +canisters: + backend: + compute_allocation: 1 +``` + +Or update an existing canister: + +```bash +icp canister settings update backend --compute-allocation 1 -e ic +``` + +Note that compute allocation incurs a rental fee regardless of actual canister activity. See [Canister settings](./settings.md#compute-allocation) for cost details. + +### Use query calls instead of update calls where appropriate + +Query calls skip consensus and return in milliseconds. If a method only reads state and the data does not need to be tamperproof, use a `query` method instead of an update method. + +For applications that require tamperproof reads (for example, a frontend that displays financial data), use certified variables instead of reducing to basic query calls. See [Certified variables](../backends/certified-variables.md) for how to serve verifiable query responses. + +### Avoid unnecessary system API calls in queries + +In query calls, avoid calling `balance()` and `time()` unless they are required for the response. These system API calls add overhead on every invocation. + +### Retrieve boundary node information for support escalation + +If latency problems persist and you need to escalate to DFINITY, include the boundary node address and request ID: + +1. Open your browser's developer tools and go to the **Network** tab. +2. Trigger a call to your canister. +3. Find the request in the network log. The boundary node IP address appears as the **Remote Address**. +4. The request ID appears as the `X-Request-Id` response header. + +Include both values when reporting latency issues. + +## Problem: Latency when reading data + +Query calls that read from stable memory are slower than those that read from heap memory. If a hot query path reads frequently accessed values from stable memory, consider caching those values in heap memory as a `transient` variable (Motoko) or a `thread_local!` `RefCell` (Rust). + +This is a trade-off: cached heap values are lost on upgrade and must be re-initialized in `post_upgrade`. Use caching only for data that is expensive to read repeatedly and safe to reconstruct. + +## Problem: Slow inter-canister calls + +Inter-canister calls use async messaging and incur at least one additional consensus round of latency. If an inter-canister call is made on the hot path of a query, this makes the query as slow as an update call. + +Design considerations: + +- **Skip the inter-canister call when possible.** If data from another canister can be cached locally and refreshed on a schedule via a timer, avoid the synchronous call entirely. +- **Move the call off the critical path.** If the result of an inter-canister call is not needed to return an immediate response, trigger it asynchronously and return results via a subsequent query. + +For patterns around bounded and unbounded inter-canister calls, see [Inter-canister calls](../canister-calls/inter-canister-calls.md). + +## Problem: Frontend shows a blank screen with "Failed to load resource" + +A frontend deployed to the mainnet returns a blank screen and the browser console shows "Failed to load resource" errors. + +**Check for client-side firewall or proxy interference.** Some corporate firewalls and browser extensions block requests to `*.icp0.io` or `*.ic0.app` domains. If the frontend loads on a different network, a firewall or proxy is the likely cause. + +**Verify the asset canister is deployed correctly.** Run `icp canister status -e ic` and confirm the module hash is populated. If the hash is `None`, the canister exists but has no code installed. + +## Problem: Frontend violates Content Security Policy + +The browser console shows an error like: + +``` +Refused to connect to 'https://ic0.app/api/v2/canister//read_state' +because it violates the document's Content Security Policy. +``` + +This happens when the asset canister was installed without the current security headers, or when the CSP headers have drifted out of sync with the deployed code. + +**Fix:** reinstall the asset canister to refresh the CSP headers: + +```bash +icp deploy --mode reinstall -e ic +``` + +After reinstall, the asset canister serves updated security headers on every request. + +## Problem: Security policy warning "This project does not define a security policy for some assets" + +This warning appears when your project includes an asset canister but `.ic-assets.json5` does not define a security policy. + +**Fix:** add a security policy to `.ic-assets.json5` in your frontend asset directory: + +```json5 +[ + { + "match": "**/*", + "security_policy": "standard" + } +] +``` + +The `standard` policy applies a default Content Security Policy and security headers. If these headers block functionality your application needs (for example, loading resources from a specific external domain), override the relevant headers individually: + +```json5 +[ + { + "match": "**/*", + "security_policy": "standard", + "headers": { + "Content-Security-Policy": "default-src 'self' https://example.com; ..." + } + } +] +``` + +See [Asset canister](../frontends/asset-canister.md#ic-assets-json5) for the full `.ic-assets.json5` reference. + +## Problem: Rust canister fails to install with "invalid import section" + +Deploying a Rust canister returns an error indicating the Wasm module has an invalid import section: + +``` +Error: Failed to install code in canister ... +Caused by: Wasm module has an invalid import section +``` + +**Cause:** one or more crates in your dependency tree assume that certain standard library functions (such as `std::time` or file system calls) are available in `wasm32-unknown-unknown` targets. The IC Wasm runtime does not provide these functions, so the Wasm module imports them and the install is rejected. + +**Fix:** + +1. Find the crate causing the issue. Add `--target wasm32-unknown-unknown` to your `cargo build` command and look for linker errors that name unavailable imports. + +2. Check whether the crate offers a feature flag to disable non-Wasm dependencies (for example, `features = ["no-std"]` or `default-features = false`). + +3. Replace the crate with an alternative that targets `no_std` or `wasm32-unknown-unknown` explicitly. Many standard library crates have `wasm`-compatible alternatives on crates.io. + +4. If the crate is a transitive dependency, pin the version or use `[patch.crates-io]` in `Cargo.toml` to substitute a compatible fork. + +## Related + +- [Canister settings](./settings.md): compute allocation, memory allocation, and freezing threshold +- [Subnet selection](./subnet-selection.md): choosing a subnet when latency is a deployment constraint +- [Optimization](./optimization.md): reducing Wasm binary size and cycle costs +- [Asset canister](../frontends/asset-canister.md): frontend deployment and `.ic-assets.json5` configuration +- [Certified variables](../backends/certified-variables.md): tamperproof query responses + + diff --git a/docs/guides/canister-management/trust-in-canisters.md b/docs/guides/canister-management/trust-in-canisters.md new file mode 100644 index 0000000..00dc6f4 --- /dev/null +++ b/docs/guides/canister-management/trust-in-canisters.md @@ -0,0 +1,85 @@ +--- +title: "Trust in canisters" +description: "How to evaluate whether a canister is safe to interact with: code verification, build reproducibility, controller trust, and immutability options" +--- + +Applications that handle token transfers, financial transactions, or other sensitive operations require that users trust the canister to act honestly and reliably. This guide explains how to assess whether a canister you did not write is safe to interact with. + +Two questions matter: + +1. Does the canister do what it claims to do? +2. Will its behavior stay that way? + +## Does the canister do what it claims? + +### Inspect the source code + +If the developer published source code, review it to confirm it implements the claimed functionality and nothing else. Source code alone is not sufficient: you also need to confirm that the running Wasm was compiled from that source. + +### Verify the Wasm hash + +ICP exposes the SHA-256 hash of every canister's deployed Wasm module. If the developer published source code and documented build instructions, you can reproduce the build yourself and compare hashes: + +1. Get the deployed hash: + +```bash +icp canister status -n ic +``` + +2. Reproduce the build from the published source following the developer's instructions. +3. Compute the SHA-256 hash of the rebuilt Wasm and compare it to the deployed hash. + +A matching hash confirms the running code was compiled from the published source. For this to be meaningful, the build must be reproducible: the same source must produce a byte-identical Wasm binary every time. See [Reproducible builds](./reproducible-builds.md) for how to structure a project for this. + +### Track Wasm hash changes with canister history + +Every canister keeps a record of at least its 20 most recent changes, including code installations, upgrades, reinstalls, and controller changes. You can use this history to check whether a canister's Wasm hash has changed over time and, if so, when. + +See [Canister lifecycle](./lifecycle.md#canister-history) for how to query canister history programmatically and with the `icp` CLI. + +## Will the canister behavior stay that way? + +Even if a canister runs correct code today, its controllers can upgrade it to different code at any time. The second question is about governance: who controls the canister, and how decentralized is that control? + +### Verify the controller list + +Retrieve the current controller list: + +```bash +icp canister status -n ic +``` + +You can also retrieve controller information programmatically via a [`read_state` request](../../references/ic-interface-spec/https-interface.md#http-read-state) to the IC management interface. + +If the controller list contains a single developer identity, that developer has complete authority to change the canister code and any assets it holds at any time. This is the lowest trust level. + +### The trust spectrum + +| Controller type | Trust level | Notes | +|-----------------|-------------|-------| +| Single developer identity | Lowest | One person can change or delete the canister at any time | +| Multi-sig (e.g. Orbit) | Medium | Multiple parties must agree before changes take effect | +| SNS (Service Nervous System) | High | Governance is enforced by the network; changes require a community vote with token-weighted approval | +| Black-holed | Highest | No controller can change the code; the canister is permanently immutable | + +In all cases, the trust requirements flow to the controller. If an SNS governs the canister, review the SNS configuration and token distribution to assess how decentralized the governance actually is. An SNS with a heavily concentrated token supply provides weaker guarantees than a broadly distributed one. + +Note that even with decentralized governance, assets held by a canister are under the control of whoever controls the canister. Before interacting with a canister that holds your assets, understand what governance controls apply and who participates in it. + +### Black-holed canisters + +A canister can be made permanently immutable in two ways: + +**No controllers.** Setting the controller list to empty means no one can upgrade, reinstall, or delete the canister. Only the NNS can uninstall it via a proposal, and only in exceptional circumstances. If a canister has an empty controller list, no external party can ever change its code. + +**Black hole canister as controller.** The ["black hole" canister](https://github.com/ninegua/ic-blackhole) (`e3mmv-5qaaa-aaaah-aadma-cai`) has only itself as a controller and accepts no upgrade instructions. Passing control to it makes the subject canister permanently immutable while still allowing third parties to query useful information (such as the cycles balance) via the black hole interface. The black hole canister is thoroughly documented and its Wasm is independently verifiable. + +**Important caveat.** A canister that lists itself as its own controller appears immutable, but may not be. If the canister contains code that can call `install_code` or `reinstall_code` on itself, it can change its own Wasm without any external controller action. Before treating a self-controlled canister as immutable, inspect the source code to confirm it contains no such call paths. Reproducible builds are essential here: code inspection is only meaningful if you can confirm the inspected source matches what is running. + +## Related + +- [Reproducible builds](./reproducible-builds.md): structuring a project so users can independently verify the deployed Wasm hash +- [Canister lifecycle](./lifecycle.md#canister-history): querying canister history to track Wasm hash changes over time +- [Canister control](../../guides/security/canister-control.md): governance and decentralization recommendations for canister operators + + diff --git a/docs/guides/security/canister-control.md b/docs/guides/security/canister-control.md index 9cc8278..73b175e 100644 --- a/docs/guides/security/canister-control.md +++ b/docs/guides/security/canister-control.md @@ -72,4 +72,8 @@ Note that also loading other assets such as [CSS](https://xsleaks.dev/docs/attac - Use a [content security policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to prevent scripts and other content from other origins from being loaded at all. See also [define security headers, including a content security policy (CSP)](./overview.md#web-security). +## Related + +- [Trust in canisters](../canister-management/trust-in-canisters.md): how to assess whether a canister you did not write is safe to interact with, including the full trust spectrum from developer-controlled to black-holed + From 7b86eda4f711dd5677078ed01c7028c7e03a4a59 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 12 May 2026 16:31:08 +0200 Subject: [PATCH 2/7] docs: review fixes for best-practices gaps PR - Fix Map.entries() call in pagination.md Motoko example (no comparator arg) - Remove unused import Iter in pagination.md - Add sidebar orders to pagination.md (6), idempotency.md (5), trust-in-canisters.md (11), troubleshooting.md (12); orders 11-12 leave room for canister-migration.md (10) from PR #245 - Add subnet migration subsection to troubleshooting.md latency problem - Fix icp canister status flag: -n ic -> -e ic in trust-in-canisters.md - Link SNS term in trust spectrum table to concepts/sns-framework.md - Add pagination.md to inter-canister-calls.mdx Next steps --- docs/guides/canister-calls/idempotency.md | 2 ++ docs/guides/canister-calls/inter-canister-calls.mdx | 1 + docs/guides/canister-calls/pagination.md | 5 +++-- docs/guides/canister-management/troubleshooting.md | 8 ++++++++ docs/guides/canister-management/trust-in-canisters.md | 8 +++++--- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/guides/canister-calls/idempotency.md b/docs/guides/canister-calls/idempotency.md index baff003..7166c37 100644 --- a/docs/guides/canister-calls/idempotency.md +++ b/docs/guides/canister-calls/idempotency.md @@ -1,6 +1,8 @@ --- title: "Safe Retries and Idempotency" description: "Design idempotent canister APIs to enable safe retries for ingress calls and bounded-wait inter-canister calls, preventing double-spend and other correctness issues." +sidebar: + order: 5 --- In the case of network issues or other unexpected behavior, ICP clients (such as agents) that issue ingress update calls may be unable to determine whether their ingress request has been processed. For example, this can happen if the client loses connection until after the request's ingress expiry ends and the request's status is removed from the system state tree. diff --git a/docs/guides/canister-calls/inter-canister-calls.mdx b/docs/guides/canister-calls/inter-canister-calls.mdx index 575d4a7..741fe68 100644 --- a/docs/guides/canister-calls/inter-canister-calls.mdx +++ b/docs/guides/canister-calls/inter-canister-calls.mdx @@ -399,6 +399,7 @@ Calls between canisters on the same subnet complete within a single round. Cross ## Next steps - [Parallel inter-canister calls](parallel-inter-canister-calls.md): make multiple calls concurrently and use composite queries for efficient read patterns +- [Paginating query results](pagination.md): cursor-based pagination for mutable datasets that avoids duplicates and skipped items - [Candid](candid.md): define the interface your canister exposes for inter-canister calls - [Certified Variables](../backends/certified-variables.md): make query responses verifiable without update call overhead - [Inter-Canister Call Security](../security/inter-canister-calls.md): reentrancy guards, async safety patterns, and trust considerations diff --git a/docs/guides/canister-calls/pagination.md b/docs/guides/canister-calls/pagination.md index 5ebff1c..33ef611 100644 --- a/docs/guides/canister-calls/pagination.md +++ b/docs/guides/canister-calls/pagination.md @@ -1,6 +1,8 @@ --- title: "Paginating query results" description: "How to implement reliable pagination for canister query methods, including cursor-based patterns for mutable datasets" +sidebar: + order: 6 --- Many canisters expose query methods that return lists of items: messages, transactions, tokens, users. When the list grows large, returning all items in a single response is impractical. Pagination splits results into pages, but the approach matters: a naive offset-based implementation produces incorrect results as soon as the underlying dataset changes. @@ -25,7 +27,6 @@ Because the cursor is tied to an item identity rather than a position, insertion import Map "mo:core/Map"; import Nat "mo:core/Nat"; import Text "mo:core/Text"; -import Iter "mo:core/Iter"; import Array "mo:core/Array"; persistent actor { @@ -50,7 +51,7 @@ persistent actor { let threshold = switch afterId { case null 0; case (?n) n + 1 }; var collected : [Item] = []; var count = 0; - label scan for ((id, item) in Map.entries(items, Nat.compare)) { + label scan for ((id, item) in Map.entries(items)) { if (id < threshold) continue scan; if (count >= limit) break scan; collected := Array.append(collected, [item]); diff --git a/docs/guides/canister-management/troubleshooting.md b/docs/guides/canister-management/troubleshooting.md index 4ed81e9..d197a2e 100644 --- a/docs/guides/canister-management/troubleshooting.md +++ b/docs/guides/canister-management/troubleshooting.md @@ -1,6 +1,8 @@ --- title: "Troubleshooting" description: "Diagnose and resolve common issues: latency problems, frontend errors, Wasm build failures, and security policy warnings" +sidebar: + order: 12 --- This guide covers common issues encountered when developing and deploying canisters on ICP. For language-specific issues, see the [Motoko](../../languages/motoko/index.md) and [Rust](../../languages/rust/index.md) language docs. @@ -37,6 +39,12 @@ icp canister settings update backend --compute-allocation 1 -e ic Note that compute allocation incurs a rental fee regardless of actual canister activity. See [Canister settings](./settings.md#compute-allocation) for cost details. +### Consider migrating to a less-loaded subnet + +If the subnet consistently shows high load and compute allocation alone does not resolve the latency, migrating your canister to a less-loaded subnet may be the most effective remedy. Subnets process messages independently, so a canister on a busy subnet competes with every other canister on that subnet regardless of its compute allocation. + +Check current subnet loads on the [ICP Dashboard](https://dashboard.internetcomputer.org/subnets) to identify subnets with available capacity. For how to migrate a canister to a different subnet, see [Subnet selection](./subnet-selection.md#canister-is-on-the-wrong-subnet). + ### Use query calls instead of update calls where appropriate Query calls skip consensus and return in milliseconds. If a method only reads state and the data does not need to be tamperproof, use a `query` method instead of an update method. diff --git a/docs/guides/canister-management/trust-in-canisters.md b/docs/guides/canister-management/trust-in-canisters.md index 00dc6f4..bc488d4 100644 --- a/docs/guides/canister-management/trust-in-canisters.md +++ b/docs/guides/canister-management/trust-in-canisters.md @@ -1,6 +1,8 @@ --- title: "Trust in canisters" description: "How to evaluate whether a canister is safe to interact with: code verification, build reproducibility, controller trust, and immutability options" +sidebar: + order: 11 --- Applications that handle token transfers, financial transactions, or other sensitive operations require that users trust the canister to act honestly and reliably. This guide explains how to assess whether a canister you did not write is safe to interact with. @@ -23,7 +25,7 @@ ICP exposes the SHA-256 hash of every canister's deployed Wasm module. If the de 1. Get the deployed hash: ```bash -icp canister status -n ic +icp canister status -e ic ``` 2. Reproduce the build from the published source following the developer's instructions. @@ -46,7 +48,7 @@ Even if a canister runs correct code today, its controllers can upgrade it to di Retrieve the current controller list: ```bash -icp canister status -n ic +icp canister status -e ic ``` You can also retrieve controller information programmatically via a [`read_state` request](../../references/ic-interface-spec/https-interface.md#http-read-state) to the IC management interface. @@ -59,7 +61,7 @@ If the controller list contains a single developer identity, that developer has |-----------------|-------------|-------| | Single developer identity | Lowest | One person can change or delete the canister at any time | | Multi-sig (e.g. Orbit) | Medium | Multiple parties must agree before changes take effect | -| SNS (Service Nervous System) | High | Governance is enforced by the network; changes require a community vote with token-weighted approval | +| [SNS (Service Nervous System)](../../concepts/sns-framework.md) | High | Governance is enforced by the network; changes require a community vote with token-weighted approval | | Black-holed | Highest | No controller can change the code; the canister is permanently immutable | In all cases, the trust requirements flow to the controller. If an SNS governs the canister, review the SNS configuration and token distribution to assess how decentralized the governance actually is. An SNS with a heavily concentrated token supply provides weaker guarantees than a broadly distributed one. From fa0c6e43339bb367e62b322a10cb096c6084d242 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 12 May 2026 16:32:27 +0200 Subject: [PATCH 3/7] fix: use accepted Upstream comment keywords to pass validation Validation script requires hand-written, sync from, or informed by. Changed "ported from" and "rewritten (not ported) from" to "informed by" in pagination.md, trust-in-canisters.md, and troubleshooting.md. --- docs/guides/canister-calls/pagination.md | 2 +- docs/guides/canister-management/troubleshooting.md | 2 +- docs/guides/canister-management/trust-in-canisters.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guides/canister-calls/pagination.md b/docs/guides/canister-calls/pagination.md index 33ef611..6f6bd3d 100644 --- a/docs/guides/canister-calls/pagination.md +++ b/docs/guides/canister-calls/pagination.md @@ -164,4 +164,4 @@ Each call to a query method on a canister on the IC mainnet costs cycles. When p - [Calling from clients](./calling-from-clients.md): making query calls from a browser or CLI - [Data persistence](../backends/data-persistence.md): storage patterns for canister state - + diff --git a/docs/guides/canister-management/troubleshooting.md b/docs/guides/canister-management/troubleshooting.md index d197a2e..e4f34d5 100644 --- a/docs/guides/canister-management/troubleshooting.md +++ b/docs/guides/canister-management/troubleshooting.md @@ -170,4 +170,4 @@ Caused by: Wasm module has an invalid import section - [Asset canister](../frontends/asset-canister.md): frontend deployment and `.ic-assets.json5` configuration - [Certified variables](../backends/certified-variables.md): tamperproof query responses - + diff --git a/docs/guides/canister-management/trust-in-canisters.md b/docs/guides/canister-management/trust-in-canisters.md index bc488d4..ffb5194 100644 --- a/docs/guides/canister-management/trust-in-canisters.md +++ b/docs/guides/canister-management/trust-in-canisters.md @@ -84,4 +84,4 @@ A canister can be made permanently immutable in two ways: - [Canister lifecycle](./lifecycle.md#canister-history): querying canister history to track Wasm hash changes over time - [Canister control](../../guides/security/canister-control.md): governance and decentralization recommendations for canister operators - + From 830f4e56d7821e45f6d66117baf4d63043ffe951 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 12 May 2026 16:33:02 +0200 Subject: [PATCH 4/7] docs: link stable-structures page from pagination.md Related section --- docs/guides/canister-calls/pagination.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/guides/canister-calls/pagination.md b/docs/guides/canister-calls/pagination.md index 6f6bd3d..2122d54 100644 --- a/docs/guides/canister-calls/pagination.md +++ b/docs/guides/canister-calls/pagination.md @@ -163,5 +163,6 @@ Each call to a query method on a canister on the IC mainnet costs cycles. When p - [Inter-canister calls](./inter-canister-calls.md): calling query methods from other canisters - [Calling from clients](./calling-from-clients.md): making query calls from a browser or CLI - [Data persistence](../backends/data-persistence.md): storage patterns for canister state +- [Stable structures (Rust)](../../languages/rust/stable-structures.md): deep dive into `ic-stable-structures` used in the Rust example above From 9e629eaf3c4f972abc02b289ce79dca01054e31c Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 12 May 2026 16:34:12 +0200 Subject: [PATCH 5/7] docs: replace "token standards" with "digital asset standards" in data-persistence --- docs/guides/backends/data-persistence.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/backends/data-persistence.mdx b/docs/guides/backends/data-persistence.mdx index 779dffa..a8b14ae 100644 --- a/docs/guides/backends/data-persistence.mdx +++ b/docs/guides/backends/data-persistence.mdx @@ -643,7 +643,7 @@ If your application needs to maintain a history of transactions or events, avoid - **Dedicated logging canister.** A separate canister that accepts append-only log entries reduces load on the main canister and keeps the history size from affecting upgrade cost. - **`StableLog` (Rust).** For canisters that can accommodate history growth, `StableLog` from `ic-stable-structures` provides an append-only log directly in stable memory. -- **External history services.** Services like [CAP](https://cap.ooo/) maintain transaction provenance records that integrate with explorers and wallets, which is useful for token standards compliance. +- **External history services.** Services like [CAP](https://cap.ooo/) maintain transaction provenance records that integrate with explorers and wallets, which is useful for [digital asset standards](../../references/digital-asset-standards.md) compliance. Be aware that inter-canister calls to a logging service add latency and cycle cost. Size the logging approach to the transaction volume you expect. From 77334a7b826d059b964aefc5cadcb71c5cbeae28 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 12 May 2026 16:41:06 +0200 Subject: [PATCH 6/7] fix: correct two code bugs in pagination.md examples - Motoko: Array.append does not exist in mo:core/Array; replace with Array.concat - Rust: Item struct used as StableBTreeMap value requires Storable impl; add it using ciborium CBOR serialization, consistent with stable-structures.md pattern --- docs/guides/canister-calls/pagination.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/guides/canister-calls/pagination.md b/docs/guides/canister-calls/pagination.md index 2122d54..ad5441e 100644 --- a/docs/guides/canister-calls/pagination.md +++ b/docs/guides/canister-calls/pagination.md @@ -54,7 +54,7 @@ persistent actor { label scan for ((id, item) in Map.entries(items)) { if (id < threshold) continue scan; if (count >= limit) break scan; - collected := Array.append(collected, [item]); + collected := Array.concat(collected, [item]); count += 1; }; let nextCursor = if (count < limit) null else ?(collected[count - 1].id); @@ -68,18 +68,35 @@ persistent actor { ```rust use ic_stable_structures::{StableBTreeMap, memory_manager::{MemoryId, MemoryManager, VirtualMemory}, DefaultMemoryImpl}; +use ic_stable_structures::storable::{Bound, Storable}; use ic_cdk::{query, update}; use candid::{CandidType, Deserialize}; +use serde::Serialize; +use std::borrow::Cow; use std::cell::RefCell; type Memory = VirtualMemory; -#[derive(CandidType, Deserialize, Clone)] +#[derive(CandidType, Serialize, Deserialize, Clone)] struct Item { id: u64, name: String, } +impl Storable for Item { + 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 Item"); + Cow::Owned(buf) + } + + fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { + ciborium::from_reader(bytes.as_ref()).expect("failed to decode Item") + } +} + #[derive(CandidType)] struct Page { items: Vec, From 080185a3c247242bef961fe94dd3e6a23d02e8d6 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 12 May 2026 16:49:43 +0200 Subject: [PATCH 7/7] docs: add actor class instantiation cost warning to canister-per-user Spawning a user canister carries the same overhead as a fresh canister install. This was in the portal storage.mdx efficiency recommendations but belongs here alongside the other canister-per-user caveats. --- docs/concepts/app-architecture.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/concepts/app-architecture.md b/docs/concepts/app-architecture.md index 3fda25d..1e21489 100644 --- a/docs/concepts/app-architecture.md +++ b/docs/concepts/app-architecture.md @@ -80,6 +80,7 @@ Each user gets their own canister that they control. The main application canist **Things to know:** - The main canister must treat every user canister as potentially malicious. Any code path that interacts with user canisters must assume adversarial behavior and be hardened against it. - Development cost is very high. Handling all possible actions from potentially malicious user canisters requires expert knowledge of the ICP security and messaging model. +- Spawning a user canister (via an actor class in Motoko or a management canister `create_canister` call in Rust) carries the same cost as a fresh canister install. Do it once per user at account creation, never on a hot call path. - **There is no known successful end-to-end implementation of the full canister-per-user vision.** A few projects have explored variations, but the architecture remains experimental. - Common misconception: canister-per-user is not the most scalable pattern. Canister-per-subnet is more performant because it can utilize multiple subnets without the overhead of managing a large number of small canisters.