For AI agents: Documentation index at /llms.txt

Skip to content

Wallet Integration

Wallet integration on the Internet Computer uses a popup-based signer model where every meaningful action requires explicit user approval. The app opens a wallet popup, requests permission, and the wallet shows a human-readable consent message before executing each canister call.

This guide covers integration using @icp-sdk/signer, the signer library in the ICP JavaScript SDK.

Internet Identity and wallet signers serve different purposes:

Internet IdentityWallet signer
PurposeAuthenticate a user (prove identity)Approve and sign canister calls
User seesLogin prompt onceConsent message per action
After approvalSession delegation (sign-once, act-many)Single call executed
Use whenRead data, frequent writes, session-based UXToken transfers, approvals, high-value one-off actions

Use Internet Identity for login. Use a wallet signer when your app needs users to explicitly approve individual transactions: token transfers, NFT operations, or any action where a per-operation confirmation dialog is appropriate.

The signer model is defined by five ICRC standards (ICRC-21, 25, 27, 29, and 49) covering consent messages, permissions, account discovery, and canister call routing. For details on each, see Wallet signer standards. A compliant wallet such as OISY implements all five.

The lifecycle of a wallet-initiated call:

  1. Your app creates a Signer pointing to the wallet’s signer URL
  2. Call getAccounts(): the wallet popup opens and prompts the user to share their account
  3. Construct a SignerAgent using the returned principal
  4. Use the agent with any canister actor. The wallet intercepts every call, fetches an ICRC-21 consent message from the target canister, shows it to the user, and only executes if the user approves

The key insight: a SignerAgent is a drop-in replacement for HttpAgent. Code that creates actors with HttpAgent can switch to SignerAgent to add wallet approval to every call.

Terminal window
npm install @icp-sdk/signer @icp-sdk/core

To interact with token ledgers, also install:

Terminal window
npm install @icp-sdk/canisters
import { Signer } from '@icp-sdk/signer';
import { PostMessageTransport } from '@icp-sdk/signer/web';
const signer = new Signer({
transport: new PostMessageTransport({ url: 'https://oisy.com/sign' }),
});
// Opens the wallet popup. User approves account sharing.
// Returns an array of { owner: Principal, subaccount?: Uint8Array }
const accounts = await signer.getAccounts();
const principal = accounts[0].owner;

getAccounts() triggers the wallet’s icrc27_accounts flow. The popup opens, the user approves, and you receive their principal.

You can request permissions upfront before calling getAccounts() to batch all permission prompts into a single interaction:

// Request all needed permissions at once (optional but recommended)
await signer.requestPermissions([{ method: 'icrc27_accounts' }]);
const accounts = await signer.getAccounts();

If you skip this step, the signer handles permissions per-method. The user sees a permissions prompt the first time each method is called.

SignerAgent wraps a Signer and acts as a drop-in replacement for HttpAgent. Any canister actor built with it routes calls through the wallet for approval.

import { SignerAgent } from '@icp-sdk/signer/agent';
import { HttpAgent } from '@icp-sdk/core/agent';
// Create a read-only agent for balance queries (no wallet needed)
const readAgent = await HttpAgent.create({ host: 'https://icp0.io' });
// Create a SignerAgent for wallet-approved calls
const signerAgent = await SignerAgent.create({
signer,
account: principal, // principal from getAccounts()
agent: readAgent, // optional: share the HttpAgent for root key fetch
});

Read operations don’t require wallet approval. Use a plain HttpAgent for queries:

import { IcrcLedgerCanister } from '@icp-sdk/canisters/ledger/icrc';
import { Principal } from '@icp-sdk/core/principal';
const ledger = IcrcLedgerCanister.create({
agent: readAgent,
canisterId: Principal.fromText('mxzaz-hqaaa-aaaar-qaada-cai'), // ckBTC ledger
});
const balance = await ledger.balance({ owner: principal });

Separate read and write agents: use readAgent for queries, signerAgent for transfers.

Using the signer agent with IcrcLedgerCanister routes the transfer through the wallet. The wallet fetches the ICRC-21 consent message and presents it to the user before the call executes.

const signedLedger = IcrcLedgerCanister.create({
agent: signerAgent,
canisterId: Principal.fromText('mxzaz-hqaaa-aaaar-qaada-cai'),
});
// The wallet popup opens, shows a consent message, user approves
const blockIndex = await signedLedger.transfer({
to: { owner: recipientPrincipal, subaccount: [] },
amount: 1_000_000n, // in base units (e.g. 0.01 ckBTC = 1_000_000 e8s)
});

Call closeChannel() on the signer when the user logs out or closes the session:

await signer.closeChannel();

closeChannel() closes the open communication channel with the wallet popup.

The signer session is tied to the browser tab. After a page reload, the user’s principal is no longer available from the signer. To avoid opening the popup again immediately, store the principal in sessionStorage and restore it on mount: then re-establish the signer session lazily when the user initiates a transfer:

import { Principal } from '@icp-sdk/core/principal';
const SESSION_KEY = 'wallet-principal';
// On connect: store principal
sessionStorage.setItem(SESSION_KEY, principal.toText());
// On mount: restore principal without opening popup
const stored = sessionStorage.getItem(SESSION_KEY);
if (stored) {
const restoredPrincipal = Principal.fromText(stored);
// Use restoredPrincipal for read-only queries
// Only call getAccounts() again when user initiates a write
}
// On disconnect: clear storage
sessionStorage.removeItem(SESSION_KEY);
import { SignerError } from '@icp-sdk/signer';
try {
await signer.getAccounts();
} catch (err) {
if (err instanceof SignerError) {
switch (err.code) {
case 3001: // ACTION_ABORTED: user closed the popup or rejected the prompt
break;
case 3000: // PERMISSION_NOT_GRANTED: permission was denied
break;
default:
console.error('Signer error', err.code, err.message);
}
}
}

Common err.code values from the ICRC-25 standard:

CodeMeaning
3000Permission not granted
3001Action aborted: user closed the popup or rejected
4000Network error: IC call failed

For local development against a running local network:

const signer = new Signer({
transport: new PostMessageTransport({ url: 'http://localhost:5174/sign' }),
});
const readAgent = await HttpAgent.create({ host: 'http://localhost:8000' });

For a test signer target, you can use any ICRC-25-compliant wallet running locally that exposes a /sign endpoint: for example, a local instance of OISY or a custom signer built with @icp-sdk/signer.

On mainnet, omit host from HttpAgent.create(): it defaults to https://icp0.io.

The oisy-signer-demo example shows a complete app that:

  1. Connects to OISY and fetches the user’s accounts
  2. Queries ICRC-1 token balances using a read-only agent
  3. Performs self-transfers using the signer agent

To run locally:

Terminal window
git clone https://github.com/dfinity/examples
cd examples/hosting/oisy-signer-demo
icp network start -d
npm install
icp deploy

Two additional libraries are available for more advanced wallet integration scenarios:

Both libraries are expected to move to the @icp-sdk namespace on npm and will likely be covered in the wallet-integration skill going forward. They are not documented in detail here.