Wallet Integration
What This Is
Wallet integration on the Internet Computer uses the ICRC signer standards — a popup-based model where every action requires explicit user approval via JSON-RPC 2.0 over window.postMessage.
This skill covers integration using @dfinity/oisy-wallet-signer. Other integration paths (IdentityKit, signer-js) exist but are not covered here.
The signer model = explicit per-action approval. connect() establishes a channel. Nothing more.
It is not:
- A session system
- A delegated identity (no ICRC-34)
- A background executor
ICRC standards implemented:
- ICRC-21 — Canister call consent messages
- ICRC-25 — Signer interaction standard (permissions)
- ICRC-27 — Accounts
- ICRC-29 — Window PostMessage transport
- ICRC-49 — Call canister
Not implemented:
- ICRC-46 — Session-based delegation (not supported; use a delegation-capable model if you need sessions)
When to Use
- Clear, intentional, high-value actions: token transfers (ICP / ICRC-1 / ICRC-2), NFT mint/claim, single approvals
- Funding / deposit flows: "Top up", "Deposit into protocol"
- Any action where a confirmation dialogue per operation feels natural
When NOT to Use
- Delegation or sessions: sign once / act many times, background execution, autonomous behaviour
- High-frequency interactions: games, social actions, rapid write operations
- Invisible writes: autosave, cron jobs, auto-compounding
Decision test: If your app still feels good when every meaningful update shows a confirmation dialogue, this library is appropriate. If not, use a delegation-capable model instead.
Prerequisites
@dfinity/oisy-wallet-signerinstalled- Peer dependencies installed:
@dfinity/utils,@dfinity/zod-schemas,@icp-sdk/canisters,@icp-sdk/core,zod - A non-anonymous identity on the signer side (e.g.
Ed25519KeyIdentity) - For local development: a running local network (
icp network start -d)
npm i @dfinity/oisy-wallet-signer @dfinity/utils @dfinity/zod-schemas @icp-sdk/canisters @icp-sdk/core zod
How It Works
End-to-End Lifecycle
1. dApp: IcrcWallet.connect({url}) → opens popup, polls icrc29_status
2. dApp: wallet.requestPermissionsNotGranted() → prompts user if needed
3. dApp: wallet.accounts() → signer prompts, returns accounts
4. dApp: wallet.transfer({...}) → signer fetches ICRC-21 consent message
→ signer prompts user with consent
→ signer executes canister call
→ returns block index
5. dApp: wallet.disconnect() → closes popup, cleans up
Pitfalls
-
Importing classes from the wrong entry point.
Signer,RelyingParty,IcpWallet, andIcrcWalletare not exported from the main entry point. Import them from their dedicated subpaths or you getundefined.// WRONG — will fail import {Signer} from '@dfinity/oisy-wallet-signer'; // CORRECT import {Signer} from '@dfinity/oisy-wallet-signer/signer'; import {IcpWallet} from '@dfinity/oisy-wallet-signer/icp-wallet'; import {IcrcWallet} from '@dfinity/oisy-wallet-signer/icrc-wallet'; -
Using
IcrcWalletwithoutledgerCanisterId. UnlikeIcpWallet(which defaults to the ICP ledgerryjl3-tyaaa-aaaaa-aaaba-cai),IcrcWallet.transfer(),.approve(), and.transferFrom()all requireledgerCanisterId. Omitting it causes a runtime error. -
Forgetting to register prompts on the signer side. The signer returns error 501 (
PERMISSIONS_PROMPT_NOT_REGISTERED) if a request arrives and no prompt handler is registered for it. Register all four prompts (ICRC25_REQUEST_PERMISSIONS,ICRC27_ACCOUNTS,ICRC21_CALL_CONSENT_MESSAGE,ICRC49_CALL_CANISTER) before the signer can handle any relying party traffic. -
Sending concurrent requests to the signer. The signer processes one request at a time. A second request while one is in-flight returns error 503 (
BUSY). Serialize your calls — wait for each response before sending the next. Read-only methods (icrc29_status,icrc25_supported_standards) are exempt. -
Assuming
connect()= authenticated session.connect()only opens apostMessagechannel. The user has not pre-authorized anything. Permissions default toask_on_use— the signer will prompt the user on first use of each method. CallrequestPermissionsNotGranted()after connecting to request all permissions upfront in a single prompt instead of per-method prompts. -
Not handling the consent message state machine. The
ICRC21_CALL_CONSENT_MESSAGEprompt fires multiple times with different statuses:loading→result|error. If you only handleresult, the UI breaks on loading and error states. Always branch onpayload.status. -
sendernot matchingowner. The signer validates thatsenderin everyicrc49_call_canisterrequest matches the signer'sowneridentity. A mismatch returns error 502 (SENDER_NOT_ALLOWED). Always use theownerfromaccounts(). -
Not calling
disconnect(). BothSigner.disconnect()andwallet.disconnect()must be called on clean-up. Forgetting this leaks event listeners and leaves popup windows open. -
Ignoring permission expiration. Permissions default to a 7-day validity period. After expiry, they silently revert to
ask_on_use. Don't cache permission state client-side beyond a session. -
Auto-triggering signing on connect. Never fire a canister call immediately after
connect(). Let the user initiate the action. The signer is designed for intentional, user-driven operations.
Implementation
Import Map
// Constants, errors, and types — from main entry point
import {
ICRC25_REQUEST_PERMISSIONS,
ICRC25_PERMISSION_GRANTED,
ICRC25_PERMISSION_DENIED,
ICRC25_PERMISSION_ASK_ON_USE,
ICRC27_ACCOUNTS,
ICRC21_CALL_CONSENT_MESSAGE,
ICRC49_CALL_CANISTER,
DEFAULT_SIGNER_WINDOW_CENTER,
DEFAULT_SIGNER_WINDOW_TOP_RIGHT,
RelyingPartyResponseError,
RelyingPartyDisconnectedError
} from '@dfinity/oisy-wallet-signer';
import type {
PermissionsPromptPayload,
AccountsPromptPayload,
ConsentMessagePromptPayload,
CallCanisterPromptPayload,
IcrcAccounts,
SignerOptions,
RelyingPartyOptions
} from '@dfinity/oisy-wallet-signer';
// Classes — from dedicated subpaths
import {Signer} from '@dfinity/oisy-wallet-signer/signer';
import {RelyingParty} from '@dfinity/oisy-wallet-signer/relying-party';
import {IcpWallet} from '@dfinity/oisy-wallet-signer/icp-wallet';
import {IcrcWallet} from '@dfinity/oisy-wallet-signer/icrc-wallet';
dApp Side (Relying Party)
Choosing the Right Class
| Class | Use for |
|---|---|
IcpWallet | ICP ledger operations — ledgerCanisterId optional (defaults to ICP ledger) |
IcrcWallet | Any ICRC ledger — ledgerCanisterId required |
RelyingParty | Low-level custom canister calls via protected call() |
Connect, Permissions, Accounts
const wallet = await IcrcWallet.connect({
url: 'https://your-wallet.example.com/sign', // URL of the wallet implementing the signer
host: 'https://icp-api.io',
windowOptions: {width: 576, height: 625, position: 'center'},
connectionOptions: {timeoutInMilliseconds: 120_000},
onDisconnect: () => {
/* wallet popup closed */
}
});
const {allPermissionsGranted} = await wallet.requestPermissionsNotGranted();
const accounts = await wallet.accounts();
const {owner} = accounts[0];
IcpWallet — ICP Transfers and Approvals
Uses {owner, request} — no ledgerCanisterId needed.
const wallet = await IcpWallet.connect({url: 'https://your-wallet.example.com/sign'});
const accounts = await wallet.accounts();
const {owner} = accounts[0];
await wallet.icrc1Transfer({
owner,
request: {to: {owner: recipientPrincipal, subaccount: []}, amount: 100_000_000n}
});
await wallet.icrc2Approve({
owner,
request: {spender: {owner: spenderPrincipal, subaccount: []}, amount: 500_000_000n}
});
IcrcWallet — Any ICRC Ledger
Uses {owner, ledgerCanisterId, params} — ledgerCanisterId is required.
const wallet = await IcrcWallet.connect({url: 'https://your-wallet.example.com/sign'});
const accounts = await wallet.accounts();
const {owner} = accounts[0];
await wallet.transfer({
owner,
ledgerCanisterId: 'mxzaz-hqaaa-aaaar-qaada-cai',
params: {to: {owner: recipientPrincipal, subaccount: []}, amount: 1_000_000n}
});
await wallet.approve({
owner,
ledgerCanisterId: 'mxzaz-hqaaa-aaaar-qaada-cai',
params: {spender: {owner: spenderPrincipal, subaccount: []}, amount: 5_000_000n}
});
await wallet.transferFrom({
owner,
ledgerCanisterId: 'mxzaz-hqaaa-aaaar-qaada-cai',
params: {from: {owner: fromPrincipal, subaccount: []}, to: {owner: toPrincipal, subaccount: []}, amount: 1_000_000n}
});
Query Methods and Disconnect
const standards = await wallet.supportedStandards();
const currentPermissions = await wallet.permissions();
await wallet.disconnect();
Error Handling (dApp Side)
try {
await wallet.transfer({...});
} catch (err) {
if (err instanceof RelyingPartyResponseError) {
switch (err.code) {
case 3000: /* PERMISSION_NOT_GRANTED */ break;
case 3001: /* ACTION_ABORTED — user rejected */ break;
case 4000: /* NETWORK_ERROR */ break;
}
}
if (err instanceof RelyingPartyDisconnectedError) {
/* popup closed unexpectedly */
}
}
Wallet Side (Signer)
Initialise and Register All Prompts
const signer = Signer.init({
owner: identity,
host: 'https://icp-api.io',
sessionOptions: {
sessionPermissionExpirationInMilliseconds: 7 * 24 * 60 * 60 * 1000
}
});
signer.register({
method: ICRC25_REQUEST_PERMISSIONS,
prompt: ({requestedScopes, confirm, origin}: PermissionsPromptPayload) => {
confirm(
requestedScopes.map(({scope}) => ({
scope,
state: userApproved ? ICRC25_PERMISSION_GRANTED : ICRC25_PERMISSION_DENIED
}))
);
}
});
signer.register({
method: ICRC27_ACCOUNTS,
prompt: ({approve, reject, origin}: AccountsPromptPayload) => {
approve([{owner: identity.getPrincipal().toText()}]);
}
});
signer.register({
method: ICRC21_CALL_CONSENT_MESSAGE,
prompt: (payload: ConsentMessagePromptPayload) => {
if (payload.status === 'loading') {
// show spinner
} else if (payload.status === 'result') {
// payload.consentInfo: { Ok: ... } (from canister) or { Warn: ... } (signer-generated fallback)
// show consent UI, then: payload.approve() or payload.reject()
} else if (payload.status === 'error') {
// show error, optionally payload.details
}
}
});
signer.register({
method: ICRC49_CALL_CANISTER,
prompt: (payload: CallCanisterPromptPayload) => {
if (payload.status === 'executing') {
/* show progress */
} else if (payload.status === 'result') {
/* call succeeded */
} else if (payload.status === 'error') {
/* call failed */
}
}
});
Consent Message: Ok vs Warn
{ Ok: consentInfo }— canister implements ICRC-21; message is canister-verified{ Warn: { consentInfo, canisterId, method, arg } }— signer generated a fallback (foricrc1_transfer,icrc2_approve,icrc2_transfer_from)
Always distinguish these in the UI — warn the user when the message is signer-generated.
Disconnect
signer.disconnect();
Error Code Reference
| Code | Name | Meaning |
|---|---|---|
| 500 | ORIGIN_ERROR | Origin mismatch |
| 501 | PERMISSIONS_PROMPT_NOT_REGISTERED | Missing prompt handler |
| 502 | SENDER_NOT_ALLOWED | sender ≠ owner |
| 503 | BUSY | Concurrent request rejected |
| 504 | NOT_INITIALIZED | Owner identity not set |
| 1000 | GENERIC_ERROR | Catch-all |
| 2000 | REQUEST_NOT_SUPPORTED | Method not supported |
| 3000 | PERMISSION_NOT_GRANTED | Permission denied |
| 3001 | ACTION_ABORTED | User cancelled |
| 4000 | NETWORK_ERROR | IC call failure |
Permission States
| State | Constant | Behavior |
|---|---|---|
| Granted | ICRC25_PERMISSION_GRANTED | Proceeds without prompting |
| Denied | ICRC25_PERMISSION_DENIED | Rejected immediately (error 3000) |
| Ask on use | ICRC25_PERMISSION_ASK_ON_USE | Prompts user on access (default) |
Permissions stored in localStorage as oisy_signer_{origin}_{owner} with timestamps. Default validity: 7 days.
Deploy & Test
Local Development — Your Own Signer
If you are building both the dApp and the wallet/signer, start a local network and pass host to both sides:
icp network start -d
// dApp side — point to your local wallet's /sign route
const wallet = await IcrcWallet.connect({
url: 'http://localhost:5174/sign',
host: 'http://localhost:8000'
});
// Wallet/signer side — same local network host
const signer = Signer.init({
owner: identity,
host: 'http://localhost:8000'
});
Local Development — Using the Pseudo Wallet Signer
If you are building a dApp (relying party) and need a signer to test against locally, the library provides a pseudo wallet signer in its demo:
git clone https://github.com/dfinity/oisy-wallet-signer
cd oisy-wallet-signer
npm ci
cd demo
npm ci
npm run sync:all
npm run dev:wallet # starts the pseudo wallet on port 5174
Then connect from your dApp:
const wallet = await IcpWallet.connect({
url: 'http://localhost:5174/sign',
host: 'http://localhost:8000' // match your local network port
});
Mainnet
On mainnet, point to the wallet's production signer URL and omit host (defaults to https://icp-api.io):
const wallet = await IcpWallet.connect({
url: 'https://your-wallet.example.com/sign'
});
Expected Behavior
Connection
connect()resolves with a wallet instance; throwsRelyingPartyDisconnectedErroron timeoutwallet.supportedStandards()returns an array containing at least ICRC-21, ICRC-25, ICRC-27, ICRC-29, ICRC-49
Permissions
requestPermissionsNotGranted()triggers the signer's permissions prompt- After approval,
wallet.permissions()returns scopes with stategranted - A second call returns
{allPermissionsGranted: true}without prompting again
Accounts
wallet.accounts()returns at least one{owner: string}(principal as text)- The returned
ownermatches the signer's identity principal
Transfers and Approvals
icrc1Transfer()/transfer(),icrc2Approve()/approve(), andtransferFrom()all resolve with abigintblock index- Each triggers the consent message prompt on the signer before execution