| name | cloud-engine-canisters |
| description | Rules for canister code on a cloud engine (CloudEngine subnet, e.g. OpenCloud). Engine canisters hold 0 cycles: never attach cycles you computed: drop `(with cycles = …)` clauses (failure: IC0504). Cross-subnet calls must be bounded-wait and cycle-free (`(with timeout = N)`, `Call::bounded_wait`) or rejected: 'Unbounded-wait calls and calls with cycles are not allowed to CloudEngine subnets'. HTTPS outcalls use the ordinary wrapper and are free; never via the proxy: no transform applies ('Replicas had different responses') and its budget drains into InsufficientCycles: move the outcall, don't top up. Cycle-bearing cross-subnet calls go through the engine's proxy canister: XRC, and threshold ECDSA/Schnorr and vetKD (Bitcoin, Ethereum, VetKeys); hand-encode the arguments; the ic-vetkeys and ic-cdk helpers attach cycles. Derived keys belong to the proxy. Use when writing or debugging engine canister code, signing or deriving keys, or on these errors. Do NOT use for deploying a proxy (deploy-to-cloud-engine). |
| license | Apache-2.0 |
| compatibility | A cloud engine (CloudEngine subnet) to run on, plus a deployed proxy canister for any chain-key or XRC call; Motoko examples need moc >= 0.14.2 (parenthetical `(with cycles = …)` / `(with timeout = N)` call attributes) and `to_candid` / `from_candid`, Rust examples need ic-cdk >= 0.18 (bounded-wait `Call` API) |
| metadata | {"title":"Cloud Engine Canisters","category":"CloudEngine"} |
Cloud Engine Canisters
What This Is
A cloud engine is a user-owned slice of Internet Computer capacity, administered from a web console (see the deploy-to-cloud-engine skill for getting code onto one). Engines run on a dedicated CloudEngine subnet type with a free cycles cost schedule: nothing an engine canister does is metered in cycles — execution, storage, messaging, and HTTPS outcalls all cost zero — and engine canisters hold a 0 cycles balance by design.
That model comes with protocol-enforced call rules. Code that works on a normal Application subnet can fail on an engine, and the failures look like cycles or consensus problems rather than what they are: code written for the wrong subnet type. The rules:
| Rule | On a cloud engine |
|---|
| 1 | Never attach cycles to any call — remove (with cycles = …) and cycle-attaching wrappers |
| 2 | Cross-subnet calls must be bounded-wait — (with timeout = N) in Motoko, Call::bounded_wait in Rust |
| 3 | HTTPS outcalls: ordinary code, zero cost — keep the standard wrapper, transform on your own canister, never through the proxy |
| 4 | Cycle-bearing cross-subnet targets (XRC, threshold signing, vetKD) go through the console proxy canister |
Rule 1 — Never attach a cycles amount you computed yourself
Engine canisters hold 0 cycles and there is nothing to pay for: under the engine's free cost schedule every fee an Application-subnet canister would pay (execution, message transmission, HTTPS outcalls, threshold signing, vetKD, storage) is charged as zero.
-
Inter-canister calls you write: no cycles clause at all. There is nothing to buy — every fee on an engine is charged as zero — so a cycles attachment is never needed, independently of whether the protocol tolerates it. Write await service.method(args), never await (with cycles = 1_000_000) service.method(args). Verified on a live engine canister (Cycles: 0): any non-zero amount fails with
Canister <id> is out of cycles, error code Some("IC0504")
Do not "fix" this by zeroing the amount. The platform team's guidance is simply not to mention cycles at all on an engine call, and a cycles = 0 left in place is noise that breaks again the moment the amount becomes non-zero (a computed fee, a copied constant). If you see IC0504 from an engine canister, some call is attaching a non-zero amount.
-
Rust: the same rule — never call .with_cycles(…) on a Call builder. (The platform team states the Motoko rule as "just don't mention cycles"; omitting .with_cycles is its Rust equivalent.)
-
Management-canister calls keep their standard wrapper. Call.httpRequest (Motoko, mo:ic) and ic_cdk::management_canister::http_request (Rust) ask the ic0.cost_* system API for the fee, and that API is cost-schedule aware: on an engine it returns 0, so the wrapper attaches nothing and the same source stays portable to an Application subnet. What breaks is substituting a hardcoded or hand-computed fee — never take a number from the https-outcalls cost tables and attach it on an engine.
-
The one exception: cycles that a cross-subnet target charges are placed inside ProxyArgs.cycles of the console proxy (Rule 4), never attached to a call your canister sends.
Rule 2 — Cross-subnet calls must be bounded-wait
The protocol rejects any cross-subnet (XNet) request from or to a CloudEngine subnet that is unbounded-wait (guaranteed-response) or carries cycles. The caller gets a reject with exactly this text:
Unbounded-wait calls and calls with cycles are not allowed to CloudEngine subnets
Verified live from an engine canister: a plain await to a canister on another subnet comes back as that exact #system_fatal reject, while the same call with (with timeout = 30) and no cycles clause succeeds.
Make every cross-subnet call bounded-wait and cycle-free:
// Motoko: `timeout : Nat32` is in seconds
let result = await (with timeout = 30) service.method(args);
let reply: T = Call::bounded_wait(canister_id, "method")
.with_arg(arg)
.await?
.candid()?;
- Same-subnet calls are exempt: canisters on the same engine may call each other unbounded-wait. Bounded-wait is still the better default — an unbounded-wait call to an unresponsive callee blocks the caller's upgrades indefinitely (see the
multi-canister skill).
- The restriction applies in both directions: a canister on a normal subnet calling into an engine canister must also use a bounded-wait, cycle-free call.
- A bounded-wait call can complete with
SYS_UNKNOWN (outcome unknown). Handle it — the multi-canister skill covers the patterns.
Rule 3 — HTTPS outcalls: ordinary code, zero cost
HTTPS outcalls execute on the engine's own subnet, and the cost API reports them as free there, so the standard wrapper works unchanged — there is no engine-specific outcall code:
import IC "mo:ic/Types";
import Call "mo:ic/Call";
// Build `request : IC.HttpRequestArgs` exactly as in the https-outcalls skill,
// with the transform query on THIS canister. Call.httpRequest asks the cost API,
// which returns 0 on an engine, so nothing is attached.
let response = await Call.httpRequest(request);
-
Keep the wrapper; never swap in a hardcoded fee (Rule 1). ic_cdk::management_canister::http_request behaves the same way in Rust. This is how outcalls are written in engine apps running today.
-
Everything else about outcalls is unchanged — transform function on your own canister, max_response_bytes, idempotency. Engines support the full outcall feature set per the platform team, replicated or not, so is_replicated = ?false is available for non-idempotent or rate-limited APIs — and for bulk workloads, where a replicated call multiplies every request by the replica count. Load the https-outcalls skill for those.
-
Never route HTTPS outcalls through the console proxy canister. The platform team is explicit: all HTTP outcalls should be direct, and the proxy exists only for mainnet services that require cycles. The IC requires the transform function to live on the canister that issues http_request — behind the proxy that is the proxy itself, and the proxy exposes no transform method (verified against its live interface, and the team confirms none is planned). Untransformed responses differ across replicas for most real APIs, and the call fails with:
SYS_TRANSIENT: No consensus could be reached. Replicas had different responses
A proxied outcall also pays. The proxy sits on a normal Application subnet, so it is charged the real outcall fee out of the balance you funded, while the same call issued from your engine canister is charged nothing. At migration scale — thousands of calls — that balance drains and every call starts failing with ProxyError::InsufficientCycles. That is a symptom of proxying outcalls, not of an under-funded engine.
Both failures have the same fix: issue the outcall directly from your canister (free, works). Neither is fixed by retries or by topping the proxy up.
Rule 4 — Cross-subnet cycle-bearing calls via the console proxy
Two separate facts land in the same place. The Rule 2 restriction means an engine canister cannot send a cross-subnet message carrying cycles — and, per the platform team, cloud engines do not provide threshold signing at all, so those facilities have to be reached on mainnet. Either way the call cannot be made directly:
- threshold ECDSA / Schnorr signing (
sign_with_ecdsa, sign_with_schnorr) and their public-key methods, and vetKD (vetkd_derive_key, vetkd_public_key) — not available on an engine; use mainnet's via the proxy,
- the exchange-rate canister (XRC) — a mainnet canister that charges cycles,
- any other canister that must be called with cycles across a subnet boundary.
This is the proxy's entire purpose: reaching mainnet services that require cycles. It is not for HTTPS outcalls (Rule 3).
The workaround is the console 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 — and neither do HTTPS outcalls (Rule 3).
Getting a proxy
The proxy is not something your canister code creates: it is deployed and funded outside the app, then its canister id is handed to the app as configuration. Two different canisters can play the role, and they are not interchangeable:
| Console proxy (the engine's) | Self-deployed proxy (icp new … --subfolder proxy) |
|---|
| Who may call it | The engine's canister-id ranges, plus controllers | Controllers only |
| Your engine canisters can call it | Yes, with no extra setup | Only if you add each of them as a controller |
| Threshold-key derivation | Caller-isolated (see below) | No isolation: raw pass-through |
| Funded with | A card, from the console | Cycles you already hold (icp cycles mint) |
For engine canisters, use the console proxy. A self-deployed proxy is the right tool for calls you make from the CLI (icp canister call --proxy, icp deploy --proxy), and the console proxy cannot serve that purpose, because it rejects ingress calls from any principal that is not one of its controllers, which your CLI identity is not.
Deploying and funding either one is not a canister-code task. It is covered in the deploy-to-cloud-engine skill; from here you need only the resulting proxy canister id. Take it from the user or from the app's configuration, never hardcode a guess, and read the derivation warning below before assuming one proxy id can be swapped for another.
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:
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). The platform team confirms the caller chooses how much the proxy attaches, and the proxy receives args as an opaque blob, so it cannot size the amount for you: a constant you pass is exactly what it will require, and the required field of InsufficientCycles echoes your own number back rather than reporting a proxy-side reservation. 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.
- Over-attaching is safe; under-attaching is not. The callee refunds what it does not consume, and the refund goes back to the proxy (the proxy is the caller on that leg), so a generous
cycles figure costs nothing and survives a fee increase. available in InsufficientCycles is the proxy's liquid balance (its freezing reserve is already excluded), so a proxy can report InsufficientCycles while still showing a balance in the console.
- A
CallFailed is not proof the call did not happen. The proxy relays with a bounded-wait call, so a downstream SYS_UNKNOWN reaches you as CallFailed with that reject code in reason. The target may have executed and the cycles may already be spent. Treat it as "outcome unknown", not "failed": the multi-canister skill covers the patterns.
Motoko sketch (Rust is analogous with candid::encode_one / decode_one):
// `proxy` is an actor typed to the candid interface above.
let arg = to_candid (request); // encode the TARGET method's argument
let res = await (with timeout = 30) 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)
Threshold ECDSA, Schnorr, and vetKD are the proxy's main job on an engine, and the one place where getting the call wrong costs more than an error message: a key you cannot reproduce is a Bitcoin or Ethereum address whose funds you cannot move.
The helper libraries do not work here. ic-vetkeys, ic-cdk-management-canister, and Motoko's mo:ic-vetkeys/ManagementCanister all call aaaaa-aa directly and attach the fee for you. On an engine that is a cross-subnet, cycle-bearing call: rejected twice over (Rules 1 and 2). Their convenience is exactly what you must give up: encode the management-canister argument yourself and send it through the proxy. The vetkeys skill's code is written for a normal subnet and needs this rewrite before it will run on an engine.
What the proxy rewrites. For the six key methods (sign_with_ecdsa, ecdsa_public_key, sign_with_schnorr, schnorr_public_key, vetkd_derive_key, vetkd_public_key) the console proxy decodes your argument, isolates the derivation to you, and re-encodes it:
- ECDSA / Schnorr: your calling canister's principal is inserted as the first element of
derivation_path.
- vetKD: your principal is prepended to
context, length-tagged: one byte holding the principal's length, then the principal bytes, then your context.
- On the three
*_public_key methods, canister_id is additionally forced to None.
The principal comes from msg_caller(), so it cannot be forged and one engine canister can never reach another's keys. (Verified: cargo test -p proxy-canister --lib, 10/10; ecdsa_sign_prepends_caller_and_preserves_the_rest, vetkd_prefixes_caller_into_context_length_tagged, distinct_callers_get_distinct_derivations.)
Fees. ecdsa_public_key, schnorr_public_key, and vetkd_public_key are free: pass cycles = 0. The paying methods are charged by the subnet that holds the key, not yours:
| Key name | sign_with_ecdsa / sign_with_schnorr / vetkd_derive_key |
|---|
key_1 (production) | 26_153_846_153 |
test_key_1 (testing) | 10_000_000_000 |
Round up rather than passing the exact figure: the unused remainder is refunded to the proxy.
Worked example: ECDSA, Motoko. Declare the management-canister types yourself; field names and variant tags must match the candid interface exactly, because the proxy decodes and re-encodes the blob; a missing or misnamed field, or an unknown variant tag, fails the decode (surfacing as CallFailed, not a mis-derived key). A field the proxy does not model is not caught: candid's record subtyping skips it on decode and the re-encode drops it.
type EcdsaKeyId = { curve : { #secp256k1 }; name : Text };
type EcdsaPublicKeyArgs = { canister_id : ?Principal; derivation_path : [Blob]; key_id : EcdsaKeyId };
type EcdsaPublicKeyReply = { public_key : Blob; chain_code : Blob };
type SignWithEcdsaArgs = { message_hash : Blob; derivation_path : [Blob]; key_id : EcdsaKeyId };
type SignWithEcdsaReply = { signature : Blob };
let mgmt = Principal.fromText("aaaaa-aa");
let keyId : EcdsaKeyId = { curve = #secp256k1; name = "key_1" };
let signFee = 30_000_000_000; // >= 26_153_846_153; the excess comes back
// One helper for every proxied management call.
func viaProxy(method : Text, arg : Blob, cycles : Nat) : async Blob {
let res = await (with timeout = 30) proxy.proxy({
canister_id = mgmt; // the TARGET, not the proxy
method = method;
args = arg;
cycles = cycles; // 0 for the *_public_key methods
});
switch (res) {
case (#Ok { result }) { result };
case (#Err e) { Runtime.trap("proxy: " # debug_show e) }; // real code: return an error
};
};
// Address: free, no cycles.
let pkArg = to_candid ({
canister_id = null; // forced to null by the proxy anyway
derivation_path = [Text.encodeUtf8("user-42")]; // your own path; the proxy prefixes itself
key_id = keyId;
} : EcdsaPublicKeyArgs);
let ?pk : ?EcdsaPublicKeyReply = from_candid (await viaProxy("ecdsa_public_key", pkArg, 0))
else Runtime.trap("decode ecdsa_public_key");
// Signature: pays the fee. `message_hash` must be exactly 32 bytes.
let sigArg = to_candid ({
message_hash = messageHash;
derivation_path = [Text.encodeUtf8("user-42")]; // the SAME path as above
key_id = keyId;
} : SignWithEcdsaArgs);
let ?sig : ?SignWithEcdsaReply = from_candid (await viaProxy("sign_with_ecdsa", sigArg, signFee))
else Runtime.trap("decode sign_with_ecdsa");
Note that sign_with_ecdsa has no canister_id field: the key it signs with is always the caller's, and behind the proxy the caller is the proxy. That is precisely why the public key you publish and the signature you produce must both come through the same proxy.
Worked example: vetKD, Motoko. Same shape, different types. vetkd_public_key is free; vetkd_derive_key pays.