关于 wallet-integration
面向 ICP dapp 开发者的外部钱包集成技能,使用 `@icp-sdk/signer` 让钱包处理用户批准的 Canister 调用,应用无需持有密钥。内容涵盖多种连接传输、能力协商、权限、账户、调用和错误处理。
主要功能
- 01集成外部签名钱包
- 02支持弹窗、重定向和扩展传输
- 03协商签名器能力与权限
- 04获取账户并发起 Canister 调用
- 05处理连接生命周期与错误
适用场景
- 01为转账操作请求钱包批准
- 02连接浏览器扩展签名器
- 03在弹窗受限时使用重定向流程
- 04为 dapp 集成通用签名器
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dfinity/ icskills --skill wallet-integration安装后在 Claude Code 中输入/wallet-integration确认已加载。
详细介绍
钱包集成
这是什么
将外部钱包连接到你的 dapp,让用户在钱包中批准操作,而不是把密钥交给你的应用。@icp-sdk/signer 是依赖方客户端:你的应用是依赖方,钱包是签名方,双方通过 ICRC 签名方标准定义的传输方式交换 JSON-RPC 2.0 消息。
本技能介绍如何集成签名方。实现签名方不在本技能范围内——同意界面、提示词注册和账户托管都是钱包的职责。
示例使用 OISY(https://oisy.com/sign),但这里的内容并非 OISY 专属。对于其他 Web 签名方,通常只需更改传输 URL;而 BrowserExtensionTransport 可以发现你从未硬编码过的扩展签名方。特定签名方实际支持什么,与哪种传输方式可以连接到它是两个不同的问题——应协商能力,而不是自行假设(参见协商能力和陷阱 5)。
| 标准 | 提供的功能 | API |
|---|---|---|
| ICRC-25 | 能力发现 + 权限生命周期 | getSupportedStandards, requestPermissions, getPermissions |
| ICRC-27 | 用户的账户 | getAccounts |
| ICRC-29 | 通过 postMessage 实现弹窗传输 | PostMessageTransport |
| ICRC-49 | 执行 canister 调用 | SignerAgent(或原始基础操作 callCanister) |
| ICRC-94 | 浏览器扩展发现 | BrowserExtensionTransport.discover |
| ICRC-167 | 顶层重定向传输 | UrlTransport(签名方 6 中新增) |
模型是什么
每次写入操作都是一项由用户批准的独立行为。你的应用请求钱包执行一次 canister 调用(ICRC-49);钱包向用户展示即将执行的操作,用户批准后,钱包签名并提交该操作。你的应用从不持有密钥,也不会在无人值守的情况下代表用户行事。
这就是整体模式,也正因如此,钱包集成适用于有意执行的高价值操作——转账、授权、铸造——却不适用于频繁或不可见的操作。如果用户不应该为每项操作都看到提示,你需要的就不是钱包,而是身份验证。
不应使用本技能的情况
- 会话——登录一次,然后多次操作。 这是身份验证,而非钱包集成:委托的范围有限,由身份提供方签发。请使用 internet-identity 技能。ICRC-34 确实存在,但它是一种身份验证机制,不在本技能范围内。
- Internet Identity 登录 → 请使用 internet-identity 技能。II 是身份提供方,而不是 ICRC-25 签名方。
- 让 Agent 或 CLI 以用户身份操作 → 请使用 agent-web-identity 技能。
- 构建钱包 → 如上所述,不在本技能范围内。
前提条件
npm i '@icp-sdk/signer@^6' '@icp-sdk/core@^6'
如果你要调用 ICP/ICRC 账本,请添加 @icp-sdk/canisters@^4(它会将 @dfinity/utils@^5 作为对等依赖引入)。将 @icp-sdk/core 固定为 ^6:签名方 6 需要它作为对等依赖,@icp-sdk/auth@^10 和 @icp-sdk/canisters@^4 也一样。
传输 URL 必须处于安全上下文中——HTTPS、localhost 或 127.0.0.1。
选择传输方式
传输方式是唯一负责了解如何连接钱包的部分;无论选择哪种方式,其上层的 Signer API 都相同。
| 传输方式 | 机制 | 适用场景 |
|---|---|---|
PostMessageTransport | 弹窗,通过 icrc29_status 完成握手,然后使用 postMessage | OISY 等 Web 钱包的默认方式 |
UrlTransport | 导航顶层窗口;钱包返回到你的 callbackUrl | 移动设备,或任何会阻止弹窗的场景 |
BrowserExtensionTransport | 扩展通过 window 事件进行通告 | 扩展钱包;发现未知签名方 |
import { Signer } from '@icp-sdk/signer';
import { PostMessageTransport } from '@icp-sdk/signer/web';
// One Signer per wallet connection; safe to create at module scope.
const signer = new Signer({
transport: new PostMessageTransport({ url: 'https://oisy.com/sign' })
});
扩展(ICRC-94)
import { Signer } from '@icp-sdk/signer';
import { BrowserExtensionTransport } from '@icp-sdk/signer/extension';
// Discovery order is whichever extension announced first, not a user
// preference, so return the list rather than choosing. Each provider carries
// { uuid, name, icon, rdns } — enough to render a picker.
function discoverExtensionSigners() {
return BrowserExtensionTransport.discover();
}
// Build the signer only from the uuid the user picked.
async function connectExtensionSigner(uuid: string) {
const transport = await BrowserExtensionTransport.findTransport({ uuid });
return new Signer({ transport });
}
重定向(ICRC-167)
UrlTransport 会在每次请求时卸载页面,因此它会在 sessionStorage 中记录调用顺序日志,并在钱包返回时重放。两条规则决定了它能否正常工作:
- 每次加载时都以相同顺序发出相同请求。 只能根据先前结果中恢复的值进行分支。若重放与日志不匹配,偏差保护机制会拒绝重放。
- 任何必须返回相同值的内容都要经过
memoize()——尤其是 nonce。其结果会被记录并重放,而不是重新运行。确定性的异步工作无需使用memoize:构建HttpAgent每次加载都会得到等效的 Agent,因此不会与日志产生偏差。
SignerAgent 使用此传输,因此重定向流程与弹出窗口流程使用的是相同代码:
import { IcrcLedgerCanister, toCandidAccount, type IcrcAccount } from '@icp-sdk/canisters/ledger/icrc';
import { HttpAgent } from '@icp-sdk/core/agent';
import type { Principal } from '@icp-sdk/core/principal';
import { Signer } from '@icp-sdk/signer';
import { SignerAgent } from '@icp-sdk/signer/agent';
import { UrlTransport } from '@icp-sdk/signer/web';
const transport = new UrlTransport({
// The path is the wallet's own; ICRC-167 does not dictate one.
url: 'https://wallet.example.com/sign',
// Absolute, fragment-free, on an origin you control, and declared in that
// origin's /.well-known/ii-auth-callbacks (see pitfall 9).
callbackUrl: 'https://app.example.com/signer-callback'
});
// Run this on the load of the callback route: a fresh arrival starts the flow,
// the wallet's return replays it. No separate resume or cleanup call.
async function transferOverRedirect(
account: IcrcAccount, to: IcrcAccount, amount: bigint, ledgerId: Principal
) {
const signer = new Signer({ transport });
// Deterministic, so no memoize needed — the same agent is built on each load.
const agent = await HttpAgent.create({ host: 'https://icp-api.io' });
const signerAgent = await SignerAgent.create({ signer, account: account.owner, agent });
const ledger = IcrcLedgerCanister.create({ agent: signerAgent, canisterId: ledgerId });
return ledger.transfer({
to: toCandidAccount(to),
from_subaccount: account.subaccount,
amount
});
}
使用 SignerAgent 而非 signer.callCanister,才能进行内容映射和证书检查;callCanister 是原始的 ICRC-49 基元,只会验证回复是否包含这两个字段。除非你有特定理由要自行调用该基元,否则应优先使用该 Agent;若自行调用,则必须由你负责在信任回复之前验证证书。
协商能力
只有在你将钱包写死且清楚它支持哪些功能时,才可以跳过此步骤。对于通用集成,此步骤能确保你充分了解情况:签名者可能会公开账户但不执行调用,也可能支持你尚未构建的传输方式。
import { Signer } from '@icp-sdk/signer';
async function capabilities(signer: Signer) {
const standards = await signer.getSupportedStandards(); // [{ name: 'ICRC-27', url }, ...]
const names = new Set(standards.map(({ name }) => name));
return {
canListAccounts: names.has('ICRC-27'),
canCallCanisters: names.has('ICRC-49')
};
}
getSupportedStandards 无需权限,因此可以安全地作为首次调用。
权限和账户
大多数签名者会将每个权限范围的初始状态设为 ask_on_use,首次使用每个方法时会提示用户——但 ICRC-25 将初始状态交由签名者策略决定,因此只有 getPermissions() 才是权威依据。requestPermissions 是可选的:它可以用一次事先请求,换取之后少几次提示。
| 状态 | 行为 |
|---|---|
granted | 无需提示即可继续 |
denied | 立即以错误 3000 拒绝 |
ask_on_use | 首次使用时提示(通常的初始状态) |
import type { PermissionScope, Signer } from '@icp-sdk/signer';
// The two scopes this skill uses:
// [{ method: 'icrc27_accounts' }, { method: 'icrc49_call_canister' }]
async function connect(signer: Signer, scopes?: PermissionScope[]) {
// Omitting `scopes` sets nothing: it leaves whatever states the signer
// already holds for your origin, which a previous session may have left
// `granted` or `denied`. Do not count on being prompted — getPermissions()
// is the only way to know. Supply scopes to trade several later prompts for
// one up front, and ask only for what your path uses:
// a scope the signer does not support is dropped before the prompt is drawn,
// so it costs nothing, but a supported one you never exercise is shown to
// the user for no reason.
if (scopes !== undefined) {
await signer.requestPermissions(scopes);
}
// ICRC-27 returns the accounts the user chose to share, as a list: it can be
// empty (they declined) and it can hold several. Hand it back whole and let
// the user pick rather than indexing blindly — see pitfall 4.
//
// Each element is { owner: Principal, subaccount?: Uint8Array } — already an
// IcrcAccount. Usually there is no subaccount, since signers commonly offer
// only the default one, but keep both halves anyway: it costs nothing.
return signer.getAccounts();
}
getPermissions() 会读取当前状态,不会触发提示。不要跨会话缓存该状态——钱包可能会使授权过期,届时授权会悄然恢复为 ask_on_use。
执行用户批准的调用
SignerAgent 实现了 Agent,因此可以用于任何接受该接口的对象:来自 @icp-sdk/canisters 的账本客户端,或用于你自己的 canister、来自 @icp-sdk/bindgen 的 actor。每次调用都会触发钱包提示。
import { IcrcLedgerCanister, type IcrcAccount } from '@icp-sdk/canisters/ledger/icrc';
import { HttpAgent } from '@icp-sdk/core/agent';
import { Principal } from '@icp-sdk/core/principal';
import { Signer } from '@icp-sdk/signer';
import { SignerAgent } from '@icp-sdk/signer/agent';
const ICP_LEDGER = Principal.fromText('ryjl3-tyaaa-aaaaa-aaaba-cai');
// Two clients against the same ledger: one for reads, one for writes.
async function connectLedger(signer: Signer, account: IcrcAccount) {
// One HttpAgent serves both. It answers reads directly, and SignerAgent
// borrows it for the root key and status instead of building its own.
const agent = await HttpAgent.create({ host: 'https://icp-api.io' });
// SignerAgent routes calls as a principal; it has no subaccount field.
const signerAgent = await SignerAgent.create({ signer, account: account.owner, agent });
return {
// Anonymous by default — public reads need no wallet and no prompt.
read: IcrcLedgerCanister.create({ agent, canisterId: ICP_LEDGER }),
write: IcrcLedgerCanister.create({ agent: signerAgent, canisterId: ICP_LEDGER }),
signerAgent
};
}
读取时使用普通 Agent,写入时使用签名者 Agent。 SignerAgent.query() 会将每个查询升级为经由钱包路由的完整 canister 调用,因此检查余额也会在用户面前弹出批准提示。公开数据根本不需要钱包——使用普通 HttpAgent 读取即可,它默认是匿名的:
import { type IcrcAccount, toCandidAccount } from '@icp-sdk/canisters/ledger/icrc';
import { Signer } from '@icp-sdk/signer';
async function showBalanceThenTransfer(
signer: Signer, account: IcrcAccount, to: IcrcAccount, amount: bigint
) {
const { read, write } = await connectLedger(signer, account);
// balance() takes an IcrcAccount directly.
const balance = await read.balance(account); // no prompt
const block = await write.transfer({ // prompts
// The ledger's `to` is the Candid shape; convert rather than hand-roll it.
to: toCandidAccount(to),
// The subaccount the tokens leave from.
from_subaccount: account.subaccount,
amount
});
return { balance, block };
}
signerAgent.replaceAccount(principal) 会切换之后签名写入操作所用的 principal,无需重新构建 Agent。
通道生命周期与页面重新加载
autoCloseTransportChannel 默认为 true:每次响应后约 200 ms,通道就会关闭,因此弹出窗口不会一直打开。对于多步骤流程,如果请求之间要等待你自己的异步工作,请关闭此选项,否则通道会在你等待时关闭。
import { Signer } from '@icp-sdk/signer';
async function multiStepFlow(signer: Signer) {
signer.autoCloseTransportChannel = false;
try {
const accounts = await signer.getAccounts();
await saveSelectionToYourBackend(accounts); // your own async work; channel stays open
return await signer.requestPermissions([{ method: 'icrc49_call_canister' }]);
} finally {
signer.autoCloseTransportChannel = true;
await signer.closeChannel();
}
}
连接无法在页面重新加载后继续保持。 没有可供恢复的持久会话——通道是指向已关闭弹出窗口的实时 postMessage 链接。可行的模式是持久化账户,使用匿名 Agent 根据账户呈现只读状态,并在首次写入时再按需重新建立签名者连接:
import { type IcrcAccount, decodeIcrcAccount, encodeIcrcAccount } from '@icp-sdk/canisters/ledger/icrc';
import { HttpAgent } from '@icp-sdk/core/agent';
import { Signer } from '@icp-sdk/signer';
import { SignerAgent } from '@icp-sdk/signer/agent';
const SESSION_KEY = 'wallet-account';
// On connect: remember the account, not the channel. The ICRC-1 textual
// encoding round-trips owner and subaccount as one string, so the
// subaccount survives the reload too (see pitfall 12).
function rememberAccount(account: IcrcAccount) {
sessionStorage.setItem(SESSION_KEY, encodeIcrcAccount(account));
}
// On reload: read-only state renders from this immediately, with no popup.
function restoreAccount(): IcrcAccount | null {
const stored = sessionStorage.getItem(SESSION_KEY);
if (stored === null) return null;
try {
return decodeIcrcAccount(stored);
} catch {
sessionStorage.removeItem(SESSION_KEY); // stale or malformed
return null;
}
}
// On the first write after a reload. This reopens the popup, so it must run
// from the click that starts that write — pitfall 1 applies here too.
async function ensureSignerAgent(signer: Signer, account: IcrcAccount, agent: HttpAgent) {
const offered = await signer.getAccounts(); // re-establishes the channel
// The user may have switched accounts while the page was gone, so the stored
// one is a guess until the wallet confirms it. Compare encodings, not owners:
// the same principal with a different subaccount is a different account.
const id = encodeIcrcAccount(account);
if (!offered.some((offer) => encodeIcrcAccount(offer) === id)) {
sessionStorage.removeItem(SESSION_KEY);
throw new Error('the wallet no longer offers the stored account; reconnect');
}
return SignerAgent.create({ signer, account: account.owner, agent });
}
将“断开连接”视为清除你自己的状态——无需调用钱包端的登出操作。
错误处理
import { Signer, SignerError } from '@icp-sdk/signer';
import { PostMessageTransportError } from '@icp-sdk/signer/web';
import type { IcrcAccount } from '@icp-sdk/canisters/ledger/icrc';
async function safeTransfer(
signer: Signer, account: IcrcAccount, to: IcrcAccount, amount: bigint
) {
try {
await showBalanceThenTransfer(signer, account, to, amount);
} catch (err) {
// Anything that is not a SignerError — SignerAgentError above all, where the
// wallet responded and the response failed validation — is not handled here.
if (!(err instanceof SignerError)) throw err;
switch (err.code) {
case 3001: return; // user cancelled — not a failure
case 3000: showPermissionHelp(); return; // permission denied
case 2000: showUnsupported(); return; // wallet does not support the method
case 4000: // every transport failure lands here
case 4001: // only if the signer itself returns it
// The transport error is the `cause`, never the error you caught.
if (err.cause instanceof PostMessageTransportError) showPopupBlockedHelp();
else promptReconnect();
return;
}
// ICRC-25 numbers errors by range and a signer may return a code this
// switch has never seen, so fall back on the range rather than rethrowing.
switch (Math.floor(err.code / 1000)) {
case 3:
// 3xxx is "user action", so nothing broke — but 3001 is the only code
// where silence is right, because there you know they cancelled on
// purpose. For an unnamed 3xxx say the action did not go through, or
// the UI sits unchanged after the user pressed the button.
showActionNotCompleted();
return;
case 2: showUnsupported(); return; // 2xxx not supported
case 4: promptReconnect(); return; // 4xxx transport channel
default: throw err; // 1xxx generic, and anything else
}
}
}
ICRC-25 将错误分为多个范围,并在每个范围内列出一些错误代码。请处理你了解的代码,再根据范围回退处理——签名者可能会返回 3002 或 4002,直接使用 default: throw 会导致处理不当:
| 范围 | 代码 | 含义 | 处理方式 |
|---|---|---|---|
1xxx 通用 | 1000 | 通用错误 | 将 err.data 显示给开发者 |
2xxx 不支持 | 2000 | 不支持 | 先协商功能 |
3xxx 用户操作 | 3000 | 未授予权限 | 说明需要重新授予什么权限 |
3001 | 操作已中止——用户已取消 | 静默返回;这是正常情况 | |
4xxx 传输 | 4000 | 网络错误 | 重新连接——库也会将每次传输失败报告为此代码 |
4001 | 传输通道已关闭 | 重新连接(仅限签名者报告;见下文) |
有两种失败不会以你预期的类型出现,而且需要采取截然相反的应对方式:
- 传输失败会以代码为
4000的SignerError到达。Signer.openChannel()会捕获传输抛出的任何错误——PostMessageTransportError、UrlTransportError、BrowserExtensionTransportError——并将其重新抛出为SignerError,原始错误会作为cause。因此,被阻止的弹出窗口并不是instanceof PostMessageTransportError;应检查err.cause。库也不会发出4001:“通道在收到响应前关闭”同样是4000,只有签名者自身返回4001时你才会收到它。 SignerAgentError——钱包确实响应了,但响应未通过验证:返回的内容映射与发送的调用不匹配(canister、方法、参数、发送方、nonce),证书未通过 IC 根密钥验证,或者回复不在经过认证的树中。SignerAgent会代你执行这些检查,因此这意味着钱包返回了不应返回的内容。不要将其视为连接故障并重试——应将错误呈现出来。
常见陷阱
-
在点击处理程序之外打开弹出窗口。
PostMessageTransport会拒绝非用户发起的连接(detectNonClickEstablishment默认值为true),因为 Safari 等浏览器会阻止此类弹出窗口。请从事件处理程序中连接,切勿在挂载时或useEffect中连接。// WRONG — blocked, and the transport detects it useEffect(() => { signer.getAccounts(); }, []); // CORRECT button.addEventListener('click', () => signer.getAccounts()); -
通过
SignerAgent读取数据。query()会升级为经由钱包路由的更新调用,因此每次读取都会要求用户批准。公共数据——例如账本余额、代币元数据——不需要钱包:使用普通的HttpAgent读取,默认采用匿名身份。只有写入操作才通过SignerAgent。 -
以为连接在页面重新加载后仍然有效。 通道不会在页面关闭后继续存在。根据常见陷阱 12,持久化保存账户的两部分信息,以便只读渲染;首次写入时再重新连接。见上文。
-
不加检查地访问
getAccounts()的索引。 它会返回用户选择共享的账户列表。ICRC-27 允许签名者提示用户进行选择,因此列表可能为空——用户拒绝了,这不是错误;也可能包含多个账户,而[0]会在不提示的情况下替用户选择。空列表上的accounts[0]会得到undefined,因此崩溃会发生在之后访问.owner时,而不是调用时。请检查列表长度;如果有多个账户,则提供选择器。 -
假设钱包支持特定功能。 调用
getSupportedStandards()。签名者可能会列出账户(ICRC-27),但不执行调用(ICRC-49),也可能使用你尚未实现的传输方式。 -
针对某个钱包的非标准错误代码编写处理逻辑。 ICRC-25 规定了
1xxx–4xxx范围,并列出了其中的1000/2000/3000/3001/4000/4001。这些范围以外的代码属于供应商扩展,不具备可移植性——此技能的早期版本曾记录503 BUSY,但它只存在于@dfinity/oisy-wallet-signer中,不属于任何标准。请针对列出的代码进行分支处理,根据范围回退处理,并将范围以外的代码视为通用错误。 -
通过
memoize()记录不可序列化的数据。 它会通过 JSON 持久化,因此Uint8Array往返后会变成{"0":1,"1":2,…},而CryptoKey会变成{}—— 这些变化不会报错,直到返回时流程才会失败。先将数据转换为普通数组(或十六进制),再进行记忆化,之后再转换回来。 -
重定向重放时出现分歧。 使用
UrlTransport时,每次加载都必须以相同顺序发出相同请求并执行相同的memoize步骤;请求所依赖的一切内容——尤其是 nonce——都必须通过memoize处理。在返回加载时重新获取一次性值会使流程失效。 -
callbackUrl是相对地址、带有片段,或未正确提供服务。 它必须是绝对地址、不带片段(传输层会自行追加片段),位于你控制的源上,并在该源的/.well-known/ii-auth-callbacks中声明——必须完全匹配完整 URL。仅声明还不够:钱包会跨源读取该文档,因此必须将其作为 JSON 提供并启用 CORS,否则即使回调地址正确列出,验证仍会失败。{ "callbacks": ["https://app.example.com/signer-callback"] }使用
@dfinity/static-site时,这是一个_headers配置块:/.well-known/ii-auth-callbacks Content-Type: application/json Access-Control-Allow-Origin: *验证采取失败即关闭的策略——如果地址未声明、无法读取或未完全匹配,响应就不会返回。请参阅 internet-identity 技能,其中也介绍了用于重定向登录的同一文件。
-
在钱包代码中使用顶层
await。 此处的每个调用都是异步的;对于PostMessageTransport,模块加载时的await会在用户手势之外打开弹出窗口,因此传输层会拒绝该操作——参见陷阱 1。请将这些调用封装在由 UI 调用的函数中。UrlTransport则恰好相反:它会导航顶层页面,而不是打开弹出窗口,因此没有手势检查;它的流程必须在回调路由加载时重新运行,以便重放日志——若要求用户在那里点击,就会破坏流程。无论哪种情况,都不要指望构建过程能发现意外的顶层await:Vite ≤5 默认使用es2020,会直接拒绝它;而 Vite 6+ 默认使用baseline-widely-available,允许使用它。 -
将
@icp-sdk/canisters@^3与@icp-sdk/signer@^6搭配使用。 二者无法共存——canisters 3 依赖@icp-sdk/core@^5或更早版本,而 signer 6 依赖^6,因此npm install会因ERESOLVE失败。请升级到@icp-sdk/canisters@^4和@dfinity/utils@^5。不要使用--legacy-peer-deps:它会跳过 peer 检查,仍然安装不兼容的组合,因此兼容性问题会在运行时而不是安装时暴露出来。 -
将账户视为单纯的 principal。
getAccounts()返回{ owner, subaccount? }——即IcrcAccount。通常不会提供子账户,因为 signer 通常只提供默认子账户;因此,假设账户只有 principal 的代码,只有遇到不提供该形式的 signer 时才会出问题。请完整保留账户,并让库的辅助函数处理其余工作:比较时使用encodeIcrcAccount(),不要只比较owner(该编码会规范化默认子账户,因此同一 principal 配上不同子账户时会正确地被视为不同账户);持久化时使用encodeIcrcAccount()/decodeIcrcAccount();发送时,发送方使用from_subaccount,接收方使用toCandidAccount()。SignerAgent是例外——它的account是一个Principal,因此子账户会放在账本调用参数中传递。 -
连接后立即发起调用。 应由用户主动发起。连接后未经提示就弹出审批对话框,看起来像是攻击,钱包完全有权拒绝此操作。
使用真实钱包进行测试
无需运行本地签名器:OISY 托管在远程服务上,传输层的安全上下文要求适用于签名器的 URL,而非你的源站 — https://oisy.com/sign 满足此要求。从 localhost 提供你自己的前端没有问题,它也是浏览器安全上下文,因此 WebCrypto 密钥生成在此处也能正常工作。请使用测试网代币进行测试,而非主网资产。
icp network start -d
icp deploy
从 ICP Faucet 获取免费的测试网代币,并将 OISY 切换到 IC(测试网代币) 网络即可查看。可用的账本:
| 代币 | 账本 canister |
|---|---|
| TESTICP | xafvr-biaaa-aaaai-aql5q-cai |
| TICRC1 | 3jkp5-oyaaa-aaaaj-azwqa-cai |
即使从 localhost 提供服务,也请在 Agent 上设置 host: 'https://icp-api.io' — host 是 API 调用发送到的端点,不是应用提供服务的源站。
预期行为
- 首次调用
getAccounts()会打开钱包,并解析为用户选择共享的账户,格式为{ owner: Principal, subaccount?: Uint8Array }— 也可能一个都没有。 - 通过
SignerAgent执行账本transfer时,会向用户显示待批准的调用,并解析为bigint区块索引。 - 取消canister 调用审批会以
SignerError拒绝,且code === 3001。另外两种拒绝的表现不同:拒绝共享账户时,getAccounts()会解析为[];拒绝权限申请时,错误代码为3000。 - 重新加载后,只读状态会在不弹出窗口的情况下呈现;首次写入操作会重新打开窗口。
其他参考资料
- internet-identity — II 登录;如果你需要的是会话而非每次操作都审批,可前往此处
- agent-web-identity — 让 Agent 或 CLI 在 II 应用中代表用户进行操作
- icp-cli — 使用
@icp-sdk/bindgenactor 通过SignerAgent调用你自己的 canister - canister-security — 调用到达后,在后端验证
msg.caller - OISY 签名器演示 — 基于
@icp-sdk/signer构建的可运行依赖方(React) - ICRC 签名器标准 — 上述每种方法所依据的规范