关于 cloud-engine-canisters
cloud-engine-canisters 是一项面向 Internet Computer Cloud Engine 开发的技能,说明其协议调用规则及 HTTPS 出站请求的处理方式。它帮助开发者区分 CloudEngine 子网与普通 Application 子网的约束,并给出跨子网调用的实现指导。
主要功能
- 01说明调用不得附加 cycles
- 02指导跨子网使用有界等待
- 03说明 HTTPS 出站请求的标准包装器用法
- 04列出需经控制台代理访问的服务
- 05提供 Motoko 与 Rust 调用示例
适用场景
- 01排查引擎 Canister 的 cycles 错误
- 02实现跨子网的有界等待调用
- 03为引擎 Canister 添加 HTTPS 出站请求
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dfinity/ icskills --skill cloud-engine-canisters安装后在 Claude Code 中输入/cloud-engine-canisters确认已加载。
详细介绍
Cloud Engine 容器
这是什么
云引擎是用户拥有的一部分 Internet Computer 容量,通过 Web 控制台管理(如何将代码部署到云引擎,请参阅 deploy-to-cloud-engine 技能)。引擎运行在专用的 CloudEngine 子网类型上,采用免费 cycles 费用方案:引擎容器执行的任何操作都不按 cycles 计费——执行、存储、消息传递和 HTTPS 外部调用均为零成本——并且引擎容器的 cycles 余额按设计为 0。
这种模式伴随协议强制执行的调用规则。在普通 Application 子网上可运行的代码,在引擎上可能失败;失败表现看起来像 cycles 或共识问题,实际原因却是代码针对了错误的子网类型。规则如下:
| 规则 | 在云引擎上 |
|---|---|
| 1 | 绝不为任何调用附加 cycles——移除 (with cycles = …) 和附加 cycles 的包装器 |
| 2 | 跨子网调用必须使用有界等待——Motoko 中使用 (with timeout = N),Rust 中使用 Call::bounded_wait |
| 3 | HTTPS 外部调用:普通代码,零成本——保留标准包装器,在自己的容器上进行转换,绝不要通过代理 |
| 4 | 会收取 cycles 的跨子网目标(XRC、阈值签名、vetKD、Bitcoin API、EVM/SOL RPC)必须通过控制台代理容器调用。编写这类代码前,请向用户询问代理容器的 id |
规则 1 — 绝不附加自行计算的 cycles 数量
引擎容器的 cycles 余额为 0,且无需支付任何费用:根据引擎的免费费用方案,Application 子网容器需要支付的所有费用(执行、消息传输、HTTPS 外部调用、阈值签名、vetKD、存储)均按零计费。
-
你编写的容器间调用:完全不要写 cycles 子句。 无需购买任何东西——引擎上的所有费用均按零计费——因此无论协议是否允许,附加 cycles 都没有必要。写成
await service.method(args),绝不要写成await (with cycles = 1_000_000) service.method(args)。在一个运行中的引擎容器上已验证(Cycles: 0):任何非零数量都会失败,并显示Canister <id> is out of cycles, error code Some("IC0504")不要试图将数量置零来“修复”。平台团队的建议很简单:在引擎调用中完全不要提 cycles;保留
cycles = 0只会造成无用噪声,而且一旦数量变为非零(例如计算出的费用或复制的常量),问题就会再次出现。如果在引擎容器中看到IC0504,说明某个调用附加了非零数量。 -
**Rust:**规则相同——绝不要在
Call构建器上调用.with_cycles(…)。(平台团队将 Motoko 规则表述为“完全不要提 cycles”;不使用.with_cycles就是 Rust 中的对应做法。) -
管理容器调用保留其标准包装器。
Call.httpRequest(Motoko,mo:ic)以及适用于你所用 crate 版本的 Rust 外部调用包装器(规则 3)会通过ic0.cost_*系统 API 获取费用;该 API 会根据费用方案计算费用:对于外部调用,它依据你自己的子网费用方案定价。引擎上的费用方案是免费的,因此返回 0,包装器不会附加任何费用,同一份源代码也能在 Application 子网上使用。会出问题的是改用硬编码或手动计算的费用——绝不要从https-outcalls费用表中取一个数值并将其附加到引擎调用上。 -
查询哪个子网的费用方案,正是两者的关键区别。 链密钥费用 API(
ic0.cost_sign_with_ecdsa及相关 API)依据持有密钥的子网计算费用,而不是你的子网——该子网会正常收费,因此即使从引擎调用,这些 API 也会返回非零费用,随后包装器会附加该费用。这就是为什么签名辅助函数在这里无法使用,而同一 crate 的外部调用构建器却可以(规则 3 和 4)。 -
唯一例外:跨子网目标收取的 cycles 必须放在控制台代理的
ProxyArgs.cycles内部(规则 4),绝不能附加到你的容器发出的调用上。
规则 2 — 跨子网调用必须使用有界等待
协议会拒绝任何与 CloudEngine 子网之间的跨子网(XNet)请求,只要该请求使用无界等待(保证响应)或携带 cycles。调用方会收到一条拒绝消息,内容如下:
Unbounded-wait calls and calls with cycles are not allowed to CloudEngine subnets
在引擎 canister 上实时验证:对另一个子网上的 canister 执行普通的 await,会返回内容完全相同的 #system_fatal 拒绝;而对同一调用使用 (with timeout = 30) 且不附带 cycles 子句,则会成功。
所有跨子网调用都必须使用有界等待且不携带 cycles:
// 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
- 同一子网内的调用不受此限制:同一引擎上的 canister 可以相互进行无界等待调用。不过,有界等待仍然是更好的默认选择——如果被调用方无响应,无界等待调用会无限期阻塞调用方的升级(参见
multi-canister技能)。 - 此限制适用于两个方向:普通子网上的 canister 调用引擎 canister 时,也必须使用有界等待且不携带 cycles 的调用。
- 有界等待调用可能会以
SYS_UNKNOWN(结果未知)结束。请妥善处理;multi-canister技能介绍了相关模式。
规则 3 — HTTPS 外部调用:普通代码,零成本
HTTPS 外部调用在引擎自己的子网上执行,成本 API 在该子网上会将其报告为免费,因此标准封装器可以原样使用——无需编写引擎专用的外部调用代码:
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);
-
保留包装器;绝不要换成硬编码费用(规则 1)。让包装器为调用定价,是如今运行在 Motoko 引擎上的应用编写外部调用的方式。
-
在 Rust 中,使用你的 crate 版本线提供的包装器——
ic-cdk-management-canister0.2 中的HttpRequest::new(url)…send()、0.1 中免费的ic_cdk_management_canister::http_request,或 ic-cdk 0.19 中的ic_cdk::management_canister::http_request(该模块已在 ic-cdk 0.20 中移除)。这三者在此处的行为完全相同:每个都会查询ic0.cost_*,该函数根据你自己的子网定价,因此在引擎上返回 0;每个也都会附加这个 0。按照项目实际使用的版本线编写,绝不要为了“修复”引擎外部调用而升级 crate 版本——根本没有需要修复的问题。https-outcalls介绍了这三者之间的差异。 -
ic-cdk-management-canister的外部调用构建器是此处受支持的路径——只有它的 chain-key 辅助工具在引擎上不可用(规则 4)。这个 crate 并非不可用;决定因素是目标子网。 -
在引擎上无需调整定价版本 2。 0.2 构建器始终使用定价版本 2(“按用量付费”),附加的 cycles 同时也是每个节点的支出预算——因此,在收费子网上,如果
with_expected_*设置得不够严格,远程服务收到请求后,调用可能会在中途失败。在引擎上不会发生这种情况:免费的费用表不会收费,因此预算不会限制任何内容——往返超时和转换指令限制均保持在协议最大值,也不会引发InsufficientCycles。在此处将with_expected_*设置器视为无操作:如果需要可为可移植性而设置,但绝不要为了修复引擎外部调用而调整它们,也绝不要将引擎外部调用失败归因于预算问题。仍然适用的是你自己的max_response_bytes——它在引擎上和其他环境一样会限制响应,因此超大响应确实会导致失败,也是此处唯一值得检查的外部调用限制。 -
两种定价版本在引擎上都可用,因此此处无需管理版本。 只有在副本启用了按用量付费的情况下才接受版本 2;如果没有启用,请求的版本 2 会被静默回退为版本 1,且不会报错。在收费子网上,这是资金配置不匹配;但在引擎上无害,因为两种版本下的外部调用都不收费。不要添加针对引擎的版本处理。
-
外部调用的其他方面都不变——你自己的 canister 上的转换函数、
max_response_bytes、幂等性。根据平台团队的说法,引擎支持完整的外部调用功能集,无论是否复制,因此非幂等或受速率限制的 API 可以使用is_replicated = ?false——批量工作负载也可以如此,因为复制调用会按副本数量倍增每个请求。有关这些内容,请加载https-outcalls技能。 -
绝不要通过控制台代理 canister 路由 HTTPS 外部调用。 平台团队明确表示:所有 HTTP 外部调用都应直接发起,而代理仅供需要 cycles 的主网服务使用。IC 要求转换函数位于发出
http_request的 canister 上——通过代理发起时,该函数必须位于代理本身,而代理没有提供转换方法(已根据其实时接口验证,团队也确认没有计划添加)。对于大多数真实 API,未转换的响应在不同副本之间会有差异,因此调用会失败,并显示:SYS_TRANSIENT: No consensus could be reached. Replicas had different responses代理外部调用也需要付费。代理位于普通的 Application 子网上,因此会从你充值的余额中扣除实际外部调用费用;而从你的引擎 canister 发起同一调用则完全免费。在迁移规模下——数千次调用——余额会耗尽,随后每次调用都会开始因
ProxyError::InsufficientCycles而失败。这是通过代理路由外部调用导致的症状,而不是引擎资金不足。这两种失败的解决方法相同:从你的 canister 直接发起外部调用(免费且可用)。重试或给代理充值都无法解决问题。
规则 4 — 通过控制台代理进行携带 cycles 的跨子网调用
两个事实指向同一个结论。规则 2 的限制意味着,引擎 canister 不能发送携带 cycles 的跨子网消息;此外,据平台团队所述,云引擎根本不提供阈值签名,因此必须在主网访问这些服务。无论哪种情况,都不能直接调用:
- 阈值 ECDSA / Schnorr 签名(
sign_with_ecdsa、sign_with_schnorr)及其公钥方法,以及 vetKD(vetkd_derive_key、vetkd_public_key)——引擎上不可用;请通过代理使用主网服务, - 汇率 canister(XRC)——一个会收取 cycles 的主网 canister,
- Bitcoin API(管理 canister 的
bitcoin_*方法)、EVM RPC 和 SOL RPC canister——每次调用都会收取 cycles。
控制台代理不会为你的 canister 中继其他内容(见下文“控制台代理会中继什么”)。这就是代理的全部用途:访问需要 cycles 的主网服务。它不用于 HTTPS 外呼(规则 3)。
解决办法是使用控制台代理 canister:将其部署在常规 Application 子网上,并由引擎余额提供资金。你的引擎 canister 向代理发起一次低成本、不消耗 cycles、具有有界等待时间的调用;代理在本地重新发起调用,并附带 cycles,然后将原始回复中继回来。同子网调用或不消耗 cycles 的调用不需要代理,HTTPS 外呼也不需要(规则 3)。
编写代码前:向用户索取代理
你的 canister 代码和你都无法创建控制台代理:引擎的所有者会从控制台部署代理,而应用只能将其 canister id 作为配置接收。因此,只要任务需要使用上述列表中的任何目标(功能请求、计划或现有代码移植),就要在编写代码前告知用户,而不是等到第一次调用在引擎上失败后再说:
-
用一句话说明应用需要引擎的代理 canister,以及原因。
-
询问引擎是否已有代理:
- 已有:索取其 canister id(engine → Canisters → Proxy canisters)。如果有多个代理,且应用需要签名或派生密钥,请询问应用应使用哪个代理:每个派生密钥都取决于这个选择(见“通过代理使用阈值密钥”)。绝不要替他们选择。
- 没有:请所有者部署一个:engine → Canisters → Proxy canisters → Deploy a proxy,然后确认。这里无需选择银行卡或金额:代理费用从引擎余额中支付,余额不足时也会从引擎余额自动补足。然后索取显示的 id。
例如:“此应用通过你引擎的代理 canister 进行签名。如果引擎已有代理,请把它的 id 发给我(engine → Canisters → Proxy canisters)。如果没有,引擎所有者可以在该页面通过 Deploy a proxy 部署一个:费用从引擎余额中支付,无需银行卡。然后把它的 id 发给我。”
-
与此同时继续工作:从 canister 环境变量(
PROXY_CANISTER_ID,见deploy-to-cloud-engine)读取 id,绝不要在源码中写入字面值。部署时需要该 id,编写代码时不需要。 -
收到 id 后,检查它是否为控制台代理。此查询免费且不需要项目:
icp canister call <proxy-id> get_allowed_ranges '()' -n ic --query非空的
vec { record { start = principal "…"; end = principal "…" } }表示这是一个对引擎的 canister 开放的控制台代理。如果返回错误,提示该 canister 没有此方法,或者列表为空,说明它不是控制台代理(可能是自行部署的代理,也可能根本不是代理):请用户重新提供。对于当前的控制台代理,get_funding(形式相同)还会显示其balance,以及引擎为其补充余额时的funding = opt record { engine_canister = …; … }。
绝不要编造 id、复用其他引擎或项目的 id,也不要在等待期间用自行部署的代理充数:它会拒绝你的引擎 canister,并派生出不同的密钥(见下表)。
获取代理
有两种不同的 canister 可以充当代理,二者不可互换:
| 控制台代理(引擎的) | 自部署代理(icp new … --subfolder proxy) | |
|---|---|---|
| 部署者 | 引擎的所有者,从控制台部署 | 你,使用 icp |
| 谁可以调用 | 引擎的canister ID 范围,以及控制器 | 仅控制器 |
| 你的引擎 canister 可以调用它 | 可以,无需额外设置 | 只有将每个 canister 添加为控制器后才可以 |
| 它为这些 canister 转发的目标 | 仅限上文列出的目标 | 任意目标 |
| 阈值密钥派生 | 按调用方隔离(见下文) | 不隔离:原样透传 |
| 资金来源 | 引擎的余额:余额不足时会自行充值,无需银行卡 | 你已有的 cycles(icp cycles mint) |
对于引擎 canister,请使用控制台代理。自部署代理适用于你通过命令行发起的调用(icp canister call --proxy、icp deploy --proxy);控制台代理无法用于此目的,因为它会拒绝任何非控制器主体发起的 ingress 调用,而你的命令行身份并非其控制器。
部署和充值这两种代理都不属于 canister 代码任务。相关内容见 deploy-to-cloud-engine 技能;在这里,你只需要最终的代理 canister ID。请向用户获取,或从应用配置中读取,切勿猜测并硬编码;在假设一个代理 ID 可以替换另一个之前,请先阅读下方的派生警告。
通过代理从你的 canister 发起调用
不要直接调用目标 canister,而是调用代理的 proxy 方法,并传入目标 ID、方法名、以 candid 编码的参数字节,以及要附加的 cycles。其 candid 接口如下:
type ProxyArgs = record { canister_id : principal; method : text; args : blob; cycles : nat };
type ProxySucceed = record { result : blob };
type ProxyError = variant {
InsufficientCycles : record { available : nat; required : nat };
CallFailed : record { reason : text };
UnauthorizedUser;
};
type ProxyResult = variant { Ok : ProxySucceed; Err : ProxyError };
service : {
proxy : (ProxyArgs) -> (ProxyResult);
get_allowed_ranges : () -> (vec record { start : principal; end : principal }) query;
};
args是目标方法的 candid 编码参数(由你编码);result是目标返回的原始回复字节(由你解码)。cycles是代理附加到转发调用上的数量——应根据目标收取的费用来确定(例如 XRC 或签名费用)。平台团队确认,附加多少由调用方决定;代理收到的args是不透明数据块,因此无法替你确定数量:你传入的常量就是它所要求的数量,而InsufficientCycles的required字段会原样返回你提供的数字,并不代表代理侧预留的数量。不要向外层的proxy调用附加 cycles;引擎子网禁止这样做,而且也没有必要。- 处理
ProxyError:InsufficientCycles表示代理余额过低(控制台代理会从引擎余额为自己充值,因此如果一直出现此错误,就表示该余额不足:陷阱 8);UnauthorizedUser表示调用方不在引擎范围内;CallFailed会携带下游拒绝原因,或者表示控制台代理不会转发该目标(见“控制台代理转发的目标”)。 - 多附加是安全的;少附加则不安全。 被调用方会退还未消耗的 cycles,退款会退回给代理(代理是该调用环节的调用方),因此提供充足的
cycles数量不会产生费用,还能应对费用上调。InsufficientCycles中的available是代理的可用余额(已扣除冻结储备),因此即使控制台中仍显示有余额,代理也可能报告InsufficientCycles。 CallFailed并不能证明调用没有发生。 代理使用有界等待的调用进行转发,因此下游的SYS_UNKNOWN会以CallFailed的形式传回给你,并在reason中包含该拒绝代码。目标可能已执行,cycles 也可能已经花掉。应将其视为“结果未知”,而非“调用失败”:multi-canister技能介绍了相关处理模式。
Motoko 示例(Rust 中的对应方式为 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 */ };
};
控制台代理中继的内容
控制台代理使用引擎的余额付款,因此,对于引擎上的 canister,它只中继代理应处理的内容:
- 管理 canister(
aaaaa-aa):sign_with_ecdsa、ecdsa_public_key、sign_with_schnorr、schnorr_public_key、vetkd_derive_key、vetkd_public_key、http_request和bitcoin_*方法(bitcoin_get_balance、bitcoin_get_utxos、bitcoin_send_transaction、bitcoin_get_current_fee_percentiles、bitcoin_get_block_headers); - 以下主网 canister 的任何方法:汇率 canister
uf6dk-hyaaa-aaaaq-qaaaq-cai、EVM RPC7hfb6-caaaa-aaaar-qadga-cai、SOL RPCtghme-zyaaa-aaaar-qarca-cai、比特币主网ghsi2-tqaaa-aaaan-aaaca-cai和测试网g4xu7-jiaaa-aaaan-aaaaq-cai,以及 Dogecoingordg-fyaaa-aaaan-aaadq-cai。
其他任何请求(调用其他 canister、deposit_cycles、create_canister、Cycles 账本)都会返回 CallFailed,原因以 a proxy funded by its engine relays only to … 开头。充值不会改变这一点。较旧的、由卡支付的控制台代理仍会中继所有请求,直到它改用引擎余额为止:不要依赖这一点。如果确实需要调用此列表之外的 canister 付款,就需要自部署代理,并将每个调用 canister 添加为控制器(参见 deploy-to-cloud-engine),再用你持有的 cycles 为其充值。
通过代理使用阈值密钥(派生密钥前请先阅读)
在引擎上,阈值 ECDSA、Schnorr 和 vetKD 是代理的主要用途,也是调用出错代价远高于收到错误消息的唯一情况:无法复现的密钥对应着一个你无法转移其资金的比特币或 Ethereum 地址。
链密钥辅助函数在这里不可用。 ic-vetkeys、ic-cdk-management-canister 的链密钥函数(sign_with_ecdsa、sign_with_schnorr、vetkd_derive_key 及其公钥对应函数),以及 Motoko 的 mo:ic-vetkeys/ManagementCanister,都会直接调用 aaaaa-aa,并自动附加费用。由于链密钥成本 API 按持有密钥的子网定价,即使在引擎上,该费用也不为零;而辅助函数会发起携带该费用、等待时间不受限的调用——这是跨子网且携带 cycles 的调用,因此会同时违反规则 1 和规则 2,被拒绝两次。你必须放弃的正是这些辅助函数带来的便利:自行编码管理 canister 参数,并通过代理发送。vetkeys 技能中的代码是为普通子网编写的,在引擎上运行前需要按此方式改写。
这里仅指链密钥函数,并非这些 crate 整体不可用。 ic-cdk-management-canister 也包含 HTTPS 外部调用构造器;这些是在引擎 canister 上发起外部调用的受支持方式(规则 3):它们面向你自己的子网,费用为 0,且不得通过代理路由。如果将“辅助函数在引擎上不可用”误解为“外部调用也要通过代理手动编码”,就会导致陷阱 4 中所述的共识和资金问题。应按目标子网区分 crate 的用法:本地外部调用 ⇒ 使用常规构造器;远程链密钥调用 ⇒ 使用代理。
代理会改写什么。 对于六种密钥方法(sign_with_ecdsa、ecdsa_public_key、sign_with_schnorr、schnorr_public_key、vetkd_derive_key、vetkd_public_key),控制台代理会解码你的参数,将派生范围限制为仅供你使用,再重新编码:
- ECDSA / Schnorr:将调用 canister 的 principal 插入
derivation_path的第一个元素。 - vetKD:将你的 principal 加到
context前面,并以长度标记:先放置一个字节,表示 principal 的长度,然后是 principal 字节,接着是你的 context。 - 对于三个
*_public_key方法,还会将canister_id强制设为None。 主体来自msg_caller(),因此无法伪造,而且一个引擎 canister 永远无法访问另一个 canister 的密钥。(已验证: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。)
费用。 ecdsa_public_key、schnorr_public_key 和 vetkd_public_key 免费:传入 cycles = 0。需要付费的方法由持有密钥的子网收费,而非你的子网:
| 密钥名称 | sign_with_ecdsa / sign_with_schnorr / vetkd_derive_key |
|---|---|
key_1(生产环境) | 26_153_846_153 |
test_key_1(测试环境) | 10_000_000_000 |
请向上取整,不要传入精确数值:未用完的余额会退还给代理。
示例:ECDSA,Motoko。 请自行声明管理 canister 类型;字段名称和变体标签必须与 candid 接口完全一致,因为代理会对数据块进行解码并重新编码;缺少或命名错误的字段,或未知的变体标签,都会导致解码失败(表现为 CallFailed,而非派生出错误的密钥)。代理未建模的字段不会被捕获:candid 的记录子类型规则会在解码时跳过该字段,并在重新编码时将其丢弃。
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");
请注意,sign_with_ecdsa 没有 canister_id 字段:它始终使用调用者的密钥进行签名,而通过代理调用时,调用者就是代理。因此,你发布的公钥和生成的签名必须通过同一个代理。
示例:vetKD,Motoko。 结构相同,但类型不同。vetkd_public_key 免费;vetkd_derive_key 需要付费。
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 使用相同的流程,通过 candid::encode_one / decode_one 和有界等待调用实现;类型结构请参阅规则 2 下的 Call::bounded_wait(proxy_id, "proxy") 示例。
派生密钥属于代理,而非你的应用。 管理 canister 根据其调用者派生密钥,而这个调用者就是代理,因此你获得的密钥取决于你经过的代理 canister ID:
- 将应用指向另一个代理(通过控制台中的“Deploy another proxy”再部署一个,或自行部署一个),所有地址都会改变。无法从新代理恢复旧地址。
- 删除代理会导致无法再访问其密钥。 控制台的删除按钮会将未用完的 cycles 退还到引擎的余额,但无法返还派生结果。通过该代理派生的地址持有的任何 Bitcoin 或 Ethereum 资金都会永久无法使用。请将签名应用的代理 ID 视为永久性基础设施;在将通过它派生的地址中的所有资产转移出去之前,切勿为了“节省 cycles”而删除代理。
- 自行部署的上游代理完全不提供隔离(没有主体前缀,也不强制
canister_id = None),因此其密钥也与控制台代理的不同:即使 ID 尚未变化,两者也不能互相替代。 - 任何在链下复现派生过程的实现(例如在前端派生相同的公钥)都必须复现代理注入的前缀,包括 vetKD 的长度标签。
常见陷阱
- 在编写的调用中附加 cycles。 引擎 canister 发起的跨 canister 调用不需要
(with cycles = N)子句,任何非零N都会失败并返回IC0504/is out of cycles(已在实际环境中验证)。应完全删除该子句,而不是将其设为cycles = 0:零值会被接受,因此只会掩盖错误,而不是改掉这个习惯。引擎 canister 执行的任何操作都不需要 cycles:根据引擎的免费费用方案,外部调用、签名和消息传递的费用均为零。 - 等待时间无界的跨子网调用。 对另一个子网上的 canister 直接执行
await service.method(args),属于等待时间无界的调用,会被拒绝,并显示 "Unbounded-wait calls and calls with cycles are not allowed to CloudEngine subnets"。在 Motoko 中使用(with timeout = N),或在 Rust 中使用Call::bounded_wait,并处理SYS_UNKNOWN。同一子网(引擎本地)调用不受此限制。 - 为引擎重写外部调用代码,或硬编码其费用。 外部调用无需采用引擎专用形式:保留
Call.httpRequest(Motoko);在 Rust 中,则使用项目依赖的 crate 版本所提供的封装(ic-cdk-management-canister0.2 中使用HttpRequest::new(url)…send();ic-cdk0.19 及更早版本才提供ic_cdk::management_canister::http_request,因为 0.20 移除了该模块)。这些调用查询的ic0.cost_*API 会根据你自己的子网定价,在引擎上返回 0。错误做法是将调用替换为根据 Application 子网费用表手动计算的费用,这会从余额为 0 的账户中扣除非零金额。 - 通过控制台代理路由 HTTPS 外部调用。 两种不同的故障,原因相同。共识方面:出现
SYS_TRANSIENT: No consensus could be reached. Replicas had different responses,因为 transform 必须位于发起调用的 canister 上;经过代理时,发起调用的 canister 就是代理本身,而代理没有提供任何 transform(也没有计划提供)。费用方面:代理位于普通的 Application 子网上,因此会从其余额中支付实际的外部调用费用;引擎会为该余额充值,而大量工作负载会耗尽余额,导致ProxyError::InsufficientCycles。应直接从自己的 canister 发起外部调用:在引擎上,这些调用免费且不计量,因此没有可耗尽的预算。 - 为引擎 canister 充值 cycles。 引擎 canister 按设计持有 0 cycles;你既不能也不必向其发送 cycles。从引擎 canister 读到“0 cycles”是正常情况,并非紧急状况——不要移植 Application 子网应用中的充值逻辑或 cycles 余额告警。
- 从引擎 canister 直接调用 XRC、阈值签名或 vetKD。 Cloud engine 完全不提供阈值签名(平台团队),因此必须在主网调用
sign_with_ecdsa/sign_with_schnorr/ vetKD;而跨子网且附带 cycles 的调用,正是 CloudEngine 子网 canister 无法发起的调用。应通过有资金支持的控制台代理路由这些调用(规则 4)。普通的同子网调用或不附带 cycles 的调用无需使用代理。 - 在外层
proxy调用中附加 cycles。 引擎子网禁止跨子网消息附带 cycles——这正是使用代理的原因。应将 cycles 放入ProxyArgs.cycles中(代理会在本地附加这些 cycles);绝不要在调用proxy本身时使用with cycles。 - 未先确认代理转发的调用类型,就为代理充值。 出现
ProxyError::InsufficientCycles时,先检查调用类型,再判断是否需要充值。HTTPS 外部调用根本不应通过代理(规则 3、陷阱 4):应将其移到自己的 canister 上,由其免费发起,而不是为本不该产生费用的工作购买预算;提高余额只会延后同样的停滞。只有代理真正承担的工作(XRC、阈值签名、vetKD、Bitcoin API、EVM/SOL RPC)出现余额耗尽,才是真正的资金问题。控制台代理每隔几分钟会从引擎余额为自己充值,并受每小时额度限制;因此,持续出现InsufficientCycles,说明引擎余额没有可用资金(所有者需为引擎充值),或应用的支出速度超过了额度的补充速度。代理没有充值按钮。较旧的信用卡付费代理则在引擎的 Canisters 页面中的 Proxy canisters 区域提供 Top up 和 Automatic top-up。余额说明请参见deploy-to-cloud-engine。 - 认为通过代理获取的密钥与直接调用时相同。 通过代理进行的阈值密钥派生会隔离调用者,因此派生出的密钥/地址与直接调用管理 canister 时不同。应始终通过代理获取公钥并签名;对于同一身份,不要混用直接调用和经代理的密钥调用。
- 在引擎上使用链密钥辅助库。
ic-vetkeys、ic-cdk-management-canister的链密钥函数,以及mo:ic-vetkeys/ManagementCanister都会直接调用aaaaa-aa并自行附加费用;即使在引擎上,费用也会是非零,因为链密钥费用 API 会根据持有密钥的子网定价。这会导致跨子网且附带 cycles 的调用,引擎会依据规则 1 和 2 拒绝此类调用。这些库的全部价值(类型正确、自动附加 cycles)恰恰是必须舍弃的:应手动编码管理 canister 参数,再通过代理转发。从vetkeys技能复制的代码未经修改,无法在引擎上运行。 - 过度解读上述警告,连外部调用也通过代理转发。 上述警告针对的是链密钥函数,而不是包含这些函数的 crate。Rust 外部调用代码也应使用
ic-cdk-management-canister(规则 3),且其HttpRequest构建器无需修改即可在引擎上正常使用。若得出“这个 crate 在引擎上不可用,因此必须手动编码外部调用并通过代理转发”的结论,就会直接落入陷阱 4——transform 无法生效,代理余额还会耗尽。应根据目标子网判断,而不是根据 crate 名称:aaaaa-aa的http_request是本地调用且免费;签名和 vetKD 则是远程调用且需要付费。 - 为引擎调整 pricing-version-2 预算。
ic-cdk-management-canister0.2 始终使用 version 2 定价,其中附加的 cycles 同时也是每个节点的预算,而范围过窄的with_expected_*可能导致调用执行到一半时失败。在引擎上,免费费用方案不会扣费,因此预算不会限制任何内容:超时和 transform 指令上限都处于协议允许的最大值,也不会触发InsufficientCycles。因此,这些 setter 在这里不会改变任何结果——不要用它们来修复引擎外部调用,也不要将引擎外部调用失败归因于预算耗尽。你自己的max_response_bytes是例外:它仍会限制响应大小,因此响应过大仍然是实际故障原因。(也无需管理定价版本本身:如果副本不支持按量付费,version 2 会被静默过滤为 version 1,而两者在引擎上的费用都为零。) - 删除或更换签名应用的代理。 管理 canister 根据其调用者派生密钥,因此代理的 canister id 是应用所拥有的每个地址的一部分。删除代理(控制台会将其 cycles 退回引擎余额),或将应用改为指向另一个代理,都会悄然生成不同的地址,导致留在旧地址中的 Bitcoin 或 Ethereum 资金永久无法支出。签名应用的代理 id 是永久性基础设施,不是可互换的资源。
- 将
CallFailed理解为“调用没有发生”。 代理使用有界等待调用进行转发,因此下游的SYS_UNKNOWN会作为ProxyError::CallFailed返回,且拒绝代码会包含在reason中。目标可能已经运行,cycles 也可能已经花掉。根据这一点重试签名没有问题;但重试有状态调用则不行:应将结果视为未知(参见multi-canister)。 - 从 CLI 调用控制台代理。
icp canister call --proxy <console-proxy-id>会失败:控制台代理只允许引擎的 canister id 范围内的 canister 及其自身控制器通过;来自 CLI 身份的 ingress 调用两者都不符合(该调用会在到达副本状态前被inspect_message拒绝)。如需在 CLI 端使用代理,应部署自己的代理(icp new … --subfolder proxy),它会授权控制器,也就是你。 - 未先申请代理就编写依赖代理的代码。 签名、vetKD、XRC、Bitcoin API 或 EVM/SOL RPC 代码即使没有可用代理,也可以编写甚至部署,但会在首次调用时失败:占位符或猜测的 id 会返回
UnauthorizedUser或无法到达任何目标;从其他引擎或项目复制的 id 则会使用他人的密钥签名。你无法自行创建控制台代理:没有对应的icp命令,且只有引擎所有者能从控制台部署代理。确认有需要时就应立即申请(规则 4 中的“编写代码之前”),从配置中读取 id,绝不要用自己部署的代理替代引擎代理。 - 将调用转发给控制台代理不支持的目标。 对于引擎的 canister,控制台代理只会转发管理 canister 的阈值密钥、
http_request和bitcoin_*方法,以及 XRC、EVM RPC、SOL RPC、Bitcoin 和 Dogecoin canister 的调用。其他任何调用,包括会收取 cycles 的第三方 canister,都会返回CallFailed(a proxy funded by its engine relays only to …),与代理余额无关。不要为其充值或重试:应通过将发起调用的 canister设为控制器的自部署代理路由此类调用,或移除该依赖。
其他参考资料
- 加载
deploy-to-cloud-engine,了解如何将应用部署到引擎:CLI 身份关联、指定子网的部署、控制台应用元数据,以及本技能规则 4 依赖的代理部署和资金注入:控制台流程(由引擎所有者部署,费用从引擎余额中支付)、不同余额分别支付哪些费用,以及自行部署的 CLI 替代方案。 - 加载
vetkeys,了解 vetKD 是什么,以及如何使用其密钥(IBE、加密映射、传输密钥)。其中的调用代码面向普通子网,并通过辅助库附加 cycles:在引擎上,保留这些概念,并将调用替换为上述代理形式。 - 加载
https-outcalls,了解所有与出站调用相关但非引擎特有的内容:转换函数、max_response_bytes、幂等性,以及共识失败的调试。 - 加载
multi-canister,了解跨 Canister 调用设计、有界与无界等待语义,以及SYS_UNKNOWN的处理。