Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
22 changes: 15 additions & 7 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<all_urls>`, 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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -286,14 +292,16 @@ 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 |
|----------|-------|------|
| Popup UI | `index.html` + `src/main.tsx` | The wallet's React UI, rendered in the extension popup |
| 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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading