Skip to main content

wallet-integration

Integrate an external wallet (signer) into an IC dapp with @icp-sdk/signer — the relying-party side of the ICRC signer standards. Covers picking a transport (popup via ICRC-29 / top-level redirect via ICRC-167 / browser extension via ICRC-94) then negotiating capabilities, the permission and account lifecycle, and executing canister calls the user approves through SignerAgent (ICRC-49). Uses OISY as the worked example but applies to any ICRC-25 signer. Do NOT use for Internet Identity login (use the internet-identity skill) or for implementing a wallet yourself. Use when the developer mentions wallet integration or OISY or @icp-sdk/signer or approving a transaction in a wallet popup.

Datos de origen

Repositorio
dfinity/icskills
Última actividad en el origen
22 de septiembre de 2026 a las 21:30
Idioma detectado de SKILL.md
inglés
Estrellas
36
Forks
14

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
name
wallet-integration
description
Integrate an external wallet (signer) into an IC dapp with @icp-sdk/signer — the relying-party side of the ICRC signer standards. Covers picking a transport (popup via ICRC-29 / top-level redirect via ICRC-167 / browser extension via ICRC-94) then negotiating capabilities, the permission and account lifecycle, and executing canister calls the user approves through SignerAgent (ICRC-49). Uses OISY as the worked example but applies to any ICRC-25 signer. Do NOT use for Internet Identity login (use the internet-identity skill) or for implementing a wallet yourself. Use when the developer mentions wallet integration or OISY or @icp-sdk/signer or approving a transaction in a wallet popup.
license
Apache-2.0
compatibility
Node.js >= 22, a browser (secure context: HTTPS, localhost, or 127.0.0.1)
metadata
{"title":"Wallet Integration","category":"DeFi"}
# Wallet Integration ## What This Is Connecting an **external wallet** to your dapp so the user approves actions in the wallet rather than handing your app a key. `@icp-sdk/signer` is the **relying-party** client: your app is the relying party, the wallet is the signer, and they exchange JSON-RPC 2.0 messages over a transport defined by the ICRC signer standards. This skill covers **integrating a signer**. Implementing one is out of scope — consent screens, prompt registration and account custody are the wallet's job. Examples use [OISY](https://oisy.com) (`https://oisy.com/sign`), but nothing here is OISY-specific. For another web signer the transport URL is usually the only change, and [`BrowserExtensionTransport`](#extension-icrc-94) discovers extension signers you never hardcoded. What a given signer actually supports is a separate question from which transport reaches it — negotiate it rather than assuming (see [Negotiate capabilities](#negotiate-capabilities) and pitfall 5). | Standard | What it gives you | API | |----------|-------------------|-----| | ICRC-25 | Capability discovery + permission lifecycle | `getSupportedStandards`, `requestPermissions`, `getPermissions` | | ICRC-27 | The user's accounts | `getAccounts` | | ICRC-29 | Popup transport over `postMessage` | `PostMessageTransport` | | ICRC-49 | Execute a canister call | `SignerAgent` (or `callCanister`, the raw primitive) | | ICRC-94 | Browser-extension discovery | `BrowserExtensionTransport.discover` | | ICRC-167 | Top-level redirect transport | `UrlTransport` (**new in signer 6**) | ## What the model is Every write is an individual, user-approved act. Your app asks the wallet to execute one canister call (ICRC-49); the wallet shows the user what it is about to do, the user approves, and the wallet signs and submits it. Your app never holds a key and never acts on the user's behalf unattended. That is the whole shape, and it is what makes wallet integration appropriate for deliberate, high-value actions — transfers, approvals, mints — and inappropriate for anything frequent or invisible. If a user should not see a prompt per action, you do not want a wallet; you want authentication. ### When NOT to use this skill - **A session — sign in once, then act many times.** That is authentication, not wallet integration: a delegation is scoped and issued by an identity provider. Use the **internet-identity** skill. ICRC-34 exists, but it is an auth mechanism and out of scope here. - **Internet Identity sign-in** → the **internet-identity** skill. II is an identity provider, not an ICRC-25 signer. - **Letting an agent or CLI act as the user** → the **agent-web-identity** skill. - **Building a wallet** → out of scope, as above. ## Prerequisites ```bash npm i '@icp-sdk/signer@^6' '@icp-sdk/core@^6' ``` Add `@icp-sdk/canisters@^4` if you call ICP/ICRC ledgers (it brings `@dfinity/utils@^5` as a peer). Pin `@icp-sdk/core` to `^6`: signer 6 peers it, and so do `@icp-sdk/auth@^10` and `@icp-sdk/canisters@^4`. The transport URL must be a **secure context** — HTTPS, `localhost`, or `127.0.0.1`. ## Pick a transport The transport is the only part that knows *how* the wallet is reached; the `Signer` API above it is identical whichever you choose. | Transport | Mechanism | Use when | |-----------|-----------|----------| | `PostMessageTransport` | Popup, handshaken with `icrc29_status`, then `postMessage` | Default for web wallets like OISY | | `UrlTransport` | Navigates the top-level window; wallet returns to your `callbackUrl` | Mobile, or anywhere popups are blocked | | `BrowserExtensionTransport` | Extensions announce themselves on `window` events | Extension wallets; discovering unknown signers | ```typescript 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' }) }); ``` ### Extension (ICRC-94) ```typescript 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 }); } ``` ### Redirect (ICRC-167) `UrlTransport` unloads your page on every request, so it keeps a call-order journal in `sessionStorage` and replays it when the wallet returns. Two rules make or break it: 1. **Issue the same requests, in the same order, on every load.** Branch only on values recovered from earlier results. A divergence guard rejects a replay that does not match. 2. **Put anything that must come back *the same value* through `memoize()`** — a nonce above all. Its result is journaled and replayed instead of re-run. Deterministic async work needs no `memoize`: building an `HttpAgent` yields an equivalent agent on every load, so it cannot drift from the journal. `SignerAgent` works over this transport, so a redirect flow is the same code as a popup flow: ```typescript 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 }); } ``` Going through `SignerAgent` rather than `signer.callCanister` is what gets you the content-map and certificate checks; `callCanister` is the raw ICRC-49 primitive and validates only that the reply carries both fields. Prefer the agent unless you have a specific reason to drive the primitive yourself, in which case verifying the certificate before trusting the reply is your job. ## Negotiate capabilities Skip this only if you hardcode one wallet and know what it supports. For generic integration it is the step that keeps you honest: a signer may expose accounts without executing calls, or support a transport you have not built for. ```typescript 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` needs no permission, so it is safe as a first call. ## Permissions and accounts Most signers start every scope at `ask_on_use`, prompting the first time each method is used — but ICRC-25 leaves the initial state to signer policy, so `getPermissions()` is the only authority. `requestPermissions` is **optional**: it trades several later prompts for one up front. | State | Behaviour | |-------|-----------| | `granted` | Proceeds without prompting | | `denied` | Rejected immediately with error `3000` | | `ask_on_use` | Prompts on first use (the usual initial state) | ```typescript 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()` reads the current state without prompting. Do not cache it across sessions — a wallet may expire grants, after which they silently revert to `ask_on_use`. ## Executing a call the user approves `SignerAgent` implements `Agent`, so it drops into anything that takes one: a ledger client from `@icp-sdk/canisters`, or an actor from `@icp-sdk/bindgen` for your own canister. Each call becomes a wallet prompt. ```typescript 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 }; } ``` **Read with the plain agent, write with the signer agent.** `SignerAgent.query()` upgrades every query into a full canister call routed through the wallet, so a balance check would put an approval prompt in front of the user. Public data does not need the wallet at all — read it with an ordinary `HttpAgent`, which is anonymous by default: ```typescript 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)` switches which principal later writes are signed for, without rebuilding the agent. ## Channel lifecycle and page reloads `autoCloseTransportChannel` defaults to `true`: the channel closes ~200 ms after each response, so the popup does not linger. For a multi-step flow that awaits your own async work between requests, turn it off or the channel closes underneath you. ```typescript 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(); } } ``` **A connection does not survive a page reload.** There is no persistent session to restore — the channel is a live `postMessage` link to a popup that is gone. The workable pattern is to persist the account, render read-only state from it with an anonymous agent, and re-establish the signer lazily on the first write: ```typescript 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 }); } ``` Treat "disconnect" as clearing your own state — there is no wallet-side logout to call. ## Error handling ```typescript 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 {
Ver en GitHub
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion. Ver en GitHub