mcpField
W
wallet-integration

wallet-integration

github.com/dfinity开发39287 次安装今天更新
代码仓库

关于 wallet-integration

面向 ICP dapp 开发者的外部钱包集成技能,使用 `@icp-sdk/signer` 让钱包处理用户批准的 Canister 调用,应用无需持有密钥。内容涵盖多种连接传输、能力协商、权限、账户、调用和错误处理。

主要功能

  1. 01集成外部签名钱包
  2. 02支持弹窗、重定向和扩展传输
  3. 03协商签名器能力与权限
  4. 04获取账户并发起 Canister 调用
  5. 05处理连接生命周期与错误

适用场景

  1. 01为转账操作请求钱包批准
  2. 02连接浏览器扩展签名器
  3. 03在弹窗受限时使用重定向流程
  4. 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 完成握手,然后使用 postMessageOISY 等 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 中记录调用顺序日志,并在钱包返回时重放。两条规则决定了它能否正常工作:

  1. 每次加载时都以相同顺序发出相同请求。 只能根据先前结果中恢复的值进行分支。若重放与日志不匹配,偏差保护机制会拒绝重放。
  2. 任何必须返回相同值的内容都要经过 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 会代你执行这些检查,因此这意味着钱包返回了不应返回的内容。不要将其视为连接故障并重试——应将错误呈现出来。

常见陷阱

  1. 在点击处理程序之外打开弹出窗口。 PostMessageTransport 会拒绝非用户发起的连接(detectNonClickEstablishment 默认值为 true),因为 Safari 等浏览器会阻止此类弹出窗口。请从事件处理程序中连接,切勿在挂载时或 useEffect 中连接。

    // WRONG — blocked, and the transport detects it
    useEffect(() => { signer.getAccounts(); }, []);
    
    // CORRECT
    button.addEventListener('click', () => signer.getAccounts());
    
  2. 通过 SignerAgent 读取数据。 query() 会升级为经由钱包路由的更新调用,因此每次读取都会要求用户批准。公共数据——例如账本余额、代币元数据——不需要钱包:使用普通的 HttpAgent 读取,默认采用匿名身份。只有写入操作才通过 SignerAgent。

  3. 以为连接在页面重新加载后仍然有效。 通道不会在页面关闭后继续存在。根据常见陷阱 12,持久化保存账户的两部分信息,以便只读渲染;首次写入时再重新连接。见上文。

  4. 不加检查地访问 getAccounts() 的索引。 它会返回用户选择共享的账户列表。ICRC-27 允许签名者提示用户进行选择,因此列表可能为空——用户拒绝了,这不是错误;也可能包含多个账户,而 [0] 会在不提示的情况下替用户选择。空列表上的 accounts[0] 会得到 undefined,因此崩溃会发生在之后访问 .owner 时,而不是调用时。请检查列表长度;如果有多个账户,则提供选择器。

  5. 假设钱包支持特定功能。 调用 getSupportedStandards()。签名者可能会列出账户(ICRC-27),但不执行调用(ICRC-49),也可能使用你尚未实现的传输方式。

  6. 针对某个钱包的非标准错误代码编写处理逻辑。 ICRC-25 规定了 1xxx–4xxx 范围,并列出了其中的 1000/2000/3000/3001/4000/4001。这些范围以外的代码属于供应商扩展,不具备可移植性——此技能的早期版本曾记录 503 BUSY,但它只存在于 @dfinity/oisy-wallet-signer 中,不属于任何标准。请针对列出的代码进行分支处理,根据范围回退处理,并将范围以外的代码视为通用错误。

  7. 通过 memoize() 记录不可序列化的数据。 它会通过 JSON 持久化,因此 Uint8Array 往返后会变成 {"0":1,"1":2,…},而 CryptoKey 会变成 {} —— 这些变化不会报错,直到返回时流程才会失败。先将数据转换为普通数组(或十六进制),再进行记忆化,之后再转换回来。

  8. 重定向重放时出现分歧。 使用 UrlTransport 时,每次加载都必须以相同顺序发出相同请求并执行相同的 memoize 步骤;请求所依赖的一切内容——尤其是 nonce——都必须通过 memoize 处理。在返回加载时重新获取一次性值会使流程失效。

  9. 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 技能,其中也介绍了用于重定向登录的同一文件。

  10. 在钱包代码中使用顶层 await。 此处的每个调用都是异步的;对于 PostMessageTransport,模块加载时的 await 会在用户手势之外打开弹出窗口,因此传输层会拒绝该操作——参见陷阱 1。请将这些调用封装在由 UI 调用的函数中。UrlTransport 则恰好相反:它会导航顶层页面,而不是打开弹出窗口,因此没有手势检查;它的流程必须在回调路由加载时重新运行,以便重放日志——若要求用户在那里点击,就会破坏流程。无论哪种情况,都不要指望构建过程能发现意外的顶层 await:Vite ≤5 默认使用 es2020,会直接拒绝它;而 Vite 6+ 默认使用 baseline-widely-available,允许使用它。

  11. 将 @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 检查,仍然安装不兼容的组合,因此兼容性问题会在运行时而不是安装时暴露出来。

  12. 将账户视为单纯的 principal。 getAccounts() 返回 { owner, subaccount? }——即 IcrcAccount。通常不会提供子账户,因为 signer 通常只提供默认子账户;因此,假设账户只有 principal 的代码,只有遇到不提供该形式的 signer 时才会出问题。请完整保留账户,并让库的辅助函数处理其余工作:比较时使用 encodeIcrcAccount(),不要只比较 owner(该编码会规范化默认子账户,因此同一 principal 配上不同子账户时会正确地被视为不同账户);持久化时使用 encodeIcrcAccount() / decodeIcrcAccount();发送时,发送方使用 from_subaccount,接收方使用 toCandidAccount()。SignerAgent 是例外——它的 account 是一个 Principal,因此子账户会放在账本调用参数中传递。

  13. 连接后立即发起调用。 应由用户主动发起。连接后未经提示就弹出审批对话框,看起来像是攻击,钱包完全有权拒绝此操作。

使用真实钱包进行测试

无需运行本地签名器:OISY 托管在远程服务上,传输层的安全上下文要求适用于签名器的 URL,而非你的源站 — https://oisy.com/sign 满足此要求。从 localhost 提供你自己的前端没有问题,它也是浏览器安全上下文,因此 WebCrypto 密钥生成在此处也能正常工作。请使用测试网代币进行测试,而非主网资产。

icp network start -d
icp deploy

从 ICP Faucet 获取免费的测试网代币,并将 OISY 切换到 IC(测试网代币) 网络即可查看。可用的账本:

代币账本 canister
TESTICPxafvr-biaaa-aaaai-aql5q-cai
TICRC13jkp5-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/bindgen actor 通过 SignerAgent 调用你自己的 canister
  • canister-security — 调用到达后,在后端验证 msg.caller
  • OISY 签名器演示 — 基于 @icp-sdk/signer 构建的可运行依赖方(React)
  • ICRC 签名器标准 — 上述每种方法所依据的规范

评论

支持 Markdown