- 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