Loading...
Loading...
Integrate wallets with IC dApps using ICRC signer standards (ICRC-21/25/27/29/49). Covers the popup-based signer model, consent messages, permission lifecycle, and transaction approval flows. Implementation uses @dfinity/oisy-wallet-signer. Do NOT use for Internet Identity login, delegation-based auth (ICRC-34/46), or threshold signing (chain-key). Use when the developer mentions wallet integration, OISY, oisy-wallet-signer, wallet signer, relying party, consent messages, wallet popup, or transaction approval.
npx skill4agent add dfinity/icskills wallet-integrationwindow.postMessage@dfinity/oisy-wallet-signerconnect()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.
@dfinity/oisy-wallet-signer@dfinity/utils@dfinity/zod-schemas@icp-sdk/canisters@icp-sdk/corezodEd25519KeyIdentityicp network start -dnpm i @dfinity/oisy-wallet-signer @dfinity/utils @dfinity/zod-schemas @icp-sdk/canisters @icp-sdk/core zod1. 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 upSignerRelyingPartyIcpWalletIcrcWalletundefined// 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';IcrcWalletledgerCanisterIdIcpWalletryjl3-tyaaa-aaaaa-aaaba-caiIcrcWallet.transfer().approve().transferFrom()ledgerCanisterIdPERMISSIONS_PROMPT_NOT_REGISTEREDICRC25_REQUEST_PERMISSIONSICRC27_ACCOUNTSICRC21_CALL_CONSENT_MESSAGEICRC49_CALL_CANISTERBUSYicrc29_statusicrc25_supported_standardsconnect()connect()postMessageask_on_userequestPermissionsNotGranted()ICRC21_CALL_CONSENT_MESSAGEloadingresulterrorresultpayload.statussenderownersendericrc49_call_canisterownerSENDER_NOT_ALLOWEDowneraccounts()disconnect()Signer.disconnect()wallet.disconnect()ask_on_useconnect()// 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';| Class | Use for |
|---|---|
| ICP ledger operations — |
| Any ICRC ledger — |
| Low-level custom canister calls via protected |
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];{owner, request}ledgerCanisterIdconst 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}
});{owner, ledgerCanisterId, params}ledgerCanisterIdconst 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}
});const standards = await wallet.supportedStandards();
const currentPermissions = await wallet.permissions();
await wallet.disconnect();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 */
}
}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 */
}
}
});
OkWarn{ Ok: consentInfo }{ Warn: { consentInfo, canisterId, method, arg } }icrc1_transfericrc2_approveicrc2_transfer_fromsigner.disconnect();| Code | Name | Meaning |
|---|---|---|
| 500 | | Origin mismatch |
| 501 | | Missing prompt handler |
| 502 | | |
| 503 | | Concurrent request rejected |
| 504 | | Owner identity not set |
| 1000 | | Catch-all |
| 2000 | | Method not supported |
| 3000 | | Permission denied |
| 3001 | | User cancelled |
| 4000 | | IC call failure |
| State | Constant | Behavior |
|---|---|---|
| Granted | | Proceeds without prompting |
| Denied | | Rejected immediately (error 3000) |
| Ask on use | | Prompts user on access (default) |
localStorageoisy_signer_{origin}_{owner}hosticp 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'
});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 5174const wallet = await IcpWallet.connect({
url: 'http://localhost:5174/sign',
host: 'http://localhost:8000' // match your local network port
});hosthttps://icp-api.ioconst wallet = await IcpWallet.connect({
url: 'https://your-wallet.example.com/sign'
});connect()RelyingPartyDisconnectedErrorwallet.supportedStandards()requestPermissionsNotGranted()wallet.permissions()granted{allPermissionsGranted: true}wallet.accounts(){owner: string}ownericrc1Transfer()transfer()icrc2Approve()approve()transferFrom()bigint