diff --git a/evaluations/wallet-integration.json b/evaluations/wallet-integration.json index 429bb131..d48ba4ef 100644 --- a/evaluations/wallet-integration.json +++ b/evaluations/wallet-integration.json @@ -1,64 +1,115 @@ { "skill": "wallet-integration", - "description": "Evaluation cases for the wallet-integration skill. Tests whether agents produce correct ICRC signer protocol code, avoid top-level await, and use the right wallet classes.", - + "description": "Evaluation cases for the wallet-integration skill. Tests whether agents integrate an external signer with @icp-sdk/signer correctly: the right library and pins, reads off the SignerAgent, user-initiated popups, the error class transport failures actually arrive as, and reconnect-after-reload.", "output_evals": [ { - "name": "No top-level await in wallet code", - "prompt": "Show me just the JavaScript code to connect an ICRC wallet and make a single token transfer. I'm using Vite with default settings. Keep it minimal — no signer-side code, no deploy steps.", + "name": "Adversarial: reaches for the superseded oisy library", + "prompt": "Give me the npm install command to add OISY wallet support to my IC dapp. No integration code.", "expected_behaviors": [ - "All await calls are inside async functions — no bare top-level await at module scope", - "Uses IcrcWallet or IcpWallet connect pattern", - "Shows wallet.transfer or wallet.icrc1Transfer inside an async function", - "Does NOT recommend changing build.target to 'esnext' or 'es2022' in Vite config" + "Installs @icp-sdk/signer as the client library", + "Pins @icp-sdk/core to ^6", + "Does NOT recommend @dfinity/oisy-wallet-signer" ] }, { - "name": "IcpWallet vs IcrcWallet selection", - "prompt": "I want to send ICP tokens from my frontend using a wallet. Which class should I use? Just the class name, import path, and a one-line explanation of when to use each.", + "name": "Adversarial: reading a balance through SignerAgent", + "prompt": "I connected OISY with @icp-sdk/signer and built a SignerAgent. Show me how to read the user's ICRC-1 balance and how to send a transfer — just those two calls, no connection or setup boilerplate.", "expected_behaviors": [ - "Recommends IcpWallet for ICP ledger operations", - "Explains that IcpWallet does not require ledgerCanisterId (defaults to ICP ledger)", - "Explains that IcrcWallet is for any ICRC ledger and requires ledgerCanisterId", - "Shows the correct import from 'oisy-wallet-signer'" + "Reads the balance through a plain HttpAgent, NOT through the SignerAgent", + "Explains that SignerAgent turns a query into a full canister call routed through the wallet, so a read would cost the user an approval interaction", + "Sends the transfer through the SignerAgent" ] }, { - "name": "Error handling pattern", - "prompt": "How do I handle errors when the user rejects a wallet transaction? Just show the try/catch pattern with the relevant error types.", + "name": "Adversarial: establishing the wallet popup on mount", + "prompt": "Is this correct?\n\n```jsx\nuseEffect(() => {\n signer.getAccounts().then(setAccounts);\n}, []);\n```\n\nIt's a React app connecting to OISY via @icp-sdk/signer. Answer in a short paragraph plus the corrected snippet.", "expected_behaviors": [ - "Shows try/catch around wallet operations", - "Mentions RelyingPartyResponseError with error codes (3000, 3001, 4000)", - "Mentions RelyingPartyDisconnectedError for popup closure" + "Identifies that opening the wallet on mount is not user-initiated and gets blocked by the browser", + "Moves the call into a click handler (or equivalent user gesture)", + "Does NOT suggest working around it by disabling the check, raising a timeout, or retrying" ] }, { - "name": "Signer implementation", - "prompt": "Show me the minimal code to initialize a Signer and register all four required prompts (permissions, accounts, consent message, call canister). Just the signer-side setup, no dApp/relying-party code.", + "name": "Adversarial: signer 6 against a core ^5 project", + "prompt": "My dapp's package.json pins \"@icp-sdk/canisters\": \"^3\" and \"@icp-sdk/core\": \"^5\". Give me the npm install command to add @icp-sdk/signer so I can integrate OISY. No integration code.", "expected_behaviors": [ - "Uses Signer.init() with owner identity and host", - "Shows signer.register() for each prompt type", - "Registers ICRC25_REQUEST_PERMISSIONS and ICRC27_ACCOUNTS prompts", - "Registers ICRC21_CALL_CONSENT_MESSAGE and ICRC49_CALL_CANISTER prompts" + "States that @icp-sdk/signer 6 peers @icp-sdk/core@^6 and so cannot be installed against the pinned core ^5", + "Says to move to @icp-sdk/core@^6 together with @icp-sdk/canisters@^4", + "Does NOT recommend --legacy-peer-deps or --force to get past the peer conflict" + ] + }, + { + "name": "Adversarial: assumes every wallet can do what the app needs", + "prompt": "My dapp should let users transfer tokens from whatever wallet they use, not just OISY. Show me just the connect step with @icp-sdk/signer — no transfer code.", + "expected_behaviors": [ + "Calls getSupportedStandards() before relying on the wallet's capabilities", + "Branches on what the returned standards actually contain, rather than proceeding as though any signer supports everything", + "Does NOT hardcode one wallet's capability set as though it applied to all signers" + ] + }, + { + "name": "Adversarial: getAccounts() returns a list, not an account", + "prompt": "I'm connecting OISY with @icp-sdk/signer and I need the user's account so I can show their balance. Show me just the connect function — no ledger setup, no transfer.", + "expected_behaviors": [ + "Does NOT index getAccounts() as accounts[0] without handling the list being empty", + "States or handles that the list can be empty because the user may decline to share any account", + "Treats more than one account as possible rather than silently picking the first" + ] + }, + { + "name": "Connection does not survive a page reload", + "prompt": "After a page refresh my OISY connection is gone and the balances disappear until the user clicks connect again. How should I handle this with @icp-sdk/signer? Describe the approach, no code.", + "expected_behaviors": [ + "States that the transport channel cannot survive a reload — there is no wallet session to restore", + "Persists the whole account rather than just the owner principal, so a subaccount survives the reload, and renders read-only state from it with an ordinary (anonymous) agent without opening the wallet", + "Re-establishes the signer lazily on the first write, accepting that this reopens the wallet", + "Does NOT suggest persisting the signer, the channel, or the SignerAgent itself" + ] + }, + { + "name": "Adversarial: a blocked wallet popup is not the error class you expect", + "prompt": "My OISY connect button fails in Safari, but my `catch` never enters the `err instanceof PostMessageTransportError` branch. I'm on @icp-sdk/signer. What's going on and how should the catch block look? Short answer, catch block only.", + "expected_behaviors": [ + "States that Signer wraps the transport error into a SignerError with code 4000, so an instanceof check on the thrown error cannot match", + "Says the original transport error is available as err.cause", + "Handles code 4000, not only 4001 (either as reconnect, or split by err.cause into popup-blocked vs reconnect)", + "Does NOT claim signer methods throw PostMessageTransportError directly" + ] + }, + { + "name": "Adversarial: an ICRC-25 code the table does not name", + "prompt": "My wallet returned a SignerError with code 3002, which isn't one of the codes listed in ICRC-25. I'm using @icp-sdk/signer. How should my catch block deal with codes it doesn't recognise? Short answer.", + "expected_behaviors": [ + "Explains that ICRC-25 assigns meaning by range, not only by the named codes, so 3002 inherits the 3xxx meaning", + "Handles 3002 as a user-action outcome rather than as a failure to rethrow", + "Recommends falling back on the range after the named codes, instead of a bare default that rethrows" + ] + }, + { + "name": "Adversarial: redirect flow loses a value across the navigation", + "prompt": "I'm using UrlTransport from @icp-sdk/signer. I fetch a nonce (a Uint8Array) and make a signer request with it. After the wallet redirects back, sometimes the nonce has been re-fetched and is a different value, and when I do journal it, it comes back as {\"0\":12,\"1\":43,...} instead of bytes. What is going on in each case? Short answer, no full example.", + "expected_behaviors": [ + "Identifies that a value which must come back identical — a nonce — has to go through transport.memoize() so it is journaled and replayed instead of re-fetched", + "Notes that memoize persists via JSON, so a Uint8Array or other non-JSON value has to be converted before journaling" ] } ], - "trigger_evals": { "description": "Queries to test whether the skill activates correctly.", "should_trigger": [ "Connect a wallet to my ICP dapp", - "How do I implement ICRC wallet signing?", "I need to integrate Oisy wallet into my frontend", - "How does the ICRC signer protocol work?", + "How does the ICRC signer protocol work between a dapp and a wallet?", "Add wallet connect to my dapp", - "Implement the relying party side of wallet integration" + "Implement the relying party side of wallet integration", + "Should I use per-action approval or a session delegation for wallet calls?" ], "should_not_trigger": [ "Add Internet Identity login to my app", + "I'm building a wallet — how do I handle incoming ICRC-49 call requests from dapps?", + "Log my CLI agent into oisy.com so it can act as me", "How do I deploy my canister?", "Set up stable memory in Rust", - "How do I make inter-canister calls?", "Create an ICRC-1 token ledger", "How do I use passkeys for authentication?" ] diff --git a/skills/wallet-integration/SKILL.md b/skills/wallet-integration/SKILL.md index 33a26f83..be1a3992 100644 --- a/skills/wallet-integration/SKILL.md +++ b/skills/wallet-integration/SKILL.md @@ -1,8 +1,8 @@ --- name: wallet-integration -description: "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." +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" +compatibility: "Node.js >= 22, a browser (secure context: HTTPS, localhost, or 127.0.0.1)" metadata: title: Wallet Integration category: DeFi @@ -12,444 +12,459 @@ metadata: ## 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`. +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 integration using `@dfinity/oisy-wallet-signer`. Other integration paths (IdentityKit, signer-js) exist but are not covered here. +This skill covers **integrating a signer**. Implementing one is out of scope — consent screens, prompt registration and account custody are the wallet's job. -**The signer model = explicit per-action approval.** `connect()` establishes a channel. Nothing more. +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). -**It is not:** +| 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**) | -- A session system -- A delegated identity (no ICRC-34) -- A background executor +## What the model is -**ICRC standards implemented:** +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. -- ICRC-21 — Canister call consent messages -- ICRC-25 — Signer interaction standard (permissions) -- ICRC-27 — Accounts -- ICRC-29 — Window PostMessage transport -- ICRC-49 — Call canister +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. -**Not implemented:** +### When NOT to use this skill -- 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. +- **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 -- `@dfinity/oisy-wallet-signer` (>= 4.1.0) -- Peer dependencies: `@dfinity/utils` (>= 4.2.0), `@dfinity/zod-schemas` (>= 3.2.0), `@icp-sdk/canisters` (>= 3.5.0), `@icp-sdk/core` (>= 5.0.0), `zod` -- A non-anonymous identity on the signer side (e.g. `Ed25519KeyIdentity`) - ```bash -npm i @dfinity/oisy-wallet-signer @dfinity/utils @dfinity/zod-schemas @icp-sdk/canisters @icp-sdk/core zod +npm i '@icp-sdk/signer@^6' '@icp-sdk/core@^6' ``` -## How It Works +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`. -### End-to-End Lifecycle +The transport URL must be a **secure context** — HTTPS, `localhost`, or `127.0.0.1`. -```text -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 -``` +## Pick a transport -## Pitfalls +The transport is the only part that knows *how* the wallet is reached; the `Signer` API above it is identical whichever you choose. -1. **Importing classes from the wrong entry point.** `Signer`, `RelyingParty`, `IcpWallet`, and `IcrcWallet` are **not** exported from the main entry point. Import them from their dedicated subpaths or you get `undefined`. +| 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 - // WRONG — will fail - import {Signer} from '@dfinity/oisy-wallet-signer'; +```typescript +import { Signer } from '@icp-sdk/signer'; +import { PostMessageTransport } from '@icp-sdk/signer/web'; - // 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'; - ``` +// 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) -2. **Using `IcrcWallet` without `ledgerCanisterId`.** Unlike `IcpWallet` (which defaults to the ICP ledger `ryjl3-tyaaa-aaaaa-aaaba-cai`), `IcrcWallet.transfer()`, `.approve()`, and `.transferFrom()` all **require** `ledgerCanisterId`. Omitting it causes a runtime error. +```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(); +} -3. **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. +// 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 }); +} +``` -4. **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. +### Redirect (ICRC-167) -5. **Assuming `connect()` = authenticated session.** `connect()` only opens a `postMessage` channel. The user has not pre-authorized anything. Permissions default to `ask_on_use` — the signer will prompt the user on first use of each method. Call `requestPermissionsNotGranted()` after connecting to request all permissions upfront in a single prompt instead of per-method prompts. +`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: -6. **Not handling the consent message state machine.** The `ICRC21_CALL_CONSENT_MESSAGE` prompt fires multiple times with different statuses: `loading` → `result` | `error`. If you only handle `result`, the UI breaks on loading and error states. Always branch on `payload.status`. +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. -7. **`sender` not matching `owner`.** The signer validates that `sender` in every `icrc49_call_canister` request matches the signer's `owner` identity. A mismatch returns error 502 (`SENDER_NOT_ALLOWED`). Always use the `owner` from `accounts()`. +`SignerAgent` works over this transport, so a redirect flow is the same code as a popup flow: -8. **Not calling `disconnect()`.** Both `Signer.disconnect()` and `wallet.disconnect()` must be called on clean-up. Forgetting this leaks event listeners and leaves popup windows open. +```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' +}); -9. **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. +// 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 + }); +} +``` -10. **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. +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. -## Implementation +## Negotiate capabilities -### Import Map +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 -// 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'; +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') + }; +} ``` -### dApp Side (Relying Party) - -#### Choosing the Right Class +`getSupportedStandards` needs no permission, so it is safe as a first call. -| 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()` | +## Permissions and accounts -#### Connect, Permissions, 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. -All wallet operations are async. Wrap them in functions — do not use top-level `await`, which fails with Vite's default `es2020` build target. +| State | Behaviour | +|-------|-----------| +| `granted` | Proceeds without prompting | +| `denied` | Rejected immediately with error `3000` | +| `ask_on_use` | Prompts on first use (the usual initial state) | ```typescript -// Wrapping in an async function avoids top-level await, which requires -// build.target >= es2022. This works with any bundler target. -async function connectWallet() { - 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(); +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); + } - const accounts = await wallet.accounts(); - const {owner} = accounts[0]; - return {wallet, owner}; + // 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(); } ``` -#### IcpWallet — ICP Transfers and Approvals +`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`. -Uses `{owner, request}` — no `ledgerCanisterId` needed. +## Executing a call the user approves -```typescript -async function icpWalletTransfers() { - 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} - }); +`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. - await wallet.icrc2Approve({ - owner, - request: {spender: {owner: spenderPrincipal, subaccount: []}, amount: 500_000_000n} - }); +```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 + }; } ``` -#### IcrcWallet — Any ICRC Ledger - -Uses `{owner, ledgerCanisterId, params}` — `ledgerCanisterId` is **required**. +**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 -async function icrcWalletTransfers() { - 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} +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 }); - 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} - }); + return { balance, block }; } ``` -#### Query Methods and Disconnect +`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 -async function queryAndDisconnect(wallet: IcrcWallet) { - const standards = await wallet.supportedStandards(); - const currentPermissions = await wallet.permissions(); +import { Signer } from '@icp-sdk/signer'; - await wallet.disconnect(); +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(); + } } ``` -#### Error Handling (dApp Side) +**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 -async function safeTransfer(wallet: IcrcWallet) { +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 { - 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 */ - } + 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 }); +} ``` -### Wallet Side (Signer) +Treat "disconnect" as clearing your own state — there is no wallet-side logout to call. -#### Initialise and Register All Prompts +## Error handling ```typescript -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()}]); - } -}); +import { Signer, SignerError } from '@icp-sdk/signer'; +import { PostMessageTransportError } from '@icp-sdk/signer/web'; +import type { IcrcAccount } from '@icp-sdk/canisters/ledger/icrc'; -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 +async function safeTransfer( + signer: Signer, account: IcrcAccount, to: IcrcAccount, amount: bigint +) { + try { + await showBalanceThenTransfer(signer, account, to, amount); + } catch (err) { + // Anything that is not a SignerError — SignerAgentError above all, where the + // wallet responded and the response failed validation — is not handled here. + if (!(err instanceof SignerError)) throw err; + + switch (err.code) { + case 3001: return; // user cancelled — not a failure + case 3000: showPermissionHelp(); return; // permission denied + case 2000: showUnsupported(); return; // wallet does not support the method + case 4000: // every transport failure lands here + case 4001: // only if the signer itself returns it + // The transport error is the `cause`, never the error you caught. + if (err.cause instanceof PostMessageTransportError) showPopupBlockedHelp(); + else promptReconnect(); + return; } - } -}); -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 */ + // ICRC-25 numbers errors by range and a signer may return a code this + // switch has never seen, so fall back on the range rather than rethrowing. + switch (Math.floor(err.code / 1000)) { + case 3: + // 3xxx is "user action", so nothing broke — but 3001 is the only code + // where silence is right, because there you know they cancelled on + // purpose. For an unnamed 3xxx say the action did not go through, or + // the UI sits unchanged after the user pressed the button. + showActionNotCompleted(); + return; + case 2: showUnsupported(); return; // 2xxx not supported + case 4: promptReconnect(); return; // 4xxx transport channel + default: throw err; // 1xxx generic, and anything else } } -}); - +} ``` -#### Consent Message: `Ok` vs `Warn` +ICRC-25 groups errors into ranges and names a few codes inside each. Handle the codes you know, then fall back on the range — a signer may return `3002` or `4002`, and a bare `default: throw` would mishandle it: -- `{ Ok: consentInfo }` — canister implements ICRC-21; message is canister-verified -- `{ Warn: { consentInfo, canisterId, method, arg } }` — signer generated a fallback (for `icrc1_transfer`, `icrc2_approve`, `icrc2_transfer_from`) +| Range | Code | Meaning | Handle by | +|-------|------|---------|-----------| +| `1xxx` general | `1000` | Generic error | Surfacing `err.data` to developers | +| `2xxx` not supported | `2000` | Not supported | Negotiating capabilities first | +| `3xxx` user action | `3000` | Permission not granted | Explaining what to re-grant | +| | `3001` | **Action aborted — the user cancelled** | Returning quietly; this is normal | +| `4xxx` transport | `4000` | Network error | Reconnecting — the library also reports every transport failure here | +| | `4001` | Transport channel closed | Reconnecting (signer-reported only; see below) | -Always distinguish these in the UI — warn the user when the message is signer-generated. +Two failures do not arrive as the class you would expect, and they call for opposite reactions: -#### Disconnect +- **Transport failures arrive as `SignerError` with code `4000`.** `Signer.openChannel()` catches whatever the transport threw — `PostMessageTransportError`, `UrlTransportError`, `BrowserExtensionTransportError` — and rethrows it as a `SignerError` with the original as `cause`. So a blocked popup is *not* `instanceof PostMessageTransportError`; test `err.cause` for that. The library also never emits `4001`: "channel closed before a response" is `4000` too, and `4001` reaches you only if the signer itself returns it. +- **`SignerAgentError`** — the wallet *did* respond, and the response failed validation: the returned content map did not match the call you sent (canister, method, argument, sender, nonce), the certificate did not verify against the IC root key, or the reply was absent from the certified tree. `SignerAgent` runs those checks for you, so this is a wallet returning something it should not have. Do not treat it as a connectivity fault and retry — surface it. -```typescript -signer.disconnect(); -``` +## Pitfalls -### Error Code Reference +1. **Opening the popup outside a click handler.** `PostMessageTransport` rejects establishment that is not user-initiated (`detectNonClickEstablishment`, default `true`) because Safari and others block such popups. Connect from an event handler, never on mount or in a `useEffect`. -| 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 | + ```typescript + // WRONG — blocked, and the transport detects it + useEffect(() => { signer.getAccounts(); }, []); -### Permission States + // CORRECT + button.addEventListener('click', () => signer.getAccounts()); + ``` -| 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) | +2. **Reading through `SignerAgent`.** `query()` is upgraded to an update call routed through the wallet, so every read costs the user an approval interaction. Public data — a ledger balance, token metadata — needs no wallet: read it with a plain `HttpAgent`, anonymous by default. Only writes go through `SignerAgent`. -Permissions stored in `localStorage` as `oisy_signer_{origin}_{owner}` with timestamps. Default validity: 7 days. +3. **Expecting a connection to survive a reload.** No channel outlives the page. Persist the account — both halves, per pitfall 12 — for read-only rendering, and reconnect on first write. See above. -## Deploy & Test +4. **Indexing `getAccounts()` blindly.** It returns a *list* of the accounts the user chose to share. ICRC-27 lets the signer prompt for that selection, so the list can be **empty** — the user declined, which is not an error — and it can hold **several**, where `[0]` silently picks for them. `accounts[0]` on an empty list is `undefined`, so the crash lands later at `.owner` rather than at the call. Check the length, and offer a picker when there is more than one. -### Local Development — Your Own Signer +5. **Assuming a wallet's capabilities.** Call `getSupportedStandards()`. A signer may list accounts (ICRC-27) without executing calls (ICRC-49), or speak a transport you have not built for. -If you are building both the dApp and the wallet/signer, start a local network and pass `host` to both sides: +6. **Coding against one wallet's non-standard error codes.** ICRC-25 owns `1xxx`–`4xxx` and names `1000`/`2000`/`3000`/`3001`/`4000`/`4001` inside them. A code outside those ranges is a vendor extension and does not port — earlier revisions of this skill documented a `503 BUSY` that exists only in `@dfinity/oisy-wallet-signer` and in no standard. Branch on the named codes, fall back on the range, and treat anything outside the ranges as generic. -```bash -icp network start -d -``` +7. **Journaling something non-serializable through `memoize()`.** It persists via JSON, so a `Uint8Array` round-trips as `{"0":1,"1":2,…}` and a `CryptoKey` as `{}` — silently, with the flow failing on return rather than at the call. Convert to a plain array (or hex) before memoizing and back afterwards. -```typescript -// dApp side — point to your local wallet's /sign route -async function connectLocalWallet() { - const wallet = await IcrcWallet.connect({ - url: 'http://localhost:5174/sign', - host: 'http://localhost:8000' - }); - return wallet; -} +8. **Diverging on a redirect replay.** With `UrlTransport`, issue the same requests and `memoize` steps in the same order on every load, and route anything a request depends on — a nonce above all — through `memoize`. Re-fetching a single-use value on the return load invalidates the flow. -// Wallet/signer side — same local network host -const signer = Signer.init({ - owner: identity, - host: 'http://localhost:8000' -}); -``` +9. **A `callbackUrl` that is relative, carries a fragment, or is not served correctly.** It must be absolute, fragment-free (the transport appends its own), on an origin you control, and declared in that origin's `/.well-known/ii-auth-callbacks` — matched exactly, so the full URL. Declaring it is not enough: the wallet reads that document **cross-origin**, so serve it as JSON with CORS or a correctly listed callback still fails validation. -### Local Development — Using the Pseudo Wallet Signer + ```json + { "callbacks": ["https://app.example.com/signer-callback"] } + ``` -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: + With `@dfinity/static-site` that is a `_headers` block: -```bash -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 -``` + ``` + /.well-known/ii-auth-callbacks + Content-Type: application/json + Access-Control-Allow-Origin: * + ``` -Then connect from your dApp: + Validation fails closed — undeclared, unreadable, or not matching exactly, and the response never comes back. See the **internet-identity** skill, which documents the same file for redirect sign-in. -```typescript -async function connectPseudoWallet() { - const wallet = await IcpWallet.connect({ - url: 'http://localhost:5174/sign', - host: 'http://localhost:8000' // match your local network port - }); - return wallet; -} -``` +10. **Top-level `await` in wallet code.** Every call here is async, and with `PostMessageTransport` a module-load `await` opens the popup outside a user gesture, which the transport rejects — pitfall 1. Wrap those calls in functions the UI invokes. `UrlTransport` is the exception, and the reverse: it navigates the top level rather than opening a popup, has no gesture check, and its flow *must* re-run on the callback-route load so the journal can replay — requiring a click there would break it. Either way, do not rely on the build to catch a stray top-level `await`: Vite ≤5 defaulted to `es2020` and rejected it outright, while Vite 6+ defaults to `baseline-widely-available` and allows it. -### Mainnet +11. **`@icp-sdk/canisters@^3` with `@icp-sdk/signer@^6`.** They cannot coexist — canisters 3 peers `@icp-sdk/core@^5` or older, signer 6 peers `^6`, so `npm install` fails with `ERESOLVE`. Move to `@icp-sdk/canisters@^4` and `@dfinity/utils@^5`. Do not reach for `--legacy-peer-deps`: it skips the peer check and installs the mismatched pair anyway, so the incompatibility surfaces at runtime instead of at install time. -On mainnet, point to the wallet's production signer URL and omit `host` (defaults to `https://icp-api.io`): +12. **Treating an account as just a principal.** `getAccounts()` returns `{ owner, subaccount? }` — an `IcrcAccount`. The subaccount is usually absent, because signers commonly offer only the default one, so code that assumes a bare principal works until it meets a signer that does not. Carry the account whole and let the library helpers do the rest: **compare** with `encodeIcrcAccount()` and never `owner` alone (that encoding normalizes the default subaccount, so the same principal with a *different* one is correctly a different account), **persist** with `encodeIcrcAccount()` / `decodeIcrcAccount()`, and **send** with `from_subaccount` for the sender plus `toCandidAccount()` for the recipient. `SignerAgent` is the exception — its `account` is a `Principal`, which is why the subaccount travels in the ledger call arguments instead. -```typescript -async function connectMainnetWallet() { - const wallet = await IcpWallet.connect({ - url: 'https://your-wallet.example.com/sign' - }); - return wallet; -} -``` +13. **Firing a call immediately after connecting.** Let the user initiate. An unprompted approval dialog straight after connect reads as an attack, and wallets are within their rights to reject it. -## Expected Behavior +## Testing against a real wallet -### Connection +There is no local signer to run: OISY is hosted, and the transport's secure-context requirement applies to the *signer's* URL, not to your origin — `https://oisy.com/sign` satisfies it. Serving your own frontend from `localhost` is fine, and it is a browser secure context, so WebCrypto key generation works there too. Test on testnet tokens rather than mainnet value. -- `connect()` resolves with a wallet instance; throws `RelyingPartyDisconnectedError` on timeout -- `wallet.supportedStandards()` returns an array containing at least ICRC-21, ICRC-25, ICRC-27, ICRC-29, ICRC-49 +```bash +icp network start -d +icp deploy +``` -### Permissions +Get free testnet tokens from the [ICP Faucet](https://faucet.internetcomputer.org) and switch OISY to the **IC (testnet tokens)** network to see them. Useful ledgers: -- `requestPermissionsNotGranted()` triggers the signer's permissions prompt -- After approval, `wallet.permissions()` returns scopes with state `granted` -- A second call returns `{allPermissionsGranted: true}` without prompting again +| Token | Ledger canister | +|-------|-----------------| +| TESTICP | `xafvr-biaaa-aaaai-aql5q-cai` | +| TICRC1 | `3jkp5-oyaaa-aaaaj-azwqa-cai` | -### Accounts +Set `host: 'https://icp-api.io'` on the agent even when serving from `localhost` — `host` is the API endpoint calls go to, not the origin your app is served from. -- `wallet.accounts()` returns at least one `{owner: string}` (principal as text) -- The returned `owner` matches the signer's identity principal +## Expected Behavior -### Transfers and Approvals +- The first `getAccounts()` opens the wallet and resolves with the accounts the user chose to share, as `{ owner: Principal, subaccount?: Uint8Array }` — possibly none of them. +- A ledger `transfer` through `SignerAgent` shows the user the call to approve and resolves with a `bigint` block index. +- Cancelling the **canister-call approval** rejects with `SignerError` and `code === 3001`. The other two refusals look different: declining to share accounts resolves `getAccounts()` with `[]`, and denying a permission gives code `3000`. +- After a reload, read-only state renders with no popup; the first write reopens one. -- `icrc1Transfer()` / `transfer()`, `icrc2Approve()` / `approve()`, and `transferFrom()` all resolve with a `bigint` block index -- Each triggers the consent message prompt on the signer before execution +## Additional References +- **internet-identity** — II sign-in, and the place to go if you need a session rather than per-action approval +- **agent-web-identity** — letting an agent or CLI act as the user in an II app +- **icp-cli** — `@icp-sdk/bindgen` actors to call your own canister through a `SignerAgent` +- **canister-security** — verifying `msg.caller` on the backend once calls arrive +- [OISY signer demo](https://github.com/dfinity/examples/tree/master/hosting/oisy-signer-demo) — a working relying party (React) built on `@icp-sdk/signer` +- [ICRC signer standards](https://github.com/dfinity/wg-identity-authentication) — the specifications behind every method above