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), neverawait (with cycles = 1_000_000) service.method(args). Verified on a live engine canister (Cycles: 0): any non-zero amount fails withCanister <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 = 0left in place is noise that breaks again the moment the amount becomes non-zero (a computed fee, a copied constant). If you seeIC0504from an engine canister, some call is attaching a non-zero amount. -
Rust: the same rule — never call
.with_cycles(…)on aCallbuilder. (The platform team states the Motoko rule as “just don’t mention cycles”; omitting.with_cyclesis its Rust equivalent.) -
Management-canister calls keep their standard wrapper.
Call.httpRequest(Motoko,mo:ic) and the Rust outcall wrapper for your crate line (Rule 3) ask theic0.cost_*system API for the fee, and that API is cost-schedule aware: for an outcall it prices against your own subnet’s cost schedule, which is free on an engine, so it returns 0, 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 thehttps-outcallscost tables and attach it on an engine. -
Which subnet’s schedule is consulted is the whole distinction. The chain-key cost APIs (
ic0.cost_sign_with_ecdsaand friends) price against the subnet that holds the key, not yours — that subnet charges normally, so they return a non-zero fee even when called from an engine, and the wrapper then attaches it. That is why a helper that signs cannot work here while the same crate’s outcall builder can (Rules 3 and 4). -
The one exception: cycles that a cross-subnet target charges are placed inside
ProxyArgs.cyclesof 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);
// Rust, ic-cdk >= 0.18 — bounded_wait defaults to a 300-second timeout (the IC maximum)
let reply: T = Call::bounded_wait(canister_id, "method")
.with_arg(arg)
.await? // Err on reject / SYS_UNKNOWN — handle, do not unwrap
.candid()?; // decode the reply
- 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-canisterskill). - 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 — themulti-canisterskill 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). Letting the wrapper price the call is how outcalls are written in the Motoko engine apps running today.
-
In Rust, use the wrapper your crate line provides —
HttpRequest::new(url)…send()fromic-cdk-management-canister0.2, the freeic_cdk_management_canister::http_requestin 0.1, oric_cdk::management_canister::http_requeston ic-cdk 0.19 (that module was removed in ic-cdk 0.20). All three behave identically here: each asksic0.cost_*, which prices against your own subnet and so returns 0 on an engine, and each attaches that 0. Write for the line the project is actually on, and never bump crate versions to “fix” an engine outcall — there is nothing to fix.https-outcallscovers the differences between the three. -
ic-cdk-management-canister’s outcall builders are the supported path here — only its chain-key helpers are unusable on an engine (Rule 4). The crate is not off-limits; the target subnet is what decides. -
Pricing version 2 needs no tuning on an engine. The 0.2 builder always prices version 2 (“pay-as-you-go”), where the cycles attached double as each node’s spending budget — so on a paying subnet an under-narrowed
with_expected_*lets a call fail partway, after the remote server was contacted. On an engine that cannot happen: the free cost schedule never charges, so the budget never narrows anything — the round-trip timeout and the transform-instruction limit sit at the protocol maximum, andInsufficientCyclesis never raised. Treat thewith_expected_*setters as no-ops here: set them for portability if you like, but never tune them to fix an engine outcall, and never read an engine outcall failure as a budget problem. What still applies is your ownmax_response_bytes— it bounds the response on an engine exactly as anywhere else, so an oversized reply is a real failure cause and the one outcall limit worth checking here. -
Either pricing version works on an engine, so the version is not something to manage here. Version 2 is accepted only where the replica has pay-as-you-go enabled; where it does not, a requested version 2 is silently filtered back to version 1 with no error. That is a funding mismatch on a paying subnet, but harmless on an engine, because an outcall is charged zero under both versions. Do not add engine-specific version handling.
-
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, sois_replicated = ?falseis available for non-idempotent or rate-limited APIs — and for bulk workloads, where a replicated call multiplies every request by the replica count. Load thehttps-outcallsskill 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 responsesA 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;
};
argsis the candid-encoded argument of the target method (you encode it);resultis the target’s raw reply bytes (you decode it).cyclesis 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 receivesargsas an opaque blob, so it cannot size the amount for you: a constant you pass is exactly what it will require, and therequiredfield ofInsufficientCyclesechoes your own number back rather than reporting a proxy-side reservation. Do not attach cycles to the outerproxycall itself; the engine subnet forbids that and it is unnecessary.- Handle
ProxyError:InsufficientCyclesmeans the proxy’s balance is too low (top it up in the console),UnauthorizedUsermeans the caller is outside the engine’s range,CallFailedcarries 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
cyclesfigure costs nothing and survives a fee increase.availableinInsufficientCyclesis the proxy’s liquid balance (its freezing reserve is already excluded), so a proxy can reportInsufficientCycleswhile still showing a balance in the console. - A
CallFailedis not proof the call did not happen. The proxy relays with a bounded-wait call, so a downstreamSYS_UNKNOWNreaches you asCallFailedwith that reject code inreason. The target may have executed and the cycles may already be spent. Treat it as “outcome unknown”, not “failed”: themulti-canisterskill 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 chain-key helpers do not work here. ic-vetkeys, ic-cdk-management-canister’s chain-key functions (sign_with_ecdsa, sign_with_schnorr, vetkd_derive_key and their public-key siblings), and Motoko’s mo:ic-vetkeys/ManagementCanister all call aaaaa-aa directly and attach the fee for you. Because the chain-key cost API prices against the key-holding subnet, that fee is non-zero even on an engine, and the helper issues an unbounded-wait call carrying it — 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.
This is scoped to the chain-key functions, not to the crates. ic-cdk-management-canister also carries the HTTPS-outcall builders, and those are the supported way to make an outcall from an engine canister (Rule 3): they target your own subnet, price at 0, and must not be routed through the proxy. Reading “the helpers don’t work on an engine” as “hand-encode outcalls through the proxy too” produces exactly the consensus and funding failures in pitfall 4. Split the crate by target subnet: local outcall ⇒ ordinary builder; remote chain-key ⇒ proxy.
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_keymethods,canister_idis additionally forced toNone.
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.
type VetkdKeyId = { curve : { #bls12_381_g2 }; name : Text };
type VetkdPublicKeyArgs = { canister_id : ?Principal; context : Blob; key_id : VetkdKeyId };
type VetkdPublicKeyReply = { public_key : Blob };
type VetkdDeriveKeyArgs = {
input : Blob; context : Blob; transport_public_key : Blob; key_id : VetkdKeyId
};
type VetkdDeriveKeyReply = { encrypted_key : Blob };
let vetKeyId : VetkdKeyId = { curve = #bls12_381_g2; name = "key_1" };
let dkArg = to_candid ({
input = Text.encodeUtf8("note-7");
context = Text.encodeUtf8("my-app"); // the proxy length-tags its prefix onto this
transport_public_key = transportPk;
key_id = vetKeyId;
} : VetkdDeriveKeyArgs);
let ?dk : ?VetkdDeriveKeyReply = from_candid (await viaProxy("vetkd_derive_key", dkArg, 30_000_000_000))
else Runtime.trap("decode vetkd_derive_key");
Rust is the same flow with candid::encode_one / decode_one and a bounded-wait call, see the shape in the Call::bounded_wait(proxy_id, "proxy") example under Rule 2.
The derived key belongs to the proxy, not to your app. The management canister derives from its caller, which is the proxy, so the key you get depends on which proxy canister id you went through:
- Point the app at a different proxy (a second one from the console’s “Deploy another proxy”, or a self-deployed one) and every address changes. The old address is not recoverable from the new proxy.
- Deleting a proxy destroys access to its keys. The console’s delete button refunds the remaining cycles; it cannot give back the derivation. Any Bitcoin or Ethereum funds held at an address derived through that proxy become permanently unspendable. Treat the proxy id of a signing app as permanent infrastructure, and never delete a proxy to “save cycles” without first moving every asset off the addresses derived through it.
- A self-deployed upstream proxy does no isolation at all (no principal prefix, no forced
canister_id = None), so its keys differ again from the console proxy’s: the two are not substitutes even before the id changes. - Anything reproducing a derivation off-chain (deriving the same public key in a frontend, say) must replicate the prefix the proxy injects, including vetKD’s length tag.
Common Pitfalls
- Attaching a cycles amount to a call you write. A
(with cycles = N)clause on an inter-canister call from an engine canister is never needed, and any non-zeroNfails withIC0504/is out of cycles(verified live). Remove the clause entirely rather than settingcycles = 0: a zero is accepted, so it hides the mistake instead of fixing the habit. Nothing an engine canister does needs cycles: outcall, signing, and messaging fees are all zero under the engine’s free cost schedule. - Unbounded-wait cross-subnet calls. A plain
await service.method(args)to a canister on another subnet is unbounded-wait and is rejected with “Unbounded-wait calls and calls with cycles are not allowed to CloudEngine subnets”. Use(with timeout = N)in Motoko orCall::bounded_waitin Rust — and handleSYS_UNKNOWN. Same-subnet (engine-local) calls are exempt. - Rewriting outcall code for an engine — or hardcoding its fee. Outcalls need no engine-specific form: keep
Call.httpRequest(Motoko) or, in Rust, whichever wrapper the project’s crate line provides (HttpRequest::new(url)…send()onic-cdk-management-canister0.2;ic_cdk::management_canister::http_requestonly up to ic-cdk 0.19, since 0.20 removed that module). Theic0.cost_*API they consult prices against your own subnet and returns 0 on an engine. The mistake is replacing that call with a hand-computed fee from an Application-subnet cost table, which attaches a non-zero amount against a 0 balance. - Routing HTTPS outcalls through the console proxy. Two different failures, one cause. Consensus:
SYS_TRANSIENT: No consensus could be reached. Replicas had different responses, because the transform must live on the calling canister — behind the proxy that is the proxy itself, and it exposes none (nor is one planned). Cost: the proxy is on a normal Application subnet, so it pays the real outcall fee from the balance you funded, and a bulk workload drains it intoProxyError::InsufficientCycles. Issue outcalls directly from your canister instead: on an engine they are free and unmetered, so no budget exists to exhaust. - Topping up an engine canister with cycles. Engine canisters hold 0 cycles by design; you cannot and need not send cycles to them. A “0 cycles” reading from an engine canister is normal, not an emergency — do not add top-up logic or cycles-balance alarms ported from Application-subnet apps.
- Calling the XRC / threshold signing / vetKD directly from an engine canister. Cloud engines do not provide threshold signing at all (platform team), so
sign_with_ecdsa/sign_with_schnorr/ vetKD must be reached on mainnet — and a cross-subnet, cycle-bearing call is exactly what a CloudEngine-subnet canister cannot send. Route them through the funded console proxy (Rule 4). Plain same-subnet or cycle-less calls do not need the proxy. - Attaching cycles to the outer
proxycall. The engine subnet forbids cycle-bearing cross-subnet messages — that is the whole reason for the proxy. Put the cycles insideProxyArgs.cycles(the proxy attaches them locally); neverwith cycleson the call toproxyitself. - Topping up the proxy without first asking what it was relaying. On
ProxyError::InsufficientCycles, check the call type before treating it as a funding problem. An HTTPS outcall does not belong on the proxy at all (Rule 3, pitfall 4): move it onto your own canister, where it is free, rather than buying budget for work that should cost nothing; raising the balance only delays the same stall. A drained balance is a genuine funding problem only for the proxy’s real jobs (XRC, threshold signing, vetKD): top it up, or enable auto top-up, in the Proxy canisters section of the engine’s Canisters page. Deploying and funding the console proxy is a console action, not anicpcommand, seedeploy-to-cloud-engine. - Expecting a direct-call key through the proxy. Threshold-key derivation via the proxy is caller-isolated, so the derived key/address is not the same as a direct management-canister call. Fetch the public key and sign through the proxy consistently; do not mix direct and proxied key calls for the same identity.
- Using a chain-key helper library on an engine.
ic-vetkeys,ic-cdk-management-canister’s chain-key functions, andmo:ic-vetkeys/ManagementCanistercallaaaaa-aadirectly and attach the fee themselves — non-zero even on an engine, because the chain-key cost API prices against the key-holding subnet — making it a cross-subnet, cycle-bearing call that the engine rejects under Rules 1 and 2. Their whole value proposition (correct types, automatic cycles) is what has to go: hand-encode the management-canister argument and relay it through the proxy. Code copied from thevetkeysskill will not run on an engine unmodified. - Over-reading that warning and proxying outcalls too. The warning above is about the chain-key functions, not the crates that contain them.
ic-cdk-management-canisteris also what Rust outcall code should use (Rule 3), and itsHttpRequestbuilder is correct on an engine as written. Concluding “this crate doesn’t work on an engine, so the outcall must be hand-encoded through the proxy” lands straight in pitfall 4 — a transform that cannot apply and a proxy balance that drains. Decide by target subnet, not by crate name:aaaaa-aahttp_requestis local and free; signing and vetKD are remote and paid. - Tuning pricing-version-2 budgets for an engine.
ic-cdk-management-canister0.2 always prices version 2, where the attached cycles are also each node’s budget and a too-narrowwith_expected_*can fail a call partway. On an engine the free cost schedule never charges, so the budget narrows nothing: the timeout and transform-instruction limits sit at the protocol maximum andInsufficientCyclescannot be raised. The setters therefore change nothing here — do not add them to fix an engine outcall, and do not diagnose an engine outcall failure as an exhausted budget. Your ownmax_response_bytesis the exception: it still bounds the response, so an oversized reply remains a genuine cause. (Nor does the pricing version itself need managing: where the replica lacks pay-as-you-go, version 2 is silently filtered to version 1, and both cost zero on an engine.) - Deleting or swapping the proxy of a signing app. The management canister derives keys from its caller (the proxy), so the proxy’s canister id is part of every address the app owns. Deleting a proxy (the console refunds its cycles) or repointing the app at another one silently produces different addresses and leaves any Bitcoin or Ethereum funds at the old ones permanently unspendable. A signing app’s proxy id is permanent infrastructure, not a fungible resource.
- Reading
CallFailedas “it did not happen”. The proxy relays with a bounded-wait call, so a downstreamSYS_UNKNOWNarrives asProxyError::CallFailedwith that reject code inreason. The target may have run and the cycles may be gone. Retrying a signature is harmless; retrying a stateful call on this basis is not: treat it as an unknown outcome (seemulti-canister). - Calling the console proxy from the CLI.
icp canister call --proxy <console-proxy-id>fails: the console proxy admits only the engine’s canister-id ranges and its own controllers, and an ingress call from your CLI identity is neither (it is rejected byinspect_messagebefore it reaches replicated state). For CLI-side proxying, deploy your own proxy (icp new … --subfolder proxy) which authorizes controllers, i.e. you.
Additional References
- Load
deploy-to-cloud-enginefor getting the app onto the engine: CLI identity linking, subnet-targeted deploy, console app metadata, and for deploying and funding the proxy this skill’s Rule 4 depends on, including the card-funded console flow, automatic top-up, and the self-deployed CLI alternative. - Load
vetkeysfor what vetKD is and how its keys are used (IBE, encrypted maps, transport keys). Its call code targets a normal subnet and attaches cycles through helper libraries: on an engine, keep the concepts and replace the calls with the proxied form above. - Load
https-outcallsfor everything about outcalls that is not engine-specific: transform functions,max_response_bytes, idempotency, debugging consensus failures. - Load
multi-canisterfor inter-canister call design, bounded vs unbounded wait semantics, andSYS_UNKNOWNhandling.