diff --git a/CLAUDE.md b/CLAUDE.md index 78cf4da..82f4d8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,9 @@ Every interactive or assertion-worthy element ships a stable `data-testid` so en - Build outputs: `dist/` for the web build and `dist-extension/` for the Chrome extension (`pnpm run build:extension`) - Dev server: `http://localhost:3011` - The extension/manifest version comes from this repo's `package.json` (single source of truth), injected at build time via the `__APP_VERSION__` Vite define and a manifest-rewrite plugin; never hardcode versions in `manifest.json` or source +- The dApp wire format is typed by `@canton-network/core-types`. `src/extension/messages.ts` derives the page-facing frames from its `SpliceMessage` schema and pins the event names to its `WalletEvent` enum with `satisfies`; never hand-declare a message type the SDK already publishes. Import the SDK's *types* only — a runtime import drags zod into the content script, which loads on every page +- Chrome loads the content script (`contentScript.ts`) as a classic file, so it may not contain an `import` after bundling. `STANDALONE_SCRIPTS` in `vite.config.ts` builds each such script on its own as a single-entry IIFE, which is what lets it import shared modules. A new content script goes in that list and in `knip.json`'s entries, not in the main build's `rollupOptions.input` +- The wallet sets no `window.canton` and declares no `world: "MAIN"` content script. That global is the dApp SDK's fast `detect()` path, and it was removed on purpose: it re-exposes wallet-installed to any script enumerating `window`, which is what dropping the load-time announce was for, and it only shortens the case that is already fast (an absent wallet sets no global and still costs the SDK's 2 s timeout). See `architecture.md`; `test/extensionManifest.test.ts` enforces it - `bin/` holds the gitleaks binary and is gitignored. Never commit it; `scripts/install-gitleaks.sh` puts it there on demand ## Git hooks @@ -82,6 +85,7 @@ Accounts are scoped to the parties that endpoint hosts. A Canton party can only - **DOM environment:** `@happy-dom/global-registrator` bootstrapped in [`test/setup-dom.ts`](test/setup-dom.ts) for React Testing Library interaction tests - **Test config:** [`test/tsconfig.json`](test/tsconfig.json) sets `jsx: react-jsx`; `TSX_TSCONFIG_PATH` in the test script points tsx at it - **Run tests:** `pnpm test` +- **Types in tests are gated too:** `pnpm run typecheck` runs `tsc -b` over the app and node projects, then `tsc -p test/tsconfig.json` over the tests. It is a second `tsc` call rather than a project reference because `tsc -b` would need `composite: true`, which cannot be combined with `noEmit`. `tsx` erases types without checking them, so without this step a test could hold a type error and still pass; keep test stubs compiling rather than widening a cast to silence one - **What to test:** Business logic, API integrations, component behavior - **What not to test:** Styling, third-party library internals, trivial getters/setters - **Coverage:** Aim for meaningful coverage, not a number. Cover the paths that matter. @@ -154,3 +158,4 @@ The `/sdlc:create-issue` skill applies these labels automatically when creating - [CIP-0103 dApp API spec](https://github.com/canton-foundation/cips/blob/main/cip-0103/cip-0103.md) — every field on the account object `listAccounts` returns is required, `networkId` included; the network object on the status payload is optional - [wallet-service](https://github.com/BootNodeDev/canton-wallet-service) — the JSON-RPC endpoint this wallet forwards to - [Reown (WalletConnect cloud)](https://cloud.reown.com) +- [Canton wallet SDK](https://github.com/canton-network/wallet) — home of `@canton-network/core-types` (the `SpliceMessage` wire schema) and `@canton-network/dapp-sdk` (the `ExtensionAdapter` a dApp detects this wallet with) diff --git a/architecture.md b/architecture.md index 39ec283..3152de0 100644 --- a/architecture.md +++ b/architecture.md @@ -62,7 +62,8 @@ src/ with polling and imperative refetch config/ Runtime config persisted to localStorage (the saved wallet-service endpoints and the id of the one in use) plus the shared QueryClient factory - extension/ Chrome extension scripts: background, content script, provider injection + extension/ Chrome extension scripts: background, the content script, provider + injection, and the wire message types shared by all of them network/ One shared poll of the wallet-service status, so the network in use is read from a single place (NetworkProvider + useNetwork) provider/ CIP-0103 wallet provider — request dispatcher and method handlers @@ -136,10 +137,14 @@ Manages the WalletConnect sign client lifecycle and session event handling. Connects the extension popup UI to web pages and the extension background. - **`background.ts`** — Persistent service worker. Maintains a queue of pending dApp requests, updates the action badge with the count, and caches a wallet snapshot for the popup. A queued request also opens the wallet on its own, as a `chrome.windows.create` popup window, because Chrome only allows `action.openPopup` from a gesture-carrying event and the focused window when a dApp request lands is the SDK's toolbar-less picker. It opens centered on the display the user is on, found by matching the middle of the focused browser window (`windows.getLastFocused`) against each display's work area — the screen minus the menu bar and dock, read through `system.display.getInfo`, which is why the manifest asks for the `system.display` permission. With no focused window it centers on the primary display, and with no display to measure Chrome picks the spot. One window at a time: a later request focuses it, answering the last pending request closes it, and closing it by hand answers every still-pending request with `4001 user rejected` so the dApp's call settles. A connected dApp calling `sdk.open()` (`CARPINCHO_OPEN_WALLET`, throttled per origin) opens that same window and marks it `keepOpen`, so answering an unrelated request does not take away a window the user asked to see; the URL the page sent is ignored in favour of the extension's own page. The toolbar icon still opens the normal popup. Also records the set of direct (injected-provider) dApp origins that have connected to `chrome.storage.session`, which the popup reads and watches via `storage.session.onChanged` to drive the footer's connection state. That connected-origins set is also a security input: it decides whether a requesting origin is connected (gating account access), gates `sdk.open()`, and wallet events are broadcast only to connected dApp tabs. A wallet-initiated disconnect is relayed from the worker — one origin for `CARPINCHO_FORGET_CONNECTED_ORIGIN`, all of them for the `CARPINCHO_DISCONNECT_DAPPS` a vault reset sends — so it can relay before it forgets the origins it is relaying to. -- **`contentScript.ts`** — Injected into every matched page; announces Carpincho with `canton:announceProvider`, responds to `canton:requestProvider`, and bridges page `window.postMessage` requests to the extension runtime. It also answers `SPLICE_WALLET_EXT_READY` with an ack and turns the `SPLICE_WALLET_EXT_OPEN` that `sdk.open()` posts into a `CARPINCHO_OPEN_WALLET` for the background. The announced `icon` is a base64 `data:image/png` URI inlined from `public/icons/carpincho-48.png` at build time through the `__WALLET_ICON_DATA_URL__` Vite define: the dApp SDK's wallet picker renders in a `blob:` document, so a `chrome-extension://` URL would render broken. +- **`contentScript.ts`** — Injected into every matched page; answers `canton:requestProvider` with a `canton:announceProvider` and bridges page `window.postMessage` requests to the extension runtime. It also answers `SPLICE_WALLET_EXT_READY` with an ack and turns the `SPLICE_WALLET_EXT_OPEN` that `sdk.open()` posts into a `CARPINCHO_OPEN_WALLET` for the background. It never announces on its own: the script matches ``, so an announcement at load would tell every site the user visits that the wallet is installed. The SDK's `requestAnnouncedProviders()` dispatches the request, so discovery still finds Carpincho. The announced `icon` is a base64 `data:image/png` URI inlined from `public/icons/carpincho-48.png` at build time through the `__WALLET_ICON_DATA_URL__` Vite define: the dApp SDK's wallet picker renders in a `blob:` document, so a `chrome-extension://` URL would render broken. +- **`discovery.ts`** — Who the wallet says it is (the announced detail) and the two `canton:*` discovery event names. + + The wallet deliberately sets **no `window.canton`**. A page-world (`world: "MAIN"`) script setting that global is the fast path in the dApp SDK's `ExtensionAdapter.detect()`, which returns `true` on a truthy `window.canton` and otherwise posts `SPLICE_WALLET_EXT_READY` and waits for the ack. It is not worth what it costs. The global re-exposes, to any script that merely enumerates `window`, the same fact the load-time announce was dropped to stop advertising, and it only shortens the case that is already fast: the ack lands in a millisecond or two, while a genuinely absent wallet sets no global and still costs the SDK's full 2 s detect timeout. The SDK never writes this global anywhere itself, not even in its own integration-test double, and its `detect()` checks it without comparing `target`, so setting it makes every statically registered adapter for *another* Canton wallet report installed. `test/extensionManifest.test.ts` locks the decision in. - **`directProvider.ts`** — Per-origin permission gate for injected-provider requests inside the background worker. An origin the user has not approved via `connect` gets no account data — account queries answer from an empty snapshot, reporting a disconnected state — `connect` from an unapproved origin is queued for explicit popup approval, and signing is refused; a connected origin (and origin-agnostic methods like `getActiveNetwork` / `disconnect`) is dispatched normally. -- **`messages.ts`** — Shared message-type constants and type guards used by all three extension scripts. -- **`runtimeClient.ts`** — Popup-side client for sending requests to the background and receiving responses. +- **`messages.ts`** — The one declaration site for the wire constants, message types and type guards every extension script uses. The page-facing frames are typed from `@canton-network/core-types`' `SpliceMessage` schema, so an upstream change to the envelope is a build error here. The event names themselves are plain strings pinned to the SDK enum with `satisfies`, not the enum itself: importing that enum at runtime pulls its zod schemas into the content script, which loads on every page. The SDK's `JsonRpcResponse` is a discriminated union, so `responseResult` / `responseError` are the readers for it, here beside the `jsonRpcResult` / `jsonRpcError` that build one. +- **`chromeRuntime.ts`** — The callback-style `chrome.runtime` surface and the one `sendRuntimeMessage` promise wrapper, shared by the content script and the popup client. Chrome reports a failed `sendMessage` on `runtime.lastError` inside the callback instead of throwing, and answers an unhandled message with `undefined`; both become rejections here so no caller repeats that. The runtime is read on every call rather than cached, because a content script is evaluated at `document_start`. It imports nothing, which is what lets the content script use it without pulling in `directConnections` and `chrome.storage` behind it. +- **`runtimeClient.ts`** — Popup-side client for sending requests to the background and receiving responses, over `chromeRuntime.ts`. - **`directConnections.ts`** — Persists the set of direct injected-provider dApp origins that have completed a connect (and removes them on disconnect) in `chrome.storage.session`, with an in-memory fallback when not running as an extension. This set gates injected-provider account access in addition to driving the popup footer's connection state for non-WalletConnect dApps; `clearDirectConnectedOrigins` drops all remembered origins (used on vault reset). - **`directConnectionState.ts`** — Pure helper that normalizes page URLs to stable http(s) origins and derives a remember/forget/none update from a provider request/response pair: remembers on a successful `connect` whose result reports `isConnected: true`, forgets on `disconnect`. - **`walletSnapshot.ts`** — Serialises the current wallet state (accounts, network, lock status) into a plain object the background caches so the popup can render without unlocking the vault. The accounts it carries are already scoped to what the endpoint in use hosts, so the worker answers dApps with the same list the popup shows. @@ -171,7 +176,8 @@ The app communicates with two external systems: | System | Module | Notes | |--------|--------|-------| | wallet-service JSON-RPC | `src/api/walletService.ts` | Wraps all RPC calls; the URL is the endpoint in use, resolved with `activeRpcUrl` from `src/config/runtimeConfig.ts` | -| Injected extension provider | `src/extension/contentScript.ts` | Announces Carpincho to dApps through `canton:requestProvider` / `canton:announceProvider` and relays provider requests through the extension runtime | +| Injected extension provider | `src/extension/contentScript.ts` | Answers a dApp's `canton:requestProvider` with `canton:announceProvider` and relays provider requests through the extension runtime | +| dApp SDK wire types | `@canton-network/core-types` | The `SpliceMessage` schema and `JsonRpcRequest` / `JsonRpcResponse` that `src/extension/messages.ts` types the wire frames from, so an upstream change is a build error rather than a dropped event | | WalletConnect relay | `src/wc/client.ts` | Optional fallback sign client connected to Reown relay using `VITE_WC_PROJECT_ID` | Components must never call these systems directly. All wallet-service calls go through `src/provider/walletService.ts`; injected-provider requests are bridged by `src/extension/contentScript.ts` / `src/extension/background.ts`, and WalletConnect events are handled by the `src/wc/client.ts` subscriptions wired in `src/views/home/useWalletConnectLifecycle.ts`. Both paths forward to the provider dispatcher. @@ -286,7 +292,7 @@ No JWT, cookie, or server-side session is involved. ### Extension Architecture -Two separate build artifacts are produced by `npm run build:extension`: +`pnpm run build:extension` produces three artifacts: | Artifact | Entry | Role | |----------|-------|------| @@ -294,6 +300,8 @@ Two separate build artifacts are produced by `npm run build:extension`: | Content script | `src/extension/contentScript.ts` | Injected into every web page; relays `window.postMessage` ↔ extension runtime | | Background script | `src/extension/background.ts` | Persistent service worker; queues requests, updates badge, caches wallet snapshot | -The popup and background communicate via `chrome.runtime.sendMessage` / `chrome.runtime.onMessage`. The content script and page communicate via `window.postMessage`. Message shapes are defined in `src/extension/messages.ts` and shared across all three scripts. +The popup and background communicate via `chrome.runtime.sendMessage` / `chrome.runtime.onMessage`. The content script and page communicate via `window.postMessage`. Message shapes are defined in `src/extension/messages.ts` and shared across every script. + +Chrome loads the content script as a classic file, so it may not contain an `import`. Listing it as an extra input of the main build would hoist whatever it shares with the popup and the worker into a chunk it cannot load, so `STANDALONE_SCRIPTS` in `vite.config.ts` builds every such script on its own, as a single-entry IIFE, after the main build. That is what lets the content script import the shared modules instead of carrying hand-copied constants. `src/main.tsx` detects `chrome-extension://` in `location.protocol` at startup and sets `document.documentElement.dataset.runtime = 'extension'`, which CSS can use for extension-specific layout adjustments. diff --git a/package.json b/package.json index dc1d5de..c5c6e57 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "vite", "build": "tsc -b --noEmit && vite build", "build:extension": "tsc -b --noEmit && vite build --mode extension", - "typecheck": "tsc -b --noEmit --pretty", + "typecheck": "tsc -b --noEmit --pretty && tsc -p test/tsconfig.json --noEmit --pretty", "lint": "biome check --error-on-warnings", "lint:fix": "biome check --write", "format": "biome format --write", @@ -21,6 +21,7 @@ "prepare": "husky" }, "dependencies": { + "@canton-network/core-types": "^1.11.0", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/manrope": "^5.2.8", "@noble/ed25519": "^3.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ce5193..be95a07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@canton-network/core-types': + specifier: ^1.11.0 + version: 1.11.0 '@fontsource-variable/jetbrains-mono': specifier: ^5.2.8 version: 5.3.0 @@ -300,6 +303,9 @@ packages: cpu: [x64] os: [win32] + '@canton-network/core-types@1.11.0': + resolution: {integrity: sha512-2iE0tbZc5e7Hnc8SDcxUGQ2fcnBHCUR3JGWEcerI+i2rU5MtcDpbbWrgMOc6sEMAG+hc5PchHKSHU/awuywvWw==} + '@commitlint/cli@21.2.1': resolution: {integrity: sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==} engines: {node: '>=22.12.0'} @@ -385,6 +391,9 @@ packages: resolution: {integrity: sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==} engines: {node: '>=22'} + '@daml/types@3.5.2': + resolution: {integrity: sha512-lWAXNOl1tdzodvDDw7XTv6dUZA0MeAQZtTklh+5kegNt1asKAhreppYkD+bogYU1DnMXUTeMgGmmMBZt1gZpJg==} + '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} @@ -747,6 +756,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mojotech/json-type-validation@3.1.0': + resolution: {integrity: sha512-ThH2EbHEUCPMHhXAtmYcDi0gmV+PZak4uvuWBMiBDqUuz7gGQUrsE5o1J6kKNLDX5cXAPqsfJ7uTfTcNdCDXxA==} + engines: {node: '>=6.0.0'} + '@msgpack/msgpack@3.1.3': resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} engines: {node: '>= 18'} @@ -1688,6 +1701,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} @@ -2221,6 +2237,13 @@ packages: engines: {node: '>=22.22.1'} hasBin: true + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2622,6 +2645,10 @@ packages: '@types/react': optional: true + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + vite@6.4.3: resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2874,6 +2901,12 @@ snapshots: '@biomejs/cli-win32-x64@2.4.15': optional: true + '@canton-network/core-types@1.11.0': + dependencies: + '@daml/types': 3.5.2 + uuid: 14.0.2 + zod: 4.5.4 + '@commitlint/cli@21.2.1(@types/node@24.13.3)(conventional-commits-parser@7.1.0)(typescript@5.9.3)': dependencies: '@commitlint/config-conventional': 21.2.0 @@ -2993,6 +3026,12 @@ snapshots: '@conventional-changelog/template@1.2.1': {} + '@daml/types@3.5.2': + dependencies: + '@mojotech/json-type-validation': 3.1.0 + '@types/lodash': 4.17.25 + lodash: 4.18.1 + '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -3213,6 +3252,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mojotech/json-type-validation@3.1.0': + dependencies: + lodash.isequal: 4.5.0 + '@msgpack/msgpack@3.1.3': {} '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': @@ -3960,6 +4003,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/lodash@4.17.25': {} + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 @@ -4658,6 +4703,10 @@ snapshots: optionalDependencies: yaml: 2.9.0 + lodash.isequal@4.5.0: {} + + lodash@4.18.1: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -5029,6 +5078,8 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 + uuid@14.0.2: {} + vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: esbuild: 0.25.12 diff --git a/src/extension/background.ts b/src/extension/background.ts index 3f40e09..aab0a67 100644 --- a/src/extension/background.ts +++ b/src/extension/background.ts @@ -111,7 +111,11 @@ const tabsForOrigins = async (origins: string[]): Promise => ? [] : ((await chromeApi?.tabs?.query({ url: origins.map((o) => `${o}/*`) }).catch(() => [])) ?? []) -const sendToTab = async (tab: Tab, eventName: Cip103Event, payload: unknown): Promise => { +const sendToTab = async ( + tab: Tab, + eventName: Cip103Event, + payload: RuntimeEventRelay['payload'], +): Promise => { if (tab.id === undefined) { return } diff --git a/src/extension/chromeRuntime.ts b/src/extension/chromeRuntime.ts new file mode 100644 index 0000000..0eb9a27 --- /dev/null +++ b/src/extension/chromeRuntime.ts @@ -0,0 +1,46 @@ +// The callback-style `chrome.runtime` surface, shared by the content script and the popup +// client. Both send a message and wait for one answer, and both have to keep working with no +// `chrome` at all: the wallet also runs as a plain web page. +// +// Declared here rather than imported from `@types/chrome` because the content script is bundled +// on its own and only ever touches these three members. + +type RuntimeListener = (message: unknown) => void + +export type ChromeRuntime = { + lastError?: { message?: string } + sendMessage?: (message: unknown, callback: (response?: unknown) => void) => void + onMessage?: { + addListener: (listener: RuntimeListener) => void + removeListener?: (listener: RuntimeListener) => void + } +} + +// Read on every call, never cached: a content script is evaluated at `document_start`, and the +// popup's tests install their stub after this module is imported. +export const chromeRuntime = (): ChromeRuntime | undefined => + (globalThis as { chrome?: { runtime?: ChromeRuntime } }).chrome?.runtime + +// Chrome reports a failed `sendMessage` on `runtime.lastError` inside the callback instead of +// throwing, and answers a message nobody handled with `undefined`, so both become rejections +// here and every caller can just await. +export const sendRuntimeMessage = async (message: unknown): Promise => + await new Promise((resolve, reject) => { + const api = chromeRuntime() + if (api?.sendMessage === undefined) { + reject(new Error('Carpincho extension runtime is not available')) + return + } + api.sendMessage(message, (response) => { + const lastError = api.lastError + if (lastError !== undefined) { + reject(new Error(lastError.message ?? 'Carpincho extension runtime failed')) + return + } + if (response === undefined) { + reject(new Error('Carpincho extension runtime returned no response')) + return + } + resolve(response as T) + }) + }) diff --git a/src/extension/contentScript.ts b/src/extension/contentScript.ts index 5094772..510ebed 100644 --- a/src/extension/contentScript.ts +++ b/src/extension/contentScript.ts @@ -1,165 +1,44 @@ -const CARPINCHO_PROVIDER_ID = 'carpincho-wallet' -const CARPINCHO_PROVIDER_NAME = 'Carpincho Wallet' -const CARPINCHO_PROVIDER_DESCRIPTION = 'Connect with the Carpincho browser extension wallet' - -const WalletEvent = { - SPLICE_WALLET_REQUEST: 'SPLICE_WALLET_REQUEST', - SPLICE_WALLET_RESPONSE: 'SPLICE_WALLET_RESPONSE', - SPLICE_WALLET_EXT_READY: 'SPLICE_WALLET_EXT_READY', - SPLICE_WALLET_EXT_ACK: 'SPLICE_WALLET_EXT_ACK', - SPLICE_WALLET_EXT_OPEN: 'SPLICE_WALLET_EXT_OPEN', -} as const - -const CANTON_REQUEST_PROVIDER_EVENT = 'canton:requestProvider' -const CANTON_ANNOUNCE_PROVIDER_EVENT = 'canton:announceProvider' - -interface JsonRpcRequest { - jsonrpc: '2.0' - id?: string | number | null - method: string - params?: unknown -} - -interface JsonRpcResponse { - jsonrpc: '2.0' - id?: string | number | null - result?: unknown - error?: { - code: number - message: string - data?: unknown - } -} - -interface RuntimeProviderRequest { - type: 'CARPINCHO_PROVIDER_REQUEST' - request: JsonRpcRequest - origin: string -} - -interface RuntimeOpenWallet { - type: 'CARPINCHO_OPEN_WALLET' - origin: string -} - -interface SpliceWalletRequestMessage { - type: typeof WalletEvent.SPLICE_WALLET_REQUEST - request: JsonRpcRequest - target?: string -} - -type SpliceWalletCallMessage = SpliceWalletRequestMessage & { - request: JsonRpcRequest & { id: string | number } -} - -interface SpliceWalletResponseMessage { - type: typeof WalletEvent.SPLICE_WALLET_RESPONSE - response: JsonRpcResponse -} - -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null - -const isForCarpincho = (message: { target?: unknown }): boolean => - message.target === undefined || message.target === CARPINCHO_PROVIDER_ID +import { chromeRuntime, sendRuntimeMessage } from '@/extension/chromeRuntime' +import { + announcedProvider, + CANTON_ANNOUNCE_PROVIDER_EVENT, + CANTON_REQUEST_PROVIDER_EVENT, +} from '@/extension/discovery' +import { + CARPINCHO_PROVIDER_ID, + extensionAck, + isForCarpincho, + isRecord, + isSpliceWalletRequest, + type JsonRpcResponse, + jsonRpcError, + type RuntimeEventRelay, + type RuntimeOpenWallet, + type RuntimeProviderRequest, + type SpliceWalletRequestMessage, + type SpliceWalletResponseMessage, + WalletEvent, +} from '@/extension/messages' // The bodiless page messages: only `type` and `target` matter on either. const isWalletMessage = (value: unknown, type: string): value is { target?: string } => isRecord(value) && value.type === type -const isSpliceWalletRequest = (value: unknown): value is SpliceWalletCallMessage => - isRecord(value) && - value.type === WalletEvent.SPLICE_WALLET_REQUEST && - isRecord(value.request) && - value.request.jsonrpc === '2.0' && - typeof value.request.method === 'string' && - // An id-less request is a notification: a wallet event on its way to the page, never a - // dApp call for this wallet to answer. - (typeof value.request.id === 'string' || typeof value.request.id === 'number') - -const extensionAck = (): { - type: typeof WalletEvent.SPLICE_WALLET_EXT_ACK - target: typeof CARPINCHO_PROVIDER_ID -} => ({ - type: WalletEvent.SPLICE_WALLET_EXT_ACK, - target: CARPINCHO_PROVIDER_ID, -}) - -const jsonRpcError = ( - id: JsonRpcRequest['id'], - code: number, - message: string, - data?: unknown, -): JsonRpcResponse => ({ - jsonrpc: '2.0', - id, - error: data === undefined ? { code, message } : { code, message, data }, -}) - -interface RuntimeEventRelay { - type: 'CARPINCHO_EVENT_RELAY' - eventName: string - payload: unknown -} - -type RuntimeApi = { - id: string - lastError?: { message?: string } - sendMessage: ( - message: RuntimeProviderRequest | RuntimeOpenWallet, - callback: (response?: JsonRpcResponse) => void, - ) => void - onMessage?: { - addListener: (listener: (message: unknown) => void) => void - } -} - -const runtime = (globalThis as { chrome?: { runtime?: RuntimeApi } }).chrome?.runtime - const announceProvider = (): void => { - if (runtime === undefined) { + if (chromeRuntime() === undefined) { return } window.dispatchEvent( - new CustomEvent(CANTON_ANNOUNCE_PROVIDER_EVENT, { - detail: { - id: CARPINCHO_PROVIDER_ID, - name: CARPINCHO_PROVIDER_NAME, - // A base64 data URI built from icons/carpincho-48.png at build time: the SDK types - // this field as a data or https URL, and its picker renders in a blob: document that - // cannot load an extension URL. - icon: __WALLET_ICON_DATA_URL__, - description: CARPINCHO_PROVIDER_DESCRIPTION, - target: CARPINCHO_PROVIDER_ID, - }, - }), + new CustomEvent(CANTON_ANNOUNCE_PROVIDER_EVENT, { detail: announcedProvider() }), ) } -const runtimeRequest = async (message: RuntimeProviderRequest): Promise => - await new Promise((resolve, reject) => { - if (runtime === undefined) { - reject(new Error('Carpincho extension runtime is not available')) - return - } - runtime.sendMessage(message, (response) => { - const lastError = runtime.lastError - if (lastError !== undefined) { - reject(new Error(lastError.message ?? 'Carpincho extension runtime failed')) - return - } - if (response === undefined) { - reject(new Error('Carpincho extension returned no response')) - return - } - resolve(response) - }) - }) - -// Fire and forget: `sdk.open()` waits for nothing. The callback is there to read -// `runtime.lastError`, which is what stops Chrome logging it into the dApp's console. +// Fire and forget: `sdk.open()` waits for nothing, so this does not go through +// `sendRuntimeMessage`. The callback is there to read `runtime.lastError`, which is what stops +// Chrome logging it into the dApp's console. const runtimeNotify = (message: RuntimeOpenWallet): void => { - runtime?.sendMessage(message, () => runtime?.lastError) + const api = chromeRuntime() + api?.sendMessage?.(message, () => api.lastError) } const postResponse = (response: JsonRpcResponse): void => { @@ -171,10 +50,7 @@ const postResponse = (response: JsonRpcResponse): void => { } const isRuntimeEventRelay = (value: unknown): value is RuntimeEventRelay => - typeof value === 'object' && - value !== null && - (value as { type?: unknown }).type === 'CARPINCHO_EVENT_RELAY' && - typeof (value as { eventName?: unknown }).eventName === 'string' + isRecord(value) && value.type === 'CARPINCHO_EVENT_RELAY' && typeof value.eventName === 'string' const forwardEventToPage = (message: RuntimeEventRelay): void => { // The dapp-sdk reads wallet events as id-less SPLICE_WALLET_REQUEST notifications; the @@ -187,14 +63,19 @@ const forwardEventToPage = (message: RuntimeEventRelay): void => { window.postMessage(out, '*') } -runtime?.onMessage?.addListener((message: unknown) => { +chromeRuntime()?.onMessage?.addListener((message: unknown) => { if (isRuntimeEventRelay(message)) { forwardEventToPage(message) } }) +// Only on request. Announcing at load would hand every site the user visits the wallet's full +// identity, icon included, without being asked; the SDK's `requestAnnouncedProviders()` +// dispatches this event, so discovery still finds Carpincho. Nothing else may reintroduce that +// disclosure: a page-world `window.canton` marker would hand the same fact to any script that +// enumerates `window`, and it only shortens the handshake in the case that is already fast, +// since a wallet that is not installed sets no global and still costs the full 2 s timeout. window.addEventListener(CANTON_REQUEST_PROVIDER_EVENT, announceProvider) -queueMicrotask(announceProvider) window.addEventListener('message', (event) => { if (event.source !== window) { @@ -214,11 +95,11 @@ window.addEventListener('message', (event) => { if (!isSpliceWalletRequest(data) || !isForCarpincho(data)) { return } - void runtimeRequest({ + void sendRuntimeMessage({ type: 'CARPINCHO_PROVIDER_REQUEST', request: data.request, origin: window.location.origin, - }) + } satisfies RuntimeProviderRequest) .then(postResponse) .catch((error) => { postResponse(jsonRpcError(data.request.id, -32000, (error as Error).message)) diff --git a/src/extension/directConnectionState.ts b/src/extension/directConnectionState.ts index 6589155..b2dc3a0 100644 --- a/src/extension/directConnectionState.ts +++ b/src/extension/directConnectionState.ts @@ -1,4 +1,9 @@ -import type { JsonRpcRequest, JsonRpcResponse } from '@/extension/messages' +import { + isRecord, + type JsonRpcRequest, + type JsonRpcResponse, + responseResult, +} from '@/extension/messages' import { CANTON_METHOD_CONNECT, CANTON_METHOD_DISCONNECT, @@ -22,12 +27,8 @@ export const normalizeDirectConnectionOrigin = (value: string): string | undefin // Detects the CIP-0103 connect response shape that means the dApp is connected to this wallet. const isConnectedResult = (response: JsonRpcResponse): boolean => { - const result = response.result - return ( - typeof result === 'object' && - result !== null && - (result as { isConnected?: unknown }).isConnected === true - ) + const result = responseResult(response) + return isRecord(result) && result.isConnected === true } // Derives the direct-origin state update caused by a provider request/response pair. @@ -40,7 +41,7 @@ export const directConnectionUpdateFromProviderResponse = ({ request: JsonRpcRequest response: JsonRpcResponse }): DirectConnectionUpdate => { - if (response.error !== undefined) { + if ('error' in response) { return { action: 'none' } } diff --git a/src/extension/discovery.ts b/src/extension/discovery.ts new file mode 100644 index 0000000..713859f --- /dev/null +++ b/src/extension/discovery.ts @@ -0,0 +1,23 @@ +import type { + CANTON_ANNOUNCE_PROVIDER_EVENT as SdkAnnounceEvent, + CANTON_REQUEST_PROVIDER_EVENT as SdkRequestEvent, +} from '@canton-network/core-types' +import { CARPINCHO_PROVIDER_ID, CARPINCHO_PROVIDER_NAME } from '@/extension/messages' + +// Typed by the SDK's own constants, so a rename upstream fails the build here. +export const CANTON_REQUEST_PROVIDER_EVENT: typeof SdkRequestEvent = 'canton:requestProvider' +export const CANTON_ANNOUNCE_PROVIDER_EVENT: typeof SdkAnnounceEvent = 'canton:announceProvider' + +// Who the wallet says it is, as carried by the `canton:announceProvider` detail. A function +// rather than a shared const: the detail is handed to whichever page listener asked, so each +// announcement gets its own object and no listener can edit what a later one carries. +export const announcedProvider = () => ({ + id: CARPINCHO_PROVIDER_ID, + name: CARPINCHO_PROVIDER_NAME, + description: 'Connect with the Carpincho browser extension wallet', + target: CARPINCHO_PROVIDER_ID, + // A base64 data URI built from icons/carpincho-48.png at build time: the SDK types this field + // as a data or https URL, and its picker renders in a blob: document that cannot load an + // extension URL. + icon: __WALLET_ICON_DATA_URL__, +}) diff --git a/src/extension/eventBroadcast.ts b/src/extension/eventBroadcast.ts index e635ff7..fa72bbb 100644 --- a/src/extension/eventBroadcast.ts +++ b/src/extension/eventBroadcast.ts @@ -15,7 +15,7 @@ const runtime = (globalThis as { chrome?: { runtime?: RuntimeApi } }).chrome?.ru // this pushes is one every dApp was told to expect, whichever transport it arrived on. export const broadcastWalletEvent = async ( eventName: Cip103Event, - payload: unknown, + payload: RuntimeBroadcastEvent['payload'], ): Promise => { if (runtime === undefined) { return diff --git a/src/extension/messages.ts b/src/extension/messages.ts index 443b8aa..e72075b 100644 --- a/src/extension/messages.ts +++ b/src/extension/messages.ts @@ -1,50 +1,48 @@ +import type { + ErrorResponse, + JsonRpcRequest, + JsonRpcResponse, + WalletEvent as SdkWalletEvent, + SpliceMessage, +} from '@canton-network/core-types' import type { Cip103Event } from '@/provider/events' +export type { JsonRpcRequest, JsonRpcResponse } + export const CARPINCHO_PROVIDER_ID = 'carpincho-wallet' export const CARPINCHO_PROVIDER_NAME = 'Carpincho Wallet' +type WireEventName = `${SdkWalletEvent}` + +// Plain strings rather than the SDK's own enum: importing that enum at runtime drags its zod +// schemas into the content script, which loads on every page. `satisfies` keeps the names tied +// to the enum, so an upstream rename fails the build here instead of splitting wallet from dApp. +// Each key must map to its own name: `Record` would let any two +// values swap places, which is the one typo that would still send a well-formed wrong frame. export const WalletEvent = { SPLICE_WALLET_REQUEST: 'SPLICE_WALLET_REQUEST', SPLICE_WALLET_RESPONSE: 'SPLICE_WALLET_RESPONSE', SPLICE_WALLET_EXT_READY: 'SPLICE_WALLET_EXT_READY', SPLICE_WALLET_EXT_ACK: 'SPLICE_WALLET_EXT_ACK', SPLICE_WALLET_EXT_OPEN: 'SPLICE_WALLET_EXT_OPEN', -} as const - -type WalletEventValue = (typeof WalletEvent)[K] +} as const satisfies { [K in WireEventName]?: K } -export interface JsonRpcRequest { - jsonrpc: '2.0' - id?: string | number | null - method: string - params?: unknown +// One frame of the SDK's `SpliceMessage` schema, with `type` restated as a plain string: the +// schema types it as an enum member, which no string literal can satisfy. Every other field +// still comes from the SDK. +type WireFrame = Omit, 'type'> & { + type: K } -export interface JsonRpcResponse { - jsonrpc: '2.0' - id?: string | number | null - result?: unknown - error?: { - code: number - message: string - data?: unknown - } -} +export type SpliceWalletRequestMessage = WireFrame<'SPLICE_WALLET_REQUEST'> -export interface SpliceWalletRequestMessage { - type: WalletEventValue<'SPLICE_WALLET_REQUEST'> - request: JsonRpcRequest - target?: string -} +export type SpliceWalletResponseMessage = WireFrame<'SPLICE_WALLET_RESPONSE'> export type SpliceWalletCallMessage = SpliceWalletRequestMessage & { request: JsonRpcRequest & { id: string | number } } -export interface SpliceWalletAckMessage { - type: WalletEventValue<'SPLICE_WALLET_EXT_ACK'> - target: typeof CARPINCHO_PROVIDER_ID -} +export type SpliceWalletAckMessage = WireFrame<'SPLICE_WALLET_EXT_ACK'> export interface RuntimeProviderRequest { type: 'CARPINCHO_PROVIDER_REQUEST' @@ -97,17 +95,19 @@ export interface RuntimeDisconnectDapps { } // Wallet→page broadcast: popup → background → content script → page. `eventName` is typed by -// the shared CIP-0103 list the WalletConnect session declares. +// the shared CIP-0103 list the WalletConnect session declares. `payload` is typed as the +// JSON-RPC `params` it ends up as, so a payload the SDK's schema would reject is rejected here +// rather than at the content script that has to cast it onto the outgoing frame. export interface RuntimeBroadcastEvent { type: 'CARPINCHO_BROADCAST_EVENT' eventName: Cip103Event - payload: unknown + payload: JsonRpcRequest['params'] } export interface RuntimeEventRelay { type: 'CARPINCHO_EVENT_RELAY' eventName: Cip103Event - payload: unknown + payload: JsonRpcRequest['params'] } export const isRecord = (value: unknown): value is Record => @@ -123,7 +123,7 @@ export const isSpliceWalletRequest = (value: unknown): value is SpliceWalletCall value.request.jsonrpc === '2.0' && typeof value.request.method === 'string' && // An id-less request is a notification: a wallet event on its way to the page, never a - // dApp call for the wallet to answer. Kept in step with contentScript's own copy. + // dApp call for the wallet to answer. (typeof value.request.id === 'string' || typeof value.request.id === 'number') export const extensionAck = (): SpliceWalletAckMessage => ({ @@ -147,3 +147,13 @@ export const jsonRpcError = ( id, error: data === undefined ? { code, message } : { code, message, data }, }) + +// The SDK's `JsonRpcResponse` is a discriminated union, so neither key can be read off one +// directly. Every response the wallet builds comes from the two constructors above, so a key +// that is present is never present-but-undefined: `undefined` here means the response carried +// the other side of the union. +export const responseResult = (response: JsonRpcResponse): unknown => + 'result' in response ? response.result : undefined + +export const responseError = (response: JsonRpcResponse): ErrorResponse['error'] | undefined => + 'error' in response ? response.error : undefined diff --git a/src/extension/runtimeClient.ts b/src/extension/runtimeClient.ts index b31eae2..866dc35 100644 --- a/src/extension/runtimeClient.ts +++ b/src/extension/runtimeClient.ts @@ -1,9 +1,11 @@ +import { chromeRuntime, sendRuntimeMessage } from '@/extension/chromeRuntime' import { clearDirectConnectedOrigins, DIRECT_CONNECTED_ORIGINS_KEY, storedOrigins, } from '@/extension/directConnections' import { + isRecord, jsonRpcError, jsonRpcResult, type RuntimeDisconnectDapps, @@ -30,42 +32,7 @@ const sessionStorageOnChanged = (): SessionStorageChangedEvent | undefined => } ).chrome?.storage?.session?.onChanged -type RuntimeListener = (message: unknown) => void - -type RuntimeApi = { - sendMessage?: (message: unknown, callback: (response?: unknown) => void) => void - lastError?: { message?: string } - onMessage?: { - addListener: (listener: RuntimeListener) => void - removeListener: (listener: RuntimeListener) => void - } -} - -const runtime = (): RuntimeApi | undefined => - (globalThis as { chrome?: { runtime?: RuntimeApi } }).chrome?.runtime - -export const isExtensionRuntime = (): boolean => runtime()?.sendMessage !== undefined - -const sendRuntimeMessage = async (message: unknown): Promise => - await new Promise((resolve, reject) => { - const api = runtime() - if (api?.sendMessage === undefined) { - reject(new Error('Carpincho extension runtime is not available')) - return - } - api.sendMessage(message, (response) => { - const lastError = api.lastError - if (lastError !== undefined) { - reject(new Error(lastError.message ?? 'Carpincho extension runtime failed')) - return - } - if (response === undefined) { - reject(new Error('Carpincho extension runtime returned no response')) - return - } - resolve(response as T) - }) - }) +export const isExtensionRuntime = (): boolean => chromeRuntime()?.sendMessage !== undefined export const getPendingProviderRequests = async (): Promise => await sendRuntimeMessage({ @@ -117,22 +84,17 @@ export const createRuntimeResponder = (pending: RuntimePendingRequest): Provider export const subscribeToPendingProviderRequests = ( cb: (pending: RuntimePendingRequest) => void, ): (() => void) => { - const api = runtime() - const onMessage = api?.onMessage + const onMessage = chromeRuntime()?.onMessage if (onMessage === undefined) { return () => undefined } const listener = (message: unknown): void => { - if ( - typeof message === 'object' && - message !== null && - (message as RuntimePendingRequestMessage).type === 'CARPINCHO_PENDING_REQUEST' - ) { - cb((message as RuntimePendingRequestMessage).pending) + if (isRecord(message) && message.type === 'CARPINCHO_PENDING_REQUEST') { + cb(message.pending as RuntimePendingRequestMessage['pending']) } } onMessage.addListener(listener) - return () => onMessage.removeListener(listener) + return () => onMessage.removeListener?.(listener) } // Subscribes to session-storage writes so the popup footer tracks direct dApp connection state. diff --git a/src/extension/walletSnapshot.ts b/src/extension/walletSnapshot.ts index f490799..0526c18 100644 --- a/src/extension/walletSnapshot.ts +++ b/src/extension/walletSnapshot.ts @@ -1,3 +1,4 @@ +import { isRecord } from '@/extension/messages' import type { AccountPublic } from '@/vault/types' const WALLET_SNAPSHOT_KEY = 'carpincho.wallet.snapshot' @@ -26,24 +27,21 @@ export interface ExtensionWalletSnapshot { } const isAccountPublic = (value: unknown): value is AccountPublic => - typeof value === 'object' && - value !== null && - typeof (value as AccountPublic).id === 'string' && - typeof (value as AccountPublic).name === 'string' && - typeof (value as AccountPublic).partyId === 'string' && - typeof (value as AccountPublic).publicKeyBase64 === 'string' && - typeof (value as AccountPublic).network === 'string' && - typeof (value as AccountPublic).isPrimary === 'boolean' && - typeof (value as AccountPublic).createdAt === 'number' + isRecord(value) && + typeof value.id === 'string' && + typeof value.name === 'string' && + typeof value.partyId === 'string' && + typeof value.publicKeyBase64 === 'string' && + typeof value.network === 'string' && + typeof value.isPrimary === 'boolean' && + typeof value.createdAt === 'number' const isWalletSnapshot = (value: unknown): value is ExtensionWalletSnapshot => - typeof value === 'object' && - value !== null && - Array.isArray((value as ExtensionWalletSnapshot).accounts) && - (value as ExtensionWalletSnapshot).accounts.every(isAccountPublic) && - ((value as ExtensionWalletSnapshot).primary === null || - isAccountPublic((value as ExtensionWalletSnapshot).primary)) && - typeof (value as ExtensionWalletSnapshot).updatedAt === 'number' + isRecord(value) && + Array.isArray(value.accounts) && + value.accounts.every(isAccountPublic) && + (value.primary === null || isAccountPublic(value.primary)) && + typeof value.updatedAt === 'number' export const persistWalletSnapshot = async ( snapshot: Omit | null, diff --git a/test/components/AccountCard.test.tsx b/test/components/AccountCard.test.tsx index 3cc6c30..762a420 100644 --- a/test/components/AccountCard.test.tsx +++ b/test/components/AccountCard.test.tsx @@ -34,7 +34,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [PRIMARY_ACCOUNT, SECONDARY_ACCOUNT], primary: PRIMARY_ACCOUNT, transactions: [], diff --git a/test/components/ActivityPanel.test.tsx b/test/components/ActivityPanel.test.tsx index 7dec1b6..084a683 100644 --- a/test/components/ActivityPanel.test.tsx +++ b/test/components/ActivityPanel.test.tsx @@ -28,7 +28,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [ACCOUNT], primary: ACCOUNT, transactions: [], @@ -251,6 +256,7 @@ describe('ActivityPanel', () => { // Scenario: first fetch in flight, no history — show a loading indicator, not the empty state. const api: Cip56TransferApi = { listPendingIncomingTransfers: () => new Promise(() => {}), + acceptTransfer: async () => ({ updateId: 'update-1' }), } renderPanel(api) @@ -320,6 +326,7 @@ describe('ActivityPanel', () => { // Scenario: confirmed transactions still render via ActivityList beneath any pending items. const api: Cip56TransferApi = { listPendingIncomingTransfers: async () => [], + acceptTransfer: async () => ({ updateId: 'update-1' }), } renderPanel(api, [txRecord('tx-1', 'Send 25 AMT')]) diff --git a/test/components/AssetsPanel.test.tsx b/test/components/AssetsPanel.test.tsx index 252eb51..4979253 100644 --- a/test/components/AssetsPanel.test.tsx +++ b/test/components/AssetsPanel.test.tsx @@ -32,7 +32,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [ACCOUNT], primary: ACCOUNT, transactions: [], diff --git a/test/components/AutoAcceptSetting.test.tsx b/test/components/AutoAcceptSetting.test.tsx index 32ad056..7d3c241 100644 --- a/test/components/AutoAcceptSetting.test.tsx +++ b/test/components/AutoAcceptSetting.test.tsx @@ -28,7 +28,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [ACCOUNT], primary: ACCOUNT, transactions: [], diff --git a/test/components/Header.test.tsx b/test/components/Header.test.tsx index 132d25a..11562ba 100644 --- a/test/components/Header.test.tsx +++ b/test/components/Header.test.tsx @@ -14,7 +14,12 @@ const baseVault = (overrides: Partial = {}): VaultContextValu setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [], primary: null, transactions: [], diff --git a/test/components/HomeTabs.test.tsx b/test/components/HomeTabs.test.tsx index 27008ff..b9d5919 100644 --- a/test/components/HomeTabs.test.tsx +++ b/test/components/HomeTabs.test.tsx @@ -57,7 +57,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [ACCOUNT], primary: ACCOUNT, transactions: [], diff --git a/test/components/MenuSheet.test.tsx b/test/components/MenuSheet.test.tsx index fd8216e..e2d0065 100644 --- a/test/components/MenuSheet.test.tsx +++ b/test/components/MenuSheet.test.tsx @@ -15,7 +15,8 @@ const baseVault = (overrides: Partial = {}): VaultContextValu setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, accounts: [], primary: null, transactions: [], diff --git a/test/components/SendConfirm.test.tsx b/test/components/SendConfirm.test.tsx index aa5280b..36eb81b 100644 --- a/test/components/SendConfirm.test.tsx +++ b/test/components/SendConfirm.test.tsx @@ -39,7 +39,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [ACCOUNT], primary: ACCOUNT, transactions: [], diff --git a/test/components/TokenDetailSheet.test.tsx b/test/components/TokenDetailSheet.test.tsx index bb10681..6d26661 100644 --- a/test/components/TokenDetailSheet.test.tsx +++ b/test/components/TokenDetailSheet.test.tsx @@ -59,7 +59,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [ACCOUNT], primary: ACCOUNT, transactions: [], diff --git a/test/components/UtilsPanel.test.tsx b/test/components/UtilsPanel.test.tsx index d47fe58..99be09c 100644 --- a/test/components/UtilsPanel.test.tsx +++ b/test/components/UtilsPanel.test.tsx @@ -42,7 +42,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [ACCOUNT], primary: ACCOUNT, transactions: [], diff --git a/test/components/VaultPanel.test.tsx b/test/components/VaultPanel.test.tsx index 4a88ee9..0369d1b 100644 --- a/test/components/VaultPanel.test.tsx +++ b/test/components/VaultPanel.test.tsx @@ -15,7 +15,12 @@ const baseVault = (): VaultContextValue => ({ setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [], primary: null, transactions: [], diff --git a/test/components/utils/CreateContractUtil.test.tsx b/test/components/utils/CreateContractUtil.test.tsx index 4e26248..5421289 100644 --- a/test/components/utils/CreateContractUtil.test.tsx +++ b/test/components/utils/CreateContractUtil.test.tsx @@ -31,7 +31,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [ACCOUNT], primary: ACCOUNT, transactions: [], diff --git a/test/components/utils/ExerciseChoiceUtil.test.tsx b/test/components/utils/ExerciseChoiceUtil.test.tsx index c8b0bd6..0e5a5e6 100644 --- a/test/components/utils/ExerciseChoiceUtil.test.tsx +++ b/test/components/utils/ExerciseChoiceUtil.test.tsx @@ -28,7 +28,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [ACCOUNT], primary: ACCOUNT, transactions: [], diff --git a/test/components/utils/UtilsList.test.tsx b/test/components/utils/UtilsList.test.tsx index 64b391c..ecff4bb 100644 --- a/test/components/utils/UtilsList.test.tsx +++ b/test/components/utils/UtilsList.test.tsx @@ -27,7 +27,12 @@ const baseVault = (): VaultContextValue => setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [ACCOUNT], primary: ACCOUNT, transactions: [], diff --git a/test/extension/background.test.ts b/test/extension/background.test.ts index e145621..4add787 100644 --- a/test/extension/background.test.ts +++ b/test/extension/background.test.ts @@ -1,7 +1,12 @@ import { strict as assert } from 'node:assert' import { after, before, describe, it } from 'node:test' import { DIRECT_CONNECTED_ORIGINS_KEY } from '@/extension/directConnections' -import type { JsonRpcResponse, RuntimeEventRelay } from '@/extension/messages' +import { + type JsonRpcResponse, + type RuntimeEventRelay, + responseError, + responseResult, +} from '@/extension/messages' const originalChrome = (globalThis as { chrome?: unknown }).chrome @@ -294,7 +299,7 @@ describe('background: opening the wallet for a queued request', () => { for (const answers of [first, second]) { assert.equal(answers.length, 1) - assert.deepEqual(answers[0].error, { code: 4001, message: 'user rejected' }) + assert.deepEqual(responseError(answers[0]), { code: 4001, message: 'user rejected' }) } }) }) @@ -324,7 +329,7 @@ describe('background: answering the last request', () => { assert.equal(createdWindows.length, 1) assert.deepEqual(removedWindows, [APPROVAL_WINDOW_ID]) - assert.deepEqual(answers[0].result, { isConnected: true }) + assert.deepEqual(responseResult(answers[0]), { isConnected: true }) }) }) @@ -381,7 +386,7 @@ describe('background: CARPINCHO_OPEN_WALLET', () => { // reason to take it away, and it was never opened for that request. assert.equal(createdWindows.length, 1) assert.deepEqual(removedWindows, []) - assert.deepEqual(answers[0].result, { isConnected: true }) + assert.deepEqual(responseResult(answers[0]), { isConnected: true }) // Leave the window closed for the next group assert.ok(windowRemoved) @@ -488,6 +493,6 @@ describe('background: requests landing while the window is still opening', () => assert.equal(createdWindows.length, 1) assert.deepEqual(removedWindows, [APPROVAL_WINDOW_ID]) - assert.deepEqual(answers[0].result, { isConnected: true }) + assert.deepEqual(responseResult(answers[0]), { isConnected: true }) }) }) diff --git a/test/extension/contentScript.test.ts b/test/extension/contentScript.test.ts index 241f420..ec5d11a 100644 --- a/test/extension/contentScript.test.ts +++ b/test/extension/contentScript.test.ts @@ -7,11 +7,16 @@ type Listener = (message: unknown) => void const originalChrome = (globalThis as { chrome?: unknown }).chrome const runtimeMessages: unknown[] = [] +const announcesAtLoad: unknown[] = [] let relayListener: Listener | undefined // contentScript.ts reads `chrome` and wires its listeners at import time, so the stub // must be in place before the module loads. before(async () => { + const recordAnnounce = (event: Event): void => { + announcesAtLoad.push((event as CustomEvent).detail) + } + window.addEventListener('canton:announceProvider', recordAnnounce) Object.defineProperty(globalThis, 'chrome', { configurable: true, value: { @@ -30,6 +35,8 @@ before(async () => { }, }) await import('@/extension/contentScript') + await new Promise((resolve) => setTimeout(resolve, 25)) + window.removeEventListener('canton:announceProvider', recordAnnounce) }) after(() => { @@ -67,6 +74,14 @@ const announcedProvider = (): Record => { } describe('contentScript provider announcement', () => { + it('stays quiet until a dApp asks', () => { + // Scenario: the content script matches , so announcing at load tells every site + // the user visits that the wallet is installed. The recorder runs from before the module is + // imported until just after, above. Discovery still works: the SDK's + // requestAnnouncedProviders() dispatches the request the next test replays. + assert.deepEqual(announcesAtLoad, []) + }) + it('announces the shipped PNG as an inline data URI, not a chrome-extension URL', () => { // Scenario: the dApp SDK picker renders in a blob: document and types the announced icon // as a data or https URL, so a chrome-extension:// URL would render a broken image. The diff --git a/test/extensionBridge.test.ts b/test/extensionBridge.test.ts index dc62f45..1c45bc2 100644 --- a/test/extensionBridge.test.ts +++ b/test/extensionBridge.test.ts @@ -29,10 +29,7 @@ describe('extension postMessage bridge', () => { assert.deepEqual(manifest.content_scripts?.[0]?.js, ['contentScript.js']) assert.equal(manifest.content_scripts?.[0]?.run_at, 'document_start') assert.ok(manifest.content_scripts?.[0]?.matches?.includes('')) - assert.match( - viteConfig, - /contentScript: resolve\(__dirname, 'src\/extension\/contentScript\.ts'\)/, - ) + assert.match(viteConfig, /STANDALONE_SCRIPTS = \[[^\]]*'contentScript'/) assert.match(viteConfig, /background: resolve\(__dirname, 'src\/extension\/background\.ts'\)/) }) diff --git a/test/extensionDirectProvider.test.ts b/test/extensionDirectProvider.test.ts index 764b2bc..dc83ca9 100644 --- a/test/extensionDirectProvider.test.ts +++ b/test/extensionDirectProvider.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { afterEach, describe, it } from 'node:test' import { createDirectProviderResponse } from '@/extension/directProvider' +import { responseError, responseResult } from '@/extension/messages' import type { AccountPublic } from '@/vault/types' const originalFetch = globalThis.fetch @@ -41,8 +42,9 @@ describe('extension direct provider handling', () => { { isConnected: true }, ) - assert.equal(response?.id, 'connect-1') - const result = response?.result as { isConnected?: boolean } + assert.ok(response) + assert.equal(response.id, 'connect-1') + const result = responseResult(response) as { isConnected?: boolean } assert.equal(result.isConnected, true) }) @@ -63,7 +65,8 @@ describe('extension direct provider handling', () => { { isConnected: false }, ) - assert.deepEqual(response?.result, []) + assert.ok(response) + assert.deepEqual(responseResult(response), []) }) it('returns accounts via listAccounts for a connected origin', async () => { @@ -73,7 +76,8 @@ describe('extension direct provider handling', () => { { isConnected: true }, ) - assert.equal((response?.result as unknown[]).length, 1) + assert.ok(response) + assert.equal((responseResult(response) as unknown[]).length, 1) }) it('refuses signing from an unapproved origin instead of queuing a prompt', async () => { @@ -88,8 +92,10 @@ describe('extension direct provider handling', () => { { isConnected: false }, ) - assert.equal(response?.error?.code, -32000) - assert.match(response?.error?.message ?? '', /not connected/i) + assert.ok(response) + const error = responseError(response) + assert.equal(error?.code, -32000) + assert.match(error?.message ?? '', /not connected/i) }) it('queues signing for user approval once the origin is connected', async () => { diff --git a/test/extensionManifest.test.ts b/test/extensionManifest.test.ts index 9bd7c4f..6a22b88 100644 --- a/test/extensionManifest.test.ts +++ b/test/extensionManifest.test.ts @@ -50,11 +50,48 @@ describe('extension packaging', () => { assert.match(viteConfig, /outDir: isExtension \? 'dist-extension' : 'dist'/) }) - it('emits a classic content script without module imports', () => { - const contentScript = readText('dist-extension/contentScript.js') + it('emits every declared content script as a classic file without module imports', () => { + // Chrome loads a content script as a plain file, so it may not carry an `import`. Each one + // shares its wire constants with the popup and the service worker, so each is built on its + // own: as an input of the main build, the shared modules land in a chunk it cannot load. + // Read from the manifest rather than a hardcoded filename, because the manifest is what + // decides which files Chrome loads this way — a second one cannot slip in unchecked. + const manifest = readJson<{ content_scripts?: Array<{ js?: string[] }> }>( + 'public/manifest.json', + ) + const declared = manifest.content_scripts?.flatMap((entry) => entry.js ?? []) ?? [] + + assert.ok(declared.length > 0) + for (const file of declared) { + const script = readText(`dist-extension/${file}`) + + assert.doesNotMatch(script, /\bimport\s*[{*\w]/) + assert.doesNotMatch(script, /\bfrom\s*["'][^"']+["']/) + } + }) + + it('sets no page-world global, so a passing script cannot tell the wallet is installed', () => { + // Scenario: a MAIN-world script setting `window.canton` would let the SDK's + // ExtensionAdapter.detect() skip the SPLICE_WALLET_EXT_READY round trip. It is not worth + // it. The global hands every site the same fact the load-time announce used to, this time + // to any script that merely enumerates `window`, and it only shortens the case that is + // already fast: an uninstalled wallet sets no global, so a real absence still costs the + // SDK's full 2 s detect timeout. Discovery answers `canton:requestProvider` instead. + const manifest = readJson<{ + content_scripts?: Array<{ js?: string[]; world?: string }> + }>('public/manifest.json') - assert.doesNotMatch(contentScript, /\bimport\s*[{*\w]/) - assert.doesNotMatch(contentScript, /\bfrom\s*["'][^"']+["']/) + // One isolated-world bridge, and nothing else: a page-world entry is how the global would + // come back, so the count is part of the guard, not incidental. + assert.deepEqual( + manifest.content_scripts?.map((entry) => entry.js), + [['contentScript.js']], + ) + assert.equal( + manifest.content_scripts?.some((entry) => entry.world === 'MAIN'), + false, + ) + assert.equal(existsSync('dist-extension/cantonGlobal.js'), false) }) it('builds the announced wallet icon define from the shipped PNG', () => { diff --git a/test/tsconfig.json b/test/tsconfig.json index 9848cb8..7a3f63b 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2020", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "allowImportingTsExtensions": true, diff --git a/test/vault/exportVault.test.tsx b/test/vault/exportVault.test.tsx index 73bd890..69b4e98 100644 --- a/test/vault/exportVault.test.tsx +++ b/test/vault/exportVault.test.tsx @@ -34,13 +34,14 @@ describe('VaultContext.exportEncryptedVault', () => { }) }) - let backup: CarpinchoBackup | null = null + // Declared without an initializer on purpose: the assignment happens inside the `act` + // callback, which control-flow analysis cannot see, so an `= null` start would narrow the + // variable to `null` for the rest of the test. + let backup: CarpinchoBackup | undefined await act(async () => { - backup = (await ref.current?.exportEncryptedVault('correct-horse-battery')) ?? null + backup = await ref.current?.exportEncryptedVault('correct-horse-battery') }) - if (backup === null) { - throw new Error('no backup produced') - } + assert.ok(backup, 'no backup produced') assert.equal(backup.kind, 'carpincho-backup') assert.equal(backup.version, 1) diff --git a/test/views/CreateVault.test.tsx b/test/views/CreateVault.test.tsx index ec3b6c5..e4027f0 100644 --- a/test/views/CreateVault.test.tsx +++ b/test/views/CreateVault.test.tsx @@ -13,7 +13,12 @@ const baseVault = (): VaultContextValue => ({ setup: async () => undefined, unlock: async () => undefined, lock: () => undefined, - destroyVault: () => undefined, + destroyVault: async () => undefined, + hostedElsewhereCount: 0, + exportEncryptedVault: async () => + ({}) as Awaited>, + importEncryptedVault: async () => + ({}) as Awaited>, accounts: [], primary: null, transactions: [], diff --git a/test/views/usePendingActions.test.ts b/test/views/usePendingActions.test.ts index c154d53..0fa1afe 100644 --- a/test/views/usePendingActions.test.ts +++ b/test/views/usePendingActions.test.ts @@ -73,9 +73,11 @@ const makeArgs = ( } as unknown as VaultContextValue, proposal: undefined, proposalAccount: null, + pendingConnect: undefined, pendingSign: undefined, pendingExecute: undefined, setProposal: (v) => spies.proposalSet.push(v), + setPendingConnect: () => undefined, setPendingSign: (v) => spies.signSet.push(v), setPendingExecute: (v) => spies.executeSet.push(v), setBusy: (v) => spies.busy.push(v as boolean), diff --git a/vite.config.ts b/vite.config.ts index ef5f02b..478e5e0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' -import { defineConfig, type Plugin } from 'vite' +import { build, defineConfig, type Plugin } from 'vite' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -26,6 +26,50 @@ const walletIconDataUrl = `data:image/png;base64,${readFileSync( resolve(__dirname, 'public/icons/carpincho-48.png'), ).toString('base64')}` +// Scripts Chrome loads as classic files rather than modules, so none may carry an `import`. +// Listing one as an extra input of the main build hoists whatever it shares with the popup and +// the service worker into a chunk it cannot load, which is why every wire constant used to be +// hand-copied into contentScript.ts. Each gets its own single-entry IIFE build instead, run +// after the main one so the shared modules can just be imported. +const STANDALONE_SCRIPTS = ['contentScript'] as const + +// Shared by the main build and the standalone ones below, so the two cannot drift: a script +// built on its own has to resolve `@/` and see the same defines as the popup and the worker. +const define = { + __APP_VERSION__: JSON.stringify(appVersion), + __WALLET_ICON_DATA_URL__: JSON.stringify(walletIconDataUrl), +} +const alias = { '@': resolve(__dirname, 'src') } + +const buildStandaloneScripts = (): Plugin => ({ + name: 'carpincho-standalone-scripts', + apply: 'build', + async closeBundle() { + for (const name of STANDALONE_SCRIPTS) { + await build({ + configFile: false, + define, + resolve: { alias }, + build: { + outDir: 'dist-extension', + emptyOutDir: false, + // The main build already copied public/. Copying it again races the manifest rewrite, + // which runs alongside this one, and puts the unpatched version back. + copyPublicDir: false, + rollupOptions: { + input: resolve(__dirname, `src/extension/${name}.ts`), + output: { + format: 'iife', + entryFileNames: `${name}.js`, + inlineDynamicImports: true, + }, + }, + }, + }) + } + }, +}) + const injectManifestVersion = (): Plugin => ({ name: 'carpincho-manifest-version', apply: 'build', @@ -42,30 +86,24 @@ export default defineConfig(({ mode }) => { return { base: isExtension ? './' : '/', - define: { - __APP_VERSION__: JSON.stringify(appVersion), - __WALLET_ICON_DATA_URL__: JSON.stringify(walletIconDataUrl), - }, - plugins: [tailwindcss(), react(), ...(isExtension ? [injectManifestVersion()] : [])], - resolve: { - alias: { - '@': resolve(__dirname, 'src'), - }, - }, + define, + plugins: [ + tailwindcss(), + react(), + ...(isExtension ? [buildStandaloneScripts(), injectManifestVersion()] : []), + ], + resolve: { alias }, build: { outDir: isExtension ? 'dist-extension' : 'dist', rollupOptions: isExtension ? { input: { app: resolve(__dirname, 'index.html'), - contentScript: resolve(__dirname, 'src/extension/contentScript.ts'), background: resolve(__dirname, 'src/extension/background.ts'), }, output: { entryFileNames: (chunk) => - chunk.name === 'contentScript' || chunk.name === 'background' - ? '[name].js' - : 'assets/[name]-[hash].js', + chunk.name === 'background' ? '[name].js' : 'assets/[name]-[hash].js', }, } : undefined,