mcpField
W
wallet-integration

wallet-integration

github.com/dfinity開発39287 回インストール今日更新
リポジトリ

wallet-integration について

ICP dapp 開発者向けに、`@icp-sdk/signer` を使って外部ウォレットを統合するスキルです。アプリが鍵を保持せず、ユーザー承認済みのキャニスター呼び出しをウォレットに任せる方法を、トランスポート、権限、アカウント、エラー処理とともに説明します。

主な機能

  1. 01外部署名ウォレットの統合
  2. 02ポップアップ、リダイレクト、拡張機能のトランスポート
  3. 03署名器の機能と権限の確認
  4. 04アカウント取得とキャニスター呼び出し
  5. 05接続ライフサイクルとエラーの処理

活用シーン

  1. 01送金に対するウォレット承認の要求
  2. 02ブラウザー拡張署名器への接続
  3. 03ポップアップ不可時のリダイレクト利用
  4. 04dapp への汎用署名器の統合

インストール

コマンドをコピーしてターミナルで実行します。

$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 固有のものではありません。別のウェブ署名者を使う場合、通常はトランスポート URL を変更するだけです。また、BrowserExtensionTransportを使うと、ハードコードしていない拡張機能署名者も検出できます。特定の署名者が実際にサポートする機能と、その署名者への接続に使うトランスポートは別の問題です。決めつけず、ネゴシエーションしてください(機能のネゴシエーションと落とし穴 5 を参照)。

標準提供する機能API
ICRC-25機能の検出+権限のライフサイクルgetSupportedStandards、requestPermissions、getPermissions
ICRC-27ユーザーのアカウントgetAccounts
ICRC-29postMessageを使ったポップアップトランスポートPostMessageTransport
ICRC-49キャニスター呼び出しの実行SignerAgent(または低レベルのプリミティブであるcallCanister)
ICRC-94ブラウザー拡張機能の検出BrowserExtensionTransport.discover
ICRC-167トップレベルリダイレクトトランスポートUrlTransport(signer 6 で新登場)

モデルの概要

書き込み操作はすべて、ユーザーが個別に承認する行為です。アプリはウォレットにキャニスター呼び出し(ICRC-49)を 1 つ実行するよう要求します。ウォレットは実行しようとしている内容をユーザーに表示し、ユーザーが承認すると、ウォレットが署名して送信します。アプリがキーを保持することはなく、ユーザーに代わって無人で操作することもありません。

これがモデルの全体像です。この仕組みは、送金、承認、ミントなど、意図的に行う価値の高い操作には適していますが、頻繁に行う操作やユーザーに見えない操作には適していません。操作ごとにプロンプトを表示すべきでない場合、必要なのはウォレットではなく認証です。

このスキルを使わない場合
  • セッション — 一度サインインして、その後何度も操作する。 これはウォレット連携ではなく認証です。デリゲーションは ID プロバイダーによってスコープが設定され、発行されます。internet-identityスキルを使用してください。ICRC-34 も存在しますが、認証メカニズムであり、ここでは対象外です。
  • Internet Identity でのサインイン → internet-identityスキルを使用してください。II は ID プロバイダーであり、ICRC-25 署名者ではありません。
  • エージェントや CLI にユーザーとして操作させる → agent-web-identityスキルを使用してください。
  • ウォレットを構築する → 上述のとおり、対象外です。

前提条件

npm i '@icp-sdk/signer@^6' '@icp-sdk/core@^6'

ICP/ICRC 台帳を呼び出す場合は、@icp-sdk/canisters@^4を追加してください(ピア依存関係として@dfinity/utils@^5も含まれます)。signer 6、および@icp-sdk/auth@^10と@icp-sdk/canisters@^4のピア依存関係に合わせ、@icp-sdk/coreは^6に固定してください。

トランスポート URL はセキュアコンテキストである必要があります。HTTPS、localhost、または127.0.0.1を使用してください。

トランスポートを選ぶ

ウォレットへの接続方法を把握するのはトランスポートだけです。その上位にあるSigner API は、どのトランスポートを選んでも同じです。

トランスポート仕組み使用する場面
PostMessageTransportポップアップを開き、icrc29_statusでハンドシェイクした後、postMessageを使用OISY のようなウェブウォレットのデフォルト
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 に呼び出し順のジャーナルを保存し、ウォレットから戻ったときにそれを再実行します。成功の鍵となるルールは 2 つあります。

  1. 毎回の読み込みで、同じリクエストを同じ順序で発行します。 分岐は、それ以前の結果から復元した値に基づく場合に限ります。ジャーナルと一致しない再実行は、不整合ガードによって拒否されます。
  2. 同じ値で戻ってくる必要があるものは、すべて memoize() を通します — 特に nonce です。その結果はジャーナルに記録され、再実行される代わりに再生されます。決定論的な非同期処理に memoize は不要です。HttpAgent を構築すると、毎回同等のエージェントが生成されるため、ジャーナルとの不整合は起きません。

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
  });
}

signer.callCanister ではなく SignerAgent を経由することで、コンテンツマップと証明書のチェックが行われます。callCanister は ICRC-49 の低レベルプリミティブであり、応答に両方のフィールドが含まれていることだけを検証します。自分でプリミティブを操作する特別な理由がない限り、エージェントを使ってください。プリミティブを直接使う場合は、応答を信頼する前に証明書を検証するのが開発者の役割です。

ケイパビリティをネゴシエートする

ウォレットを 1 つに固定し、対応機能が分かっている場合に限り、この手順を省略できます。汎用的な統合では、ここが実装を適切に保つための手順です。署名者がアカウントを公開していても呼び出しを実行できない場合や、未対応のトランスポートをサポートしている場合があるためです。

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 は任意です。後で複数回表示されるプロンプトを、最初に 1 回だけ表示する形にできます。

状態動作
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 の台帳クライアントや、独自のキャニスター向けに @icp-sdk/bindgen で生成したアクターなど、Agent を受け取るものなら何にでも渡せます。呼び出しのたびにウォレットのプロンプトが表示されます。

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
  };
}

読み取りには通常のエージェントを、書き込みには署名者エージェントを使います。 SignerAgent.query() はすべてのクエリを、ウォレット経由でルーティングされる完全なキャニスター呼び出しにアップグレードするため、残高の確認でもユーザーに承認プロンプトが表示されます。公開データの読み取りにウォレットは不要です。デフォルトで匿名の通常の 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) を使うと、エージェントを再構築せずに、以降の書き込みの署名に使うプリンシパルを切り替えられます。

チャネルのライフサイクルとページの再読み込み

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 リンクだからです。実用的なパターンは、アカウントを保存し、匿名エージェントで読み取り専用の状態を表示して、最初の書き込み時に署名者を遅延して再確立することです。

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トランスポートチャネルが閉じました再接続する(署名者が報告した場合のみ。以下を参照)

想定するクラスとは異なる形で発生し、正反対の対応が必要な障害が 2 つあります。

  • トランスポート障害は、コード 4000 の SignerError として発生します。 Signer.openChannel() は、トランスポートがスローしたエラー(PostMessageTransportError、UrlTransportError、BrowserExtensionTransportError)を捕捉し、元のエラーを cause とする SignerError として再スローします。そのため、ブロックされたポップアップは 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() はウォレットを経由する update 呼び出しに昇格するため、読み取りのたびにユーザーの承認操作が必要になります。台帳残高やトークンメタデータなどの公開データを取得するのにウォレットは不要です。既定では匿名のプレーンな 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 に名前を付けています。これらの範囲外のコードはベンダー拡張であり、他の環境には移植できません。このスキルの以前の改訂版では、@dfinity/oisy-wallet-signer にしか存在せず、標準には存在しない 503 BUSY が説明されていました。名前付きコードを条件分岐で処理し、範囲にフォールバックし、範囲外のコードは一般的なエラーとして扱ってください。

  7. memoize() を通じてシリアライズできない値をジャーナルに記録する。 JSON を介して永続化されるため、Uint8Array は {"0":1,"1":2,…} に、CryptoKey は {} に変換されて往復します。これはエラーにならず、呼び出し時ではなく戻り時にフローが失敗します。メモ化する前にプレーンな配列(または 16 進数)に変換し、その後で元に戻してください。

  8. リダイレクトの再生時に処理が分岐する。 UrlTransport では、読み込みのたびに同じリクエストと memoize のステップを同じ順序で実行し、リクエストが依存する値はすべて、特に nonce は memoize を通してください。戻り時の読み込みで使い捨ての値を再取得すると、フローが無効になります。

  9. 相対パス、フラグメント付き、または正しく配信されていない callbackUrl。 絶対 URL で、フラグメントがなく(トランスポートが独自のフラグメントを追加します)、自分が管理するオリジン上にあり、そのオリジンの /.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 を使い、トップレベルの await を即座に拒否しましたが、Vite 6+ はデフォルトで baseline-widely-available を使い、これを許可します。

  11. @icp-sdk/canisters@^3 と @icp-sdk/signer@^6 を併用する。 この組み合わせは共存できません。canisters 3 は @icp-sdk/core@^5 以前を peer dependency とし、signer 6 は ^6 を peer dependency とするため、npm install は ERESOLVE で失敗します。@icp-sdk/canisters@^4 と @dfinity/utils@^5 に移行してください。--legacy-peer-deps は使わないでください。peer dependency のチェックを省略して不整合な組み合わせをインストールするため、互換性の問題がインストール時ではなく実行時に表面化します。

  12. アカウントをプリンシパルだけだと扱う。 getAccounts() は { owner, subaccount? }(IcrcAccount)を返します。署名者がデフォルトのアカウントしか提供しないことが多いため、subaccount は通常ありません。そのため、プリンシパルだけを想定したコードは、そうではない署名者に遭遇するまで動作します。アカウント全体を保持し、あとはライブラリのヘルパーに任せてください。比較には encodeIcrcAccount() を使い、owner 単独では比較しないでください(このエンコードはデフォルトの subaccount を正規化するため、同じプリンシパルでも異なる subaccount なら正しく別のアカウントとして扱われます)。永続化には encodeIcrcAccount() / decodeIcrcAccount() を使い、送信には送信者に from_subaccount、受信者に toCandidAccount() を使ってください。例外は SignerAgent です。ここでは account が Principal であるため、代わりに subaccount をレジャー呼び出しの引数に渡します。

  13. 接続直後に呼び出しを実行する。 ユーザーに操作してもらってください。接続直後に促されずに承認ダイアログが表示されると、攻撃のように見えます。ウォレットがそれを拒否するのは当然の権利です。

実際のウォレットでのテスト

ローカルの署名者を実行する必要はありません。OISY はホスト型であり、トランスポートのセキュアコンテキスト要件が適用されるのはオリジンではなく、署名者 の URL です。https://oisy.com/sign はこの要件を満たします。独自のフロントエンドを localhost から配信しても問題ありません。ブラウザーのセキュアコンテキストでもあるため、WebCrypto による鍵の生成もそこで機能します。メインネットの価値あるトークンではなく、テストネットのトークンでテストしてください。

icp network start -d
icp deploy

ICP Faucet から無料のテストネットトークンを入手し、OISY を IC(テストネットトークン) ネットワークに切り替えると確認できます。利用できるレジャーは次のとおりです。

トークンレジャーキャニスター
TESTICPxafvr-biaaa-aaaai-aql5q-cai
TICRC13jkp5-oyaaa-aaaaj-azwqa-cai

localhost から配信する場合でも、エージェントに host: 'https://icp-api.io' を設定してください。host は API 呼び出しの送信先エンドポイントであり、アプリを配信するオリジンではありません。

期待される動作

  • 最初の getAccounts() はウォレットを開き、ユーザーが共有を選んだアカウントを { owner: Principal, subaccount?: Uint8Array } として返します。アカウントが共有されない場合もあります。
  • SignerAgent を介したレジャーの transfer では、承認する呼び出しがユーザーに表示され、ブロックインデックスが bigint として返されます。
  • キャニスター呼び出しの承認をキャンセルすると、SignerError が拒否され、code === 3001 になります。ほかの 2 種類の拒否の動作は異なります。アカウントの共有を拒否すると getAccounts() は [] を返し、権限を拒否するとコード 3000 が返されます。
  • 再読み込み後は、ポップアップなしで読み取り専用の状態が表示され、最初の書き込み時にポップアップが再び開きます。

参考情報

  • internet-identity — II サインインについて。アクションごとの承認ではなくセッションが必要な場合に利用します
  • agent-web-identity — エージェントまたは CLI が II アプリでユーザーとして操作できるようにする方法
  • icp-cli — SignerAgent を介して独自のキャニスターを呼び出すための @icp-sdk/bindgen アクター
  • canister-security — 呼び出しが届いた後、バックエンドで msg.caller を検証する方法
  • OISY 署名者のデモ — @icp-sdk/signer を使用して構築された、動作する依拠当事者(React)
  • ICRC 署名者標準 — 上記のすべてのメソッドの基盤となる仕様

コメント