`, so the root's component-authoring rules do not apply to it.
+- `mock/` is source, not a double: the barrel exports `createMockAdapter`, so `testing/` cannot
+ hold it.
+- `testing/` is the published `./testing` subpath, and only four names are on its barrel:
+ `createFakeWallet`, `createAutoPicker`, `FakeSessionProvider`, `pause`. The rest is suite-local.
-## Validation Checklist
+## Testing
-- `pnpm run lint`
-- `pnpm test`
-- `pnpm run coverage`
-- `pnpm run typecheck`
+- Drive the real facade with `createFakeWallet` plus `createAutoPicker`; reach for
+ `FakeSessionProvider` when a test needs a session state and no wallet.
+- **Test our seam, not the SDK's.** Discovery, pairing, the popup and session restore are the SDK's.
+- `pnpm coverage` reports the suite with `testing/`, `mock/` and the barrel excluded.
+- The one path no test reaches is the SDK popup's own close, which the bump pass above covers.
diff --git a/canton-connect/README.md b/canton-connect/README.md
index 2650adb0..0f1f241b 100644
--- a/canton-connect/README.md
+++ b/canton-connect/README.md
@@ -1,25 +1,18 @@
# canton-connect
-wagmi-style React hooks for connecting Canton dApps to CIP-0103 wallets,
-wrapping `@canton-network/dapp-sdk`'s `DappSDK` facade.
+wagmi-style React hooks for connecting Canton dApps to CIP-0103 wallets, over
+`@canton-network/dapp-sdk`'s `DappSDK` facade.
## Why
-`@canton-network/dapp-sdk` handles wallet discovery, the connect picker, the
-session, and every transport (browser extension, WalletConnect, and — later —
-a remote gateway). It doesn't ship React hooks.
+`dapp-sdk` handles wallet discovery, the connect picker, the session and every transport (browser
+extension, WalletConnect, remote gateway), but ships no React hooks. This package adds that layer.
+A consumer never calls the SDK: it installs it as a peer, and the hooks take the SDK's own parameter
+types.
-canton-connect adds that layer: `useConnect`, `useParty`, `useSignMessage`,
-and the rest, wagmi-style. A consumer never touches the SDK directly.
-
-## Why not @partylayer/sdk
-
-[`@partylayer/sdk`](https://partylayer.xyz) is another wagmi-style package for
-Canton. This one wraps Digital Asset's official SDK directly — the dependency
-these dApps already carry — and keeps the hook layer thin enough to delete once
-the SDK ships hooks of its own. The signatures are deliberately wagmi-shaped
-either way, so swapping the implementation underneath wouldn't change a dApp's
-components.
+[`@partylayer/react`](https://partylayer.xyz) is the alternative, built over its own wallet
+adapters. This one wraps Digital Asset's official SDK, the dependency these dApps already carry, and
+stays thin enough to delete. How close the result shapes should sit to wagmi's is open in #52.
## Why a state machine
@@ -50,8 +43,15 @@ switching only once those gaps close. Until then, the machine earns its cost.
## Status
-Early. No consumer in this repo yet — `dapp/frontend` adopting it is a
-planned follow-up. Not published (`private: true` in `package.json`).
+Consumed by `dapp/frontend` in this repo as a workspace package. Not published (`private: true`).
+
+## Install
+
+Peers a consumer installs beside it: `@canton-network/dapp-sdk`, `@canton-network/core-types`,
+`react` 19 and `@walletconnect/sign-client`. The last is declared optional, but `dapp-sdk` imports
+it statically at the top of its bundle (checked on 1.5.1), so it has to be present whether or not
+you set `walletConnectProjectId`. Only the session is lazy: `SignClient.init()` runs when a pairing
+starts, not at import.
## Usage
@@ -94,44 +94,40 @@ function Dapp() {
}
if (isLocked) {
- return Wallet locked — unlock it to continue.
+ return Wallet locked. Unlock it to continue.
}
// ... your dApp: party.partyId, signMessage(text), execute(params), ledgerApi(params)
}
```
-`connect()` opens the SDK's wallet picker — a popup by default. There's no
-mode argument; the picker is what chooses the wallet.
+`connect()` opens the SDK's wallet picker, a popup by default. There is no mode argument: the picker
+is what chooses the wallet. Dismissing it rejects with `ConnectCancelledError`, which you filter by
+`instanceof`, never by message. Whether `connectError` records it as well depends on which side saw
+the close, so do not gate on that.
-Closing the picker rejects with `ConnectCancelledError`, which `connectError`
-mirrors, so a cancel is told from a failure with `instanceof` rather than by
-matching an SDK message. A custom `walletPicker` should throw it too.
+`signMessage`, `execute` and `ledgerApi` refuse with no session, and refuse again while the wallet
+reports it is not authenticated; that is `isLocked`, and it happens after a successful connect. The
+SDK's status carries one `isConnected` flag, so a lock and a wallet-side disconnect look the same
+here. `useLedger().isReady` covers both, and `useParty().party` is `undefined` for the duration:
+gate session content on the party, and use `isLocked` only to explain why it went away.
## Reference
Every hook and every config field is documented in JSDoc, which your editor surfaces at the call
site and which is published at
-[docs-canton-dappbooster.vercel.app](https://docs-canton-dappbooster.vercel.app). There is no table
-here: a copy beside the code drifts from it, and a reader who trusted the copy has no way to tell.
-
-Start at `CantonConnectProvider` and `CantonConnectConfig`; the hooks are `useConnect`, `useParty`,
-`useWalletStatus`, `useSignMessage`, `useExecute` and `useLedger`. Which wallets the picker offers is
-decided by three config fields: `walletConnectProjectId`, `walletPicker` and `additionalAdapters`.
+[docs-canton-dappbooster.vercel.app](https://docs-canton-dappbooster.vercel.app). Start at
+`CantonConnectProvider` and `CantonConnectConfig`.
## Testing helpers
-- `createMockAdapter()` — exported from the package root. A `ProviderAdapter`
- that answers `connect`/`disconnect`/`status`/`listAccounts` with no real
- wallet installed, so a dApp (or a test) can connect and show a party.
- Everything else throws, naming the method — a canned `execute` or
- `signMessage` result would be indistinguishable from a real one.
-- `createFakeWallet()` and `createAutoPicker()` — exported from
- `@bootnodedev/canton-connect/testing`. `createFakeWallet` is a real
- CIP-0103 extension wallet driven over `postMessage`, for exercising the
- SDK's actual announce → detect → provider-emit path. `createAutoPicker` is
- a headless `WalletPickerFn` that auto-selects an entry (by `providerId`, or
- the first one), for driving `connect()` without a popup.
+- `createMockAdapter()`, from the package root: a `ProviderAdapter` answering `connect`,
+ `disconnect`, `status` and `listAccounts` with no wallet installed, so a dApp or a test can
+ connect and show a party. Anything else throws, naming the method.
+- From `@bootnodedev/canton-connect/testing`: `createFakeWallet()`, a CIP-0103 extension driven over
+ `postMessage` that exercises the SDK's real announce and detect path; `createAutoPicker()`, a
+ headless picker so `connect()` runs without a popup; `FakeSessionProvider`, the context rehydrated
+ at an asked-for session with no SDK behind it; and `pause(ms)`, a real-timer sleep.
```tsx
import { CantonConnectProvider, createMockAdapter } from '@bootnodedev/canton-connect'
@@ -146,15 +142,10 @@ const config = {
## Architecture
-See [`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/architecture.md) for the facade wrapper, the
-adapter/picker seams, and the event flow.
+[`architecture.md`](https://github.com/BootNodeDev/canton-dappbooster/blob/main/canton-connect/architecture.md)
+maps the seams; its `architecture/` chapters carry the connection machine and the popup close guard.
## Testing
-```bash
-pnpm test # vitest + jsdom
-pnpm coverage # the same suite under v8 coverage; testing/, mock/ and the barrel excluded
-pnpm lint && pnpm typecheck && pnpm build
-```
-
-vitest + jsdom, with React Testing Library for hook/component tests.
+`pnpm test`: vitest + jsdom + Testing Library. `pnpm coverage` runs the same suite under v8, with
+`testing/`, `mock/` and the barrel excluded.
diff --git a/canton-connect/architecture.md b/canton-connect/architecture.md
index ae507ad5..562839ce 100644
--- a/canton-connect/architecture.md
+++ b/canton-connect/architecture.md
@@ -1,204 +1,126 @@
-# Architecture Overview — canton-connect
+# Architecture: canton-connect
-`canton-connect` is a thin React wrapper over `@canton-network/dapp-sdk`'s `DappSDK` facade. It
-gives consumer dApps a stable wagmi-style hook surface; the SDK owns discovery, the wallet picker,
-the connection session, and every wallet transport (browser extension, WalletConnect, remote
-gateway). The package is a stopgap meant to be cheap to delete as the SDK's own React story matures.
+What the package is and how to use it: [`README.md`](README.md). This file maps the seams. Two
+subsystems carry their own chapter:
+[`architecture/connection-machine.md`](architecture/connection-machine.md) for the states and what
+settles each promise, and [`architecture/popup-close-guard.md`](architecture/popup-close-guard.md)
+for the SDK bug the guard works around.
-It is browser-only.
-
-## Project Structure
+## Project structure
```
src/
- CantonConnectProvider.tsx React context; holds one DappSDK instance; init / connect / event wiring
machine/
- connectionMachine.ts the lifecycle (xstate v5); owns the sdk, the party and the last connect error; internal, not yet wired (#85)
- connectionActors.ts init / connect / restore / disconnect / walletEvents
- accountsMachine.ts the account read, invoked inside session.authenticated
- accountsActors.ts readAccounts and accountsEvents
- connectError.ts ConnectCancelledError, PickerClosedError, InitFailedError, toConnectError
- guardedConnect.ts guardedConnect: sdk.connect() with a closed-popup watchdog (#49)
- hooks/
- useConnect.ts connect / disconnect lifecycle
- useParty.ts active party
- useWalletStatus.ts lock / connect status
- useSignMessage.ts sdk.signMessage lifecycle
- useExecute.ts sdk.prepareExecuteAndWait + live tx state
- useLedger.ts sdk.ledgerApi pass-through
- walletAccount.ts account normalization + primary selection (selectUsableAccounts, selectPrimaryAccount, toParty)
- testing/
- fakeWallet.ts test-only CIP-0103 extension over postMessage (also drives real discovery)
- autoPicker.ts createAutoPicker: headless WalletPickerFn for tests/dev
- stubPopup.ts popup + window.open doubles for the close guard; off the barrel
- connectionInput.ts machine inputs whose sdk methods never answer; off the barrel
- accountsInput.ts the accounts machine's input; off the barrel
- fakeSession.tsx FakeSessionProvider: stands in for the provider with the session in a given shape; no sdk behind it
- pause.ts real-timer sleep; pause(0) flushes the pending macrotasks
- index.ts ./testing sub-path barrel
- mock/ createMockAdapter: a mock ProviderAdapter for dev/test
- types.ts Party, ConnectionStatus, CantonConnectConfig, WalletSdk, ConnectionSubscription
- index.ts public exports
+ connectionMachine.ts the lifecycle; owns the sdk, party, status and the last error
+ connectionActors.ts init / connect / restore / disconnect / walletEvents
+ accountsMachine.ts the account read, invoked inside session.authenticated
+ accountsActors.ts listAccounts reader and accountsChanged listener
+ CantonConnectProvider/
+ index.tsx the context: publishes the actor and three actions
+ useConnectionActor.ts creates the actor, sends the boot restore
+ useConnectBridge.ts connect() as a promise over the machine's tags
+ useDisconnectBridge.ts disconnect() as a promise over the machine's tags
+ adapters.ts buildAdditionalAdapters
+ hooks/ the six public hooks, plus useTxFeed and useWalletCall
+ mock/mockAdapter.ts createMockAdapter, a ProviderAdapter for dev and tests
+ testing/ the ./testing doubles, plus suite-local helpers
+ connectError.ts ConnectCancelledError, PickerClosedError, toConnectError
+ guardedConnect.ts sdk.connect() with a closed-popup watchdog
+ walletAccount.ts account normalization and primary selection
+ types.ts Party, ConnectionStatus, CantonConnectConfig, WalletSdk, context value
+ index.ts public exports
```
-## Data flow
+## Who talks to whom
```mermaid
-flowchart TD
- app["Consumer dApp"]
- hooks["Hooks — useConnect / useParty / useExecute / …"]
- provider["CantonConnectProvider (holds a DappSDK instance)"]
- sdk["@canton-network/dapp-sdk — DappSDK facade"]
- picker["walletPicker (SDK popup by default; injected in tests/dev)"]
- adapters["ExtensionAdapter · WalletConnectAdapter · (RemoteAdapter, deferred)"]
+flowchart LR
+ app["consumer dApp"]
+ cc["canton-connect"]
+ sdk["dapp-sdk"]
+ picker["wallet picker"]
wallet["CIP-0103 wallet"]
- app --> hooks
- hooks --> provider
- provider -->|init / connect| sdk
+ app -->|hooks| cc
+ cc -->|calls| sdk
sdk --> picker
- sdk --> adapters
- adapters --> wallet
- wallet -->|accountsChanged / statusChanged / txChanged| sdk
- sdk -->|onAccountsChanged / onStatusChanged / onTxChanged| provider
+ sdk -->|transport| wallet
+ wallet -->|pushes| sdk
+ sdk -->|listeners| cc
+ cc -->|context| app
```
-## Key abstractions
-
-### The connection machine (`machine/`)
-
-Internal and unwired until #85. The states, the tags and what settles `connect()` and
-`disconnect()`: [`architecture/connection-machine.md`](architecture/connection-machine.md).
-
-### `CantonConnectProvider`
-
-Holds one `DappSDK` instance (`new DappSDK({ walletPicker? })`) and owns all shared state — party,
-connection status, lock status, last-tx snapshot, connect error. Hooks are readers over this context
-(or thin delegators to facade methods). Lifecycle:
-
-- **mount**: `sdk.init({ additionalAdapters, defaultAdapters: [] })` cold-starts and restores a
- persisted session *without* opening the picker. If a session restores — even a locked one — events
- are wired immediately so a later unlock push isn't dropped.
-- **connect()**: `sdk.connect()` opens the picker and connects the chosen wallet. A rejection passes
- through `toConnectError`, which turns the built-in picker's dismissal into `ConnectCancelledError`
- so consumers never match on a message owned by `core-wallet-ui-components`.
-- **events**: `sdk.onAccountsChanged/onStatusChanged/onTxChanged` → React state. Same event names and
- types the SDK's `DappClient` exposes.
-
-**Invariant — teardown before the client swaps.** `sdk`'s `onX`/`removeOnX` bind to the *current*
-`this.client`, and `sdk.connect()` replaces the client with a new one. So listeners must be removed
-*before* triggering a connect (`connect()` tears down first, then swaps, then re-wires); otherwise
-they leak on the old client. `disconnect()` and unmount also tear down.
-
-### The wallet picker
-
-`CantonConnectConfig.walletPicker?: WalletPickerFn`. Omitted → the SDK's built-in popup (`pickWallet`,
-from `core-wallet-ui-components`). Injected → a custom picker: `createAutoPicker` (headless, for
-tests/dev) today, and a `canton-theme`-styled picker component later (deferred follow-up). This one
-seam covers production UX, testability, and the future themed UI.
-
-### The popup close guard
-
-The SDK's picker attaches `beforeunload` to the popup's `WindowProxy`, and the `about:blank → blob:`
-navigation that immediately follows destroys the listener, so a closed popup left `connect()` pending
-forever and the dApp bricked until reload (#49). `guardedConnect(sdk)` wraps the whole `sdk.connect()`
-call: it borrows `window.open` long enough to capture the handle the SDK opens, hands it straight
-back, then races the connect against a poll of `popup.closed` that rejects with `PickerClosedError`,
-carrying the SDK's own `'User closed the wallet picker'` so `toConnectError` maps it unchanged. The
-handle is remembered module-side, because a connect reusing a popup the last one left open (the
-normal path for `reuseGlobalWalletPopup` wallets) calls `window.open` not at all.
-
-**Why the call and not the picker.** Our own `walletPicker` would be the deeper seam, and the SDK
-already offers it. What blocks that route is not the `core-wallet-ui-components` trap in
-[`CLAUDE.md`](CLAUDE.md) — a picker of ours needs nothing from that package either — but that this
-package holds no picker UI by rule, and the themed one is deferred and unfiled. So the borrow is a
-stopgap standing in for a picker, not the end state.
-
-**A rejected race leaves the SDK's `connect()` running.** It is still listening for a picker result,
-and would swap the SDK's client from under the provider's event wiring on the *next* connect. That is
-what `PickerClosedError` is for: `CantonConnectProvider` retires its `DappSDK` on one, and the mount
-effect re-restores the session from the discovery session key that `connect()` never clears.
-
-Retiring the instance does not by itself reach the abandoned `connect()`. It is parked inside
-`core-wallet-ui-components` module scope on a `message` listener keyed to nothing but our origin and
-the message type, so the *next* successful connect woke every past one: one `discovery.connect`, and
-one wallet approval prompt, per popup the user had closed.
-
-`settleAbandonedConnect` drains them at the close instead. It posts the SDK's own
-`SPLICE_WALLET_PICKER_RESULT` to our window, which is the only thing that makes that listener
-unsubscribe; the `providerId` matches no registered adapter, so the abandoned connect fails with
-`WalletNotFoundError` before reaching a wallet, then rejects out of
-`waitForWalletPickerRetrySelection` because the popup is closed. `walletType` stays `'browser'` to
-keep it out of the branch that registers a remote adapter from the message, and no `name` is sent, so
-anything else on the page watching for a pick can tell the two apart — `dapp/frontend` does exactly
-that to label its connect button. It is skipped while a second guard is in flight, since the message
-would resolve that one's live waiter too — a heuristic, because only guarded connects are counted.
-
-This is a workaround, not containment: the abandoned connect still runs. The real fix is an abort on
-`DappSDK.connect()`, upstream.
-
-**The watchdog stands down once a wallet is chosen.** Past the pick the connect is waiting on the
-wallet, not the popup, so closing the popup is no longer a dismissal — treating it as one abandoned a
-live connect and left an unanswered approval request behind, one per close. Only for
-`walletType: 'browser'`: for remote and mobile the popup *is* the wallet surface, so a close there
-still strands the connect and must keep rejecting. The consequence is that closing the popup after
-choosing an extension leaves the button pending until the wallet is answered, with no way to cancel —
-the same shape as MetaMask's `eth_requestAccounts`. Nothing can retract a CIP-0103 `connect` already
-sent, and a wallet that stacks rather than replaces duplicate requests will show one prompt per
-attempt regardless.
-
-**How it fails.** For as long as the borrow is installed, the first window anything on the page opens
-is the one watched, so a connect racing an unrelated `window.open` watches the wrong handle. An SDK
-that stops reaching for `window.open` at all degrades the guard to a bare `sdk.connect()` — that much
-is pinned headless, since both provider tests wait on a URL only the SDK's own popup code writes.
-
-The drain and the stand-down have no such backstop. Both rest on the `SPLICE_WALLET_PICKER_RESULT`
-message — its type, its origin, and the `walletType` and `providerId` fields — none of it public API.
-A rename turns the drain into a no-op that returns the duplicate prompts, and turns the stand-down
-into the old behavior of abandoning a live connect, with nothing going red either way. Nor does any
-test reach #49's own *cause*, a real `WindowProxy` losing its `beforeunload` across the navigation.
-All of it is why a `dapp-sdk` bump needs the manual pass in [`CLAUDE.md`](CLAUDE.md).
-
-### Additional adapters
-
-`buildAdditionalAdapters(config, networkId)` assembles the non-extension adapters passed to `sdk.init`:
-`WalletConnectAdapter.create({ projectId, … })` when `walletConnectProjectId` is set, plus any
-`config.additionalAdapters` (e.g. the dev/test mock adapter). Extension wallets are auto-discovered
-by the facade's announce protocol — nothing to register for them. `defaultAdapters: []` suppresses
-the SDK's bundled `localhost:3030` dev Wallet Gateway.
-
-`networkId` (`CantonConnectConfig.networkId`, default `'canton:local'`) drives two things from one
-field: the WalletConnect adapter's CAIP-2 `chainId` above, and `Party.networkId` (set in
-`wireEvents`, via `toParty`).
-
-**`@walletconnect/sign-client` is declared an optional peer and is not actually optional.**
-`dapp-sdk` imports it statically at the top of its bundle, so it has to be installed whether or not
-`walletConnectProjectId` is set. Only the *session* is lazy: `SignClient.init()` runs when a pairing
-starts, not at import. The `peerDependenciesMeta` entry marking it optional therefore describes what
-we want rather than what resolves — worth an upstream issue, and until then treat it as required.
-Still true at 1.5.1, the version pinned here: the entry module touches `window` at import. Re-check on the next bump.
-
-### Hooks
-
-| Hook | Responsibility |
-| ------ | ---------------- |
-| `useConnect` | start/stop the connection; expose error/connecting state |
-| `useParty` | current primary party + connection status |
-| `useWalletStatus` | lock/connect status from wallet events |
-| `useSignMessage` | `sdk.signMessage` as a promise lifecycle |
-| `useExecute` | `sdk.prepareExecuteAndWait` + live tx status |
-| `useLedger` | raw `sdk.ledgerApi` pass-through |
-
-## Boundaries & conventions
-
-- Wraps `@canton-network/dapp-sdk` and nothing app-specific. No imports from `dapp/`. Names no wallet.
-- **Import the SDK's types; never hand-copy them.** Hook params are the SDK's own (`PrepareExecuteParams`, `LedgerApiParams`); event constants come from `core-types` (`WalletEvent`, `CANTON_*_PROVIDER_EVENT`). No `as Parameters<…>` casts.
-- No SDK-import quarantine — the package is a thin SDK wrapper throughout (the old `core`/`connectors` split it served was cancelled by adopting the facade).
+| edge | what crosses it |
+|---|---|
+| calls | `init`, `connect`, `disconnect`, `status`, `listAccounts` from the machine's actors; `signMessage`, `prepareExecuteAndWait`, `ledgerApi` from the hooks |
+| transport | extension postMessage, WalletConnect, remote gateway |
+| pushes | `statusChanged`, `accountsChanged`, `txChanged` |
+| listeners | `onStatusChanged`, `onAccountsChanged`, `onTxChanged` |
+| context | one `CantonConnectContextValue` |
+
+## Seams
+
+### The lifecycle: `machine/`
+
+One model of connecting, session, lock and disconnect, so the impossible combinations (a status
+with no party, an error beside a live session) cannot be built. Three decisions carry the weight:
+
+- `idle` is not `disconnected`. `idle` means the boot restore has not answered; `disconnected` means
+ it has, and there is nothing.
+- `party` is cleared on leaving `session.authenticated`, so a wallet that will not serve requests
+ publishes none. The session itself stays, which keeps the wallet listener alive: an unlock is
+ heard and the party is read again with no reconnect.
+- The account read is a child machine, so a failed read cannot end the session; only the promise
+ carries the failure.
+
+### The bridges
+
+`connect()` and `disconnect()` are a send plus a wait on a tag, so the promise over a transition
+lives outside the machine. Neither passes a timeout. The connect wait is #105; the disconnect wait
+the machine bounds itself, giving up on a wallet 10 s silent (`DISCONNECT_TIMEOUT_MS`), since the
+SDK's request carries no deadline of its own.
+
+### The provider publishes, the hooks select
+
+The context value is the config, the actor as `ConnectionSubscription` (`send` is unreachable
+through it, so the bridges stay the only senders) and three identity-stable actions. Each hook
+selects its own slice, which is wagmi's shape: `WagmiProvider` publishes, `useAccount` subscribes
+itself. `useConnect`, `useParty` and `useWalletStatus` read session state; `useLedger`, `useExecute`
+and `useSignMessage` select a guard plus the sdk and call it directly, never entering the machine.
+
+The machine's input is read once, when the actor is created, so a changed `config` prop needs a
+remount. One accepted cost: `sdk` in context makes the snapshot unserializable, which rules out
+`getPersistedSnapshot`.
+
+### The picker, and the close guard around it
+
+`CantonConnectConfig.walletPicker` decides the picker: omitted, the SDK's popup; injected, a custom
+one (`createAutoPicker` in tests). It is fixed at `new DappSDK()`, which is why the provider hands
+the machine a `createSdk` closure rather than an instance.
+
+With the SDK popup in use, `guardedConnect` wraps `sdk.connect()` with a watchdog on the popup
+window, because the SDK misses a close (#49). A caught close rejects with `PickerClosedError`, which
+takes the machine to `retiring`, where the `DappSDK` is replaced.
+
+### Adapters
+
+`buildAdditionalAdapters` assembles what `sdk.init` registers beyond the auto-discovered extensions:
+a `WalletConnectAdapter` when `walletConnectProjectId` is set, plus `config.additionalAdapters`. The
+init actor passes `defaultAdapters: []`, dropping the SDK's bundled `localhost:3030` dev gateway.
+`networkId` (default `'canton:local'`) is both the WalletConnect `chainId` and the fallback
+`Party.networkId` for a wallet that reports none.
+
+### Testing doubles
+
+`createFakeWallet` is a real CIP-0103 extension over `postMessage`, so a test walks the SDK's own
+announce, detect and connect path. `createAutoPicker` answers the picker headlessly, and
+`FakeSessionProvider` rehydrates the machine at an asked-for state with no SDK behind it.
## Deferred
-- **Remote / Wallet-Gateway (OIDC) path** — a configurable `RemoteAdapter` via `additionalAdapters` + `CantonConnectConfig` (issue #2, reframed; decoupled from #3).
-- **Themed wallet picker** — a `canton-theme`-styled component injected via `walletPicker`, replacing the SDK popup for UX control. Not yet filed.
-- **`dapp/frontend` adoption** — the app re-adopts this package; the connection bar returns (issue #40).
+- Remote / Wallet Gateway (OIDC) path: a configurable `RemoteAdapter` through `additionalAdapters`
+ and `CantonConnectConfig` (#2).
+- Themed in-page picker (#50): its PR (#63) was closed unmerged, so the SDK popup is still the only
+ picker; a new attempt starts from the `walletPicker` seam.
-For the full local stack around this package, see the root [`architecture.md`](../architecture.md).
+For the stack around this package: the root [`architecture.md`](../architecture.md).
diff --git a/canton-connect/architecture/connection-machine.md b/canton-connect/architecture/connection-machine.md
index 6dce5a45..908cb9a8 100644
--- a/canton-connect/architecture/connection-machine.md
+++ b/canton-connect/architecture/connection-machine.md
@@ -1,8 +1,9 @@
# The connection machine
-Reference for `machine/connectionMachine.ts`: the states, what each one means to a caller, and what
-settles `connect()` and `disconnect()`. The code is the authority; when the two disagree, fix this
-file.
+`machine/connectionMachine.ts` is the source of truth: the states, events, tags and actors are its
+`setup()` and config, and every per-state why rides beside its line as a comment. This chapter
+holds what the file cannot say from inside one state: the shape at a glance, and the contracts the
+bridges and hooks build on top of it. It keeps no per-state inventory on purpose; read the machine.
## The spine
@@ -10,124 +11,74 @@ file.
stateDiagram-v2
[*] --> idle
idle --> initializing: restore
- initializing --> restoring: onDone
- initializing --> failure: onError
- restoring --> session: onDone [isAuthenticated]
- restoring --> disconnected: nothing to restore
+ initializing --> restoring: sdk booted
+ initializing --> failure: boot failed
+ restoring --> session: session found
+ restoring --> disconnected: nothing there
idle --> connecting: connect
disconnected --> connecting: connect
failure --> connecting: connect
- connecting --> session: onDone [isAuthenticated]
+ session --> connecting: connect (wallet change)
+ connecting --> session: wallet approved
connecting --> failure: declined or threw
- connecting --> retiring: the picker was closed
- retiring --> restoring: onDone, on the replacement
- retiring --> failure: onError, the replacement's init failed
+ connecting --> retiring: picker closed
+ retiring --> restoring: replacement booted
+ retiring --> failure: replacement failed too
session --> disconnecting: disconnect
- disconnecting --> disconnected: settled
- disconnecting --> disconnected: 10 s unanswered, on a replacement sdk
+ disconnecting --> disconnected: settled, or 10 s silence
```
-`session` holds `unauthenticated` (the wallet reports it will not serve requests) and
-`authenticated`, whose three substates mirror the accounts child: `reading`, `ready`, `unavailable`.
-Entry always targets `authenticated`; only a wallet push reaches `unauthenticated`.
-`disconnecting` is a single state: a connect asked for while it runs is ignored, not queued, so it
-never leads anywhere but `disconnected`. Ten seconds without the wallet's answer takes that same
-exit, on a replacement sdk: the SDK's request carries no deadline of its own.
-
-Three events reach further than the diagram shows. `connect` is taken everywhere except `connecting`
-and `disconnecting`. `disconnect` is taken everywhere except `idle`, `disconnected` and
-`disconnecting`. `restore` is taken by `idle`, `disconnected`, `session` and `failure`. A fourth,
-`connectError.reset`, is taken everywhere and changes no state.
-
-The wallet's push arrives as `wallet.statusChanged`, sent by the `walletEvents` actor:
-`session.authenticated` leaves for `unauthenticated` when `connection.isConnected` is false, and
-`unauthenticated` returns to `authenticated` when it is true.
-
-## States
-
-| state | means | public `status` |
-| --- | --- | --- |
-| `idle` | nothing attempted yet | `idle` |
-| `initializing` | SDK cold start | `idle` |
-| `restoring` | asking the wallet for a session | `idle` |
-| `connecting` | the wallet is deciding | `connecting` |
-| `session.unauthenticated` | session alive, wallet not authenticated, party dropped | `connected` |
-| `session.authenticated.reading` | account read in flight | `connected` |
-| `session.authenticated.ready` | party known | `connected` |
-| `session.authenticated.unavailable` | the read failed, session intact | `connected` |
-| `failure` | the attempt failed; the error stays in context until exit | `disconnected` |
-| `retiring` | the closed picker's instance is abandoned, its replacement booting | `disconnected` |
-| `disconnecting` | the wallet is being asked to end the session; unanswered for 10 s, it settles anyway on a replacement sdk | `disconnecting` |
-| `disconnected` | asked, and there is nothing | `disconnected` |
-
-## What each actor reaches for
-
-| actor | invoked by | reaches |
-| --- | --- | --- |
-| `init` | `initializing`, `retiring` | `sdk.init`, once per SDK instance; the SDK caches a rejection forever, so only a replacement retries |
-| `connect` | `connecting` | `init`, then `guardedConnect` or `sdk.connect`, then `sdk.status` when the answer is not connected |
-| `restore` | `restoring` | `sdk.status`; an answer without `connection` counts as nothing to restore |
-| `disconnect` | `disconnecting` | `sdk.disconnect`, under the machine's 10 s deadline since the SDK sets none |
-| `walletEvents` | `session` | `sdk.onStatusChanged` |
-| `accounts` | `session.authenticated` | `accountsMachine` |
-| `readAccounts` | `accounts.reading` | `sdk.listAccounts`, then `selectUsableAccounts`, `selectPrimaryAccount`, `toParty` |
-| `accountsEvents` | the accounts root | `sdk.onAccountsChanged` |
-
-Each reads its sdk off the invoke's input, resolved from context when the invoke starts, so leaving
-the state stops the actor and drops the listener with it.
-
-## What settles a promise
-
-The `connect()` and `disconnect()` columns describe the bridges the provider PR adds; on this branch
-the tags exist and nothing awaits them.
-
-| machine state | tags | `connect()` | `disconnect()` |
-| --- | --- | --- | --- |
-| `idle` | `disconnect.settled` | waits | resolves |
-| `initializing`, `restoring` | none | waits | waits |
-| `connecting` | `connecting` | waits | already answered, one state earlier |
-| `session.authenticated.reading` | `connecting` | waits | waits |
-| `session.authenticated.ready` | `connect.settled` | resolves | waits |
-| `session.authenticated.unavailable` | `connect.failed` | rejects, wallet's error | waits |
-| `session.unauthenticated` | `connect.settled`, `unauthenticated` | resolves, no party | waits |
-| `failure` | `connect.failed` | rejects, recorded error | waits |
-| `retiring` | `connect.cancelled` | rejects, `ConnectCancelledError` | waits |
-| `disconnecting` | none | waits | waits |
-| `disconnected` | `connect.cancelled`, `disconnect.settled` | rejects, `ConnectCancelledError` | resolves |
-
-The last two tags are for hooks rather than bridges: `connecting` answers `isConnecting`,
-`unauthenticated` answers `isLocked`. On this branch nothing reads them; the provider PR wires both.
-A five-way enum stays a selector's job, so `status` is `toConnectionStatus`.
-
-Five placements carry weight:
-
-- **`session.unauthenticated` settles a connect.** A wallet that connects locked answers no account
- read, so waiting for a party would wait forever.
-- **Entering it drops the party.** A wallet that will not serve requests has no party to offer, and
- a lock and a wallet-side disconnect arrive as the same push, so the two cannot be told apart. The
- session itself stays, which is what keeps the listener alive: an unlock pushes `isConnected: true`
- and the party is read again with no reconnect.
-- **A connect over a standing session is ignored.** Every `session` state carries `connect.settled`, so the call is already answered by the state it lands on; nothing reaches the wallet.
-- **A connect during `disconnecting` is ignored, not queued.** `sdk.connect()` and `sdk.disconnect()`
- both rewrite the client and must not overlap, so `disconnecting` handles no `connect` and always
- ends at `disconnected`. Its public `status` is `disconnecting`, so a consumer keeps its connect
- action disabled until the disconnect settles.
-- **`retiring` cancels rather than fails.** The user closed the picker, so nothing failed, even
- though the machine goes on to boot a replacement and restore on it.
+`connecting`, `retiring` and `restoring` each split into `new` and `changing`: the variants carry
+whether a standing session is at stake. A connect from `session` runs as
+`changing`, and a closed picker resumes that session through `retiring.changing` and
+`restoring.changing`, which `toConnectionStatus` reports as `'connecting'` rather than
+`'disconnected'` and `'idle'`: a consumer gating on status must not unmount the app while its
+session is on the way back.
+
+`session` holds `unauthenticated` (the wallet will not serve requests) and `authenticated`, whose
+substates mirror the accounts child: `reading`, `ready`, `unavailable`. Entry always targets
+`authenticated`; only a wallet push moves between the two.
+
+`restore` is not only the boot event: `disconnected`, `failure` and a standing `session` (one whose
+sdk was replaced under it) all take it back to `initializing`.
+
+## What consumers stand on
+
+The tags are the machine's public face; the bridges and hooks read nothing else.
+
+- `connect()` sends the event and waits: `connect.settled` resolves it, `connect.failed` rejects
+ with the recorded error through `toConnectError`, `connect.cancelled` rejects with a fresh
+ `ConnectCancelledError`. The wait has no timeout, so a state that answers a connect must carry
+ one of the three.
+- `disconnect()` waits for `disconnect.settled` the same way.
+- Hooks select: `isConnecting` is `hasTag('connecting')`, `isLocked` is `hasTag('unauthenticated')`,
+ `status` is `toConnectionStatus`, `connectError` is `context.lastConnectError` through
+ `toConnectError`.
+
+Consequences a caller notices:
+
+- A connect sent over a standing session goes to the wallet as a wallet change, and after a
+ wallet-side disconnect (one indistinguishable push with a lock) it is the only recovery a
+ consumer can drive. A change the user walks out on resumes the session it would have replaced.
+- A connect during a disconnect is ignored, not queued, and is answered as a cancel once the
+ machine rests in `disconnected`; `status` stays `disconnecting` until then, so a consumer keeps
+ its connect action disabled.
+- A connect never lands in `session.unauthenticated`: `landAuthenticated` is the only entry into
+ `session`, and an unauthenticated wallet answer goes to `failure`. Locked is only ever reached
+ by a wallet push on a standing session, and the party comes back the same way.
+- `retiring` and `disconnected` answer as cancels and record no error: a cancel is the user walking
+ away, not a failure.
## The last connect error
-`lastConnectError` rides in context and outlives the state that produced it, so a recovered session
-can still say why the attempt before it failed. It is cleared on entering `connecting`, `retiring`,
-`disconnecting` and `disconnected`, by `connectError.reset` (the event the provider PR puts behind
-`useConnect().reset()`), and by a push that recovers a failed read.
-
-The two ways a picker close reaches the caller differ. A close the watchdog catches records nothing:
-`connecting` goes to `retiring`, which clears the error, and `connect()` rejects with a fresh
-`ConnectCancelledError`. A dismissal the SDK itself rejects (`'User closed the wallet picker'`) goes
-to `failure` and is recorded; the provider classifies it with `toConnectError` as
-`ConnectCancelledError` on the way to `connectError`. Either way a consumer filters by `instanceof`,
-never by message.
+Which states record, keep and clear `lastConnectError` is the machine's own rule; the context
+comment carries the why (a recovered session can still say why the attempt before it failed).
+
+A picker close reaches the caller two ways. A close the watchdog catches records nothing:
+`connecting` goes to `retiring` and `connect()` rejects with a fresh `ConnectCancelledError`. A
+dismissal the SDK itself rejects goes to `failure` and is recorded; `toConnectError` classifies it
+as `ConnectCancelledError` on the way out. Either way a consumer filters by `instanceof`, never by
+message. The watchdog itself: [`popup-close-guard.md`](popup-close-guard.md).
## The accounts machine
diff --git a/canton-connect/architecture/popup-close-guard.md b/canton-connect/architecture/popup-close-guard.md
new file mode 100644
index 00000000..38ea75a6
--- /dev/null
+++ b/canton-connect/architecture/popup-close-guard.md
@@ -0,0 +1,71 @@
+# The popup close guard
+
+`guardedConnect.ts` wraps `sdk.connect()` when the SDK's own popup picker is in use. All of it rests
+on `dapp-sdk` internals, so this chapter is the why: what the SDK misses, what the guard does, and
+where it breaks. The bump procedure it justifies is in [`../CLAUDE.md`](../CLAUDE.md).
+
+## The bug
+
+The SDK's picker attaches `beforeunload` to the popup's `WindowProxy`, and the `about:blank` to
+`blob:` navigation that immediately follows destroys the listener. A closed popup therefore left
+`connect()` pending forever and bricked the dApp until reload (#49).
+
+## What the guard does
+
+`guardedConnect(sdk)` borrows `window.open` long enough to capture the handle the SDK opens, hands
+it straight back, then races the connect against a poll of `popup.closed` that rejects with
+`PickerClosedError`, carrying the SDK's own `'User closed the wallet picker'` so `toConnectError`
+maps it unchanged. The handle is remembered module-side, because a connect reusing a popup the last
+one left open (the normal path for `reuseGlobalWalletPopup` wallets) never calls `window.open`.
+
+Wrapping the call rather than supplying our own `walletPicker` is a stopgap: the picker seam is the
+deeper one and the SDK already offers it, but this package holds no picker UI by rule and the themed
+one (#50) has no merged implementation.
+
+## The orphaned connect
+
+A rejected race leaves the SDK's `connect()` running, still listening for a picker result and ready
+to swap the SDK's client from under the machine's event wiring on the next connect. That is what
+`PickerClosedError` is for: `connecting` takes it to `retiring`, which replaces the `DappSDK` and
+restores the session from the discovery session key that `connect()` never clears.
+
+Replacing the instance does not by itself reach the orphan. The abandoned `connect()` is parked
+inside `core-wallet-ui-components` module scope, on a `message` listener keyed to nothing but our
+origin and the message type, so the next successful connect woke every past one: one
+`discovery.connect`, and one wallet approval prompt, per popup the user had closed.
+
+`settleAbandonedConnect` drains them at the close instead. It posts the SDK's own
+`SPLICE_WALLET_PICKER_RESULT` to our window, the only thing that makes that listener unsubscribe.
+The `providerId` matches no registered adapter, so the orphan fails with `WalletNotFoundError`
+before reaching a wallet, then rejects out of `waitForWalletPickerRetrySelection` because the popup
+is closed. `walletType` stays `'browser'` to keep it out of the branch that registers a remote
+adapter from the message, and no `name` is sent, so anything else on the page watching for a pick
+can tell the two apart; `dapp/frontend` does exactly that to label its connect button. The drain is
+skipped while a second guard is in flight, since the message would resolve that one's live waiter
+too.
+
+This is a workaround, not containment: the orphan still runs. The real fix is an abort on
+`DappSDK.connect()`, upstream. CIP-0103 has no cancel for a sent `connect` either.
+
+## The watchdog stands down once a wallet is chosen
+
+Past the pick the connect is waiting on the wallet, not the popup, so closing the popup is no longer
+a dismissal; treating it as one abandoned a live connect and left an unanswered approval request
+behind, one per close. This applies only to `walletType: 'browser'`: for remote and mobile the popup
+is the wallet surface, so a close there still strands the connect and must keep rejecting.
+
+The cost is that closing the popup after choosing an extension leaves the button pending until the
+wallet answers, with no way to cancel, the same shape as MetaMask's `eth_requestAccounts`.
+
+## How it fails
+
+While the borrow is installed, the first window anything on the page opens is the one watched, so a
+connect racing an unrelated `window.open` watches the wrong handle. An SDK that stops reaching for
+`window.open` degrades the guard to a bare `sdk.connect()`; that much is pinned headless, since both
+provider tests wait on a URL only the SDK's own popup code writes.
+
+The drain and the stand-down have no such backstop. Both rest on the `SPLICE_WALLET_PICKER_RESULT`
+message: its type, its origin, and the `walletType` and `providerId` fields, none of it public API.
+A rename turns the drain into a no-op that returns the duplicate prompts, and turns the stand-down
+back into abandoning a live connect, with nothing going red either way. Nor does any test reach
+#49's own cause, a real `WindowProxy` losing its `beforeunload` across the navigation.
diff --git a/canton-connect/package.json b/canton-connect/package.json
index c815122e..03575c80 100644
--- a/canton-connect/package.json
+++ b/canton-connect/package.json
@@ -79,6 +79,7 @@
"vitest": "^4.1.10"
},
"dependencies": {
+ "@xstate/react": "^6.1.0",
"xstate": "^5.32.5"
}
}
diff --git a/canton-connect/src/CantonConnectProvider.test.tsx b/canton-connect/src/CantonConnectProvider.test.tsx
deleted file mode 100644
index 15a6eeee..00000000
--- a/canton-connect/src/CantonConnectProvider.test.tsx
+++ /dev/null
@@ -1,773 +0,0 @@
-import type { WalletPickerEntry, WalletPickerFn } from '@canton-network/dapp-sdk'
-import { DappSDK } from '@canton-network/dapp-sdk'
-import { act, render, renderHook, waitFor } from '@testing-library/react'
-import type { JSX } from 'react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { CantonConnectProvider, useCantonConnectContext } from '#src/CantonConnectProvider'
-import { ConnectCancelledError } from '#src/connectError'
-import { useConnect } from '#src/hooks/useConnect'
-import { useExecute } from '#src/hooks/useExecute'
-import { useLedger } from '#src/hooks/useLedger'
-import { useParty } from '#src/hooks/useParty'
-import { useSignMessage } from '#src/hooks/useSignMessage'
-import { useWalletStatus } from '#src/hooks/useWalletStatus'
-import { createMockAdapter } from '#src/mock/mockAdapter'
-import { createAutoPicker } from '#src/testing/autoPicker'
-import { createFakeWallet } from '#src/testing/fakeWallet'
-import { type StubPopup, stubOpen, stubPopup } from '#src/testing/stubPopup'
-
-const KERNEL_DISCOVERY_KEY = 'splice_wallet_kernel_discovery'
-const DISCOVERY_SESSION_KEY = 'splice_discovery_client_session'
-const SUGGESTED_ENTRIES_KEY = 'splice_wallet_picker_suggested_entries'
-const RECENT_GATEWAYS_KEY = 'splice_wallet_picker_recent'
-
-// Selecting the entry would start real pairing; capture what was offered and bail.
-const capturePicker =
- (offered: WalletPickerEntry[]): WalletPickerFn =>
- async (entries) => {
- offered.push(...entries)
- throw new Error('cancel')
- }
-
-// A picker a test can call connect() with when it never intends to succeed.
-const throwingPicker: WalletPickerFn = async () => {
- throw new Error('cancel')
-}
-
-let restoreOpen: (() => void) | undefined
-
-// Drives a connect to the point a close strands it: the window only gets a URL once the SDK has
-// opened it.
-const strandOnClosedPicker = async (
- result: { current: { sdk: DappSDK; connect: () => Promise } },
- popup: StubPopup,
-): Promise => {
- const stranded = result.current.sdk
-
- await act(async () => {
- const connecting = expect(result.current.connect()).rejects.toBeInstanceOf(
- ConnectCancelledError,
- )
- await waitFor(() => expect(popup.location.href).not.toBe(''))
- popup.closed = true
- await connecting
- })
-
- await waitFor(() => expect(result.current.sdk).not.toBe(stranded))
- return stranded
-}
-
-describe('CantonConnectProvider', () => {
- afterEach(() => {
- localStorage.removeItem(KERNEL_DISCOVERY_KEY)
- localStorage.removeItem(DISCOVERY_SESSION_KEY)
- localStorage.removeItem(SUGGESTED_ENTRIES_KEY)
- localStorage.removeItem(RECENT_GATEWAYS_KEY)
-
- // A prototype spy survives a failed assertion; restoring here keeps it out of later tests.
- vi.restoreAllMocks()
- restoreOpen?.()
- restoreOpen = undefined
- })
-
- it('initial state is idle with no party and not locked', () => {
- const config = { appName: 'Test dApp' }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- expect(result.current.status).toBe('idle')
- expect(result.current.party).toBe(undefined)
- expect(result.current.isLocked).toBe(false)
- })
-
- it('useCantonConnectContext throws when used outside the provider', () => {
- const Naked = (): JSX.Element => {
- useCantonConnectContext()
- return
- }
- expect(() => render()).toThrow(/inside a /)
- })
-
- it('connects the announced wallet the picker selects', async () => {
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
-
- const config = { appName: 'test', walletPicker: createAutoPicker() }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect()
- })
-
- expect(result.current.party?.partyId).toBe('alice::1220ab')
- expect(result.current.status).toBe('connected')
-
- wallet.dispose()
- })
-
- it('connects through the mock adapter with no real wallet installed', async () => {
- const mock = createMockAdapter({ id: 'mock-test', accounts: [{ partyId: 'alice::mock1220' }] })
-
- const config = {
- appName: 'test',
- additionalAdapters: [mock],
- // Selecting by id, not ordering — a real announced wallet could also be in the entries.
- walletPicker: createAutoPicker('mock-test'),
- }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect()
- })
-
- expect(result.current.party?.partyId).toBe('alice::mock1220')
- expect(result.current.status).toBe('connected')
- })
-
- it('mock party tracks the configured networkId when the mock sets none', async () => {
- const mock = createMockAdapter({
- id: 'mock-adaptive',
- accounts: [{ partyId: 'alice::mock1220' }],
- })
-
- const config = {
- appName: 'test',
- networkId: 'canton:testnet',
- additionalAdapters: [mock],
- walletPicker: createAutoPicker('mock-adaptive'),
- }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect()
- })
-
- expect(result.current.party?.networkId).toBe('canton:testnet')
- })
-
- it('mock party keeps its own networkId even when it disagrees with the config', async () => {
- const mock = createMockAdapter({
- id: 'mock-devnet',
- networkId: 'canton:devnet',
- accounts: [{ partyId: 'alice::mock1220' }],
- })
-
- const config = {
- appName: 'test',
- networkId: 'canton:testnet',
- additionalAdapters: [mock],
- walletPicker: createAutoPicker('mock-devnet'),
- }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect()
- })
-
- expect(result.current.party?.networkId).toBe('canton:devnet')
- })
-
- it('wires events for a restored session even when the wallet reports locked', async () => {
- // Mirrors what a real connect() persists to localStorage, so init() takes the restore path.
- localStorage.setItem(
- KERNEL_DISCOVERY_KEY,
- JSON.stringify({ walletType: 'extension', providerId: 'browser:ext:wallet-a' }),
- )
- localStorage.setItem(
- DISCOVERY_SESSION_KEY,
- JSON.stringify({ providerId: 'browser:ext:wallet-a' }),
- )
-
- // First status() is the SDK's internal restore check; the second is ours, finding it locked.
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- statusResponses: [true, false],
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
-
- const config = { appName: 'test', walletPicker: createAutoPicker() }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await waitFor(() => expect(result.current.isLocked).toBe(true))
- expect(result.current.status).toBe('connected')
- expect(result.current.party).toBe(undefined)
-
- act(() => {
- wallet.push('statusChanged', {
- provider: { id: 'wallet-a', providerType: 'browser' },
- connection: { isConnected: true, isNetworkConnected: true },
- })
- })
-
- await waitFor(() => expect(result.current.isLocked).toBe(false))
-
- wallet.dispose()
- })
-
- it('clears isLocked when connect() succeeds after a locked session was restored', async () => {
- localStorage.setItem(
- KERNEL_DISCOVERY_KEY,
- JSON.stringify({ walletType: 'extension', providerId: 'browser:ext:wallet-a' }),
- )
- localStorage.setItem(
- DISCOVERY_SESSION_KEY,
- JSON.stringify({ providerId: 'browser:ext:wallet-a' }),
- )
-
- // Restore's own check sees connected, ours finds it locked, connect()'s sees connected again.
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- statusResponses: [true, false, true],
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
-
- const config = { appName: 'test', walletPicker: createAutoPicker() }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await waitFor(() => expect(result.current.isLocked).toBe(true))
-
- await act(async () => {
- await result.current.connect()
- })
-
- expect(result.current.isLocked).toBe(false)
- expect(result.current.party?.partyId).toBe('alice::1220ab')
-
- wallet.dispose()
- })
-
- it('tears down the previous client listeners before connect() swaps the client', async () => {
- localStorage.setItem(
- KERNEL_DISCOVERY_KEY,
- JSON.stringify({ walletType: 'extension', providerId: 'browser:ext:wallet-a' }),
- )
- localStorage.setItem(
- DISCOVERY_SESSION_KEY,
- JSON.stringify({ providerId: 'browser:ext:wallet-a' }),
- )
-
- // Restore's internal check sees connected, our own check finds it locked, connect()'s own
- // check (against the swapped-in client) sees connected again.
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- statusResponses: [true, false, true],
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
-
- const config = { appName: 'test', walletPicker: createAutoPicker() }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await waitFor(() => expect(result.current.isLocked).toBe(true))
-
- // connect() replaces sdk's internal client with a new one; teardown must run against the
- // old client first, or removeOnAccountsChanged ends up targeting the wrong client.
- const removeSpy = vi.spyOn(result.current.sdk, 'removeOnAccountsChanged')
- const connectSpy = vi.spyOn(result.current.sdk, 'connect')
-
- await act(async () => {
- await result.current.connect()
- })
-
- expect(removeSpy).toHaveBeenCalledTimes(1)
- expect(removeSpy.mock.invocationCallOrder[0]).toBeLessThan(
- connectSpy.mock.invocationCallOrder[0],
- )
-
- wallet.dispose()
- })
-
- it('keeps delivering events to useParty() after a throwing picker rejects connect() on a restored session', async () => {
- localStorage.setItem(
- KERNEL_DISCOVERY_KEY,
- JSON.stringify({ walletType: 'extension', providerId: 'browser:ext:wallet-a' }),
- )
- localStorage.setItem(
- DISCOVERY_SESSION_KEY,
- JSON.stringify({ providerId: 'browser:ext:wallet-a' }),
- )
-
- // Restore's internal check, our own restore check, and the post-failure probe all see the
- // same still-live, connected client.
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- statusResponses: [true, true, true],
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
-
- const config = { appName: 'test', walletPicker: throwingPicker }
- const { result } = renderHook(() => ({ connect: useConnect(), party: useParty() }), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await waitFor(() => expect(result.current.party.party?.partyId).toBe('alice::1220ab'))
- expect(result.current.party.status).toBe('connected')
-
- // sdk.connect() rejects before it ever swaps its client — the restored session survives.
- await act(async () => {
- await expect(result.current.connect.connect()).rejects.toThrow('cancel')
- })
-
- expect(result.current.party.party?.partyId).toBe('alice::1220ab')
-
- act(() => {
- wallet.push('accountsChanged', [
- {
- partyId: 'bob::9931cd',
- primary: true,
- hint: 'bob',
- publicKey: 'pub-bob',
- networkId: 'canton:local',
- },
- ])
- })
-
- await waitFor(() => expect(result.current.party.party?.partyId).toBe('bob::9931cd'))
-
- wallet.dispose()
- })
-
- it('sets connectError and rejects connect() when the picker throws', async () => {
- const config = { appName: 'test', walletPicker: throwingPicker }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await expect(result.current.connect()).rejects.toThrow('cancel')
- })
-
- expect(result.current.connectError?.message).toBe('cancel')
- expect(result.current.status).toBe('disconnected')
- })
-
- it('offers a WalletConnect entry when a project id is configured', async () => {
- const offered: WalletPickerEntry[] = []
-
- const config = {
- appName: 'test',
- walletConnectProjectId: 'test-project',
- walletPicker: capturePicker(offered),
- }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect().catch(() => undefined)
- })
-
- expect(offered).toEqual([
- expect.objectContaining({ providerId: 'walletconnect', type: 'mobile' }),
- ])
- })
-
- it('retires the SDK a closed picker left mid-connect', async () => {
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
- const popup = stubPopup()
- restoreOpen = stubOpen(popup)
-
- const config = { appName: 'test' }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await strandOnClosedPicker(result, popup)
- expect(result.current.status).toBe('disconnected')
-
- wallet.dispose()
- })
-
- it('keeps a restored session across the retirement a closed picker forces', async () => {
- localStorage.setItem(
- KERNEL_DISCOVERY_KEY,
- JSON.stringify({ walletType: 'extension', providerId: 'browser:ext:wallet-a' }),
- )
- localStorage.setItem(
- DISCOVERY_SESSION_KEY,
- JSON.stringify({ providerId: 'browser:ext:wallet-a' }),
- )
-
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
- const popup = stubPopup()
- restoreOpen = stubOpen(popup)
-
- const config = { appName: 'test' }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await waitFor(() => expect(result.current.party?.partyId).toBe('alice::1220ab'))
-
- await strandOnClosedPicker(result, popup)
- expect(result.current.status).toBe('connected')
- expect(result.current.party?.partyId).toBe('alice::1220ab')
-
- wallet.dispose()
- })
-
- it('leaves a consumer-supplied picker alone rather than guarding the SDK popup', async () => {
- const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
- const picker = vi.fn(throwingPicker)
-
- const config = { appName: 'test', walletPicker: picker }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await expect(result.current.connect()).rejects.toThrow('cancel')
- })
-
- expect(picker).toHaveBeenCalledTimes(1)
- expect(openSpy).not.toHaveBeenCalled()
- })
-
- it('offers no WalletConnect entry without a project id', async () => {
- const offered: WalletPickerEntry[] = []
-
- const config = { appName: 'test', walletPicker: capturePicker(offered) }
- const { result } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect().catch(() => undefined)
- })
-
- expect(offered).toEqual([])
- })
-
- it('does not re-init when a rerender passes a new config object with the same field values', async () => {
- const initSpy = vi.spyOn(DappSDK.prototype, 'init')
-
- // Hoisted so this reference stays stable across renders; only the wrapping config is fresh.
- const walletPicker = createAutoPicker()
-
- const { rerender } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
-
- {children}
-
- ),
- })
-
- await waitFor(() => expect(initSpy).toHaveBeenCalledTimes(1))
-
- rerender()
-
- expect(initSpy).toHaveBeenCalledTimes(1)
- })
-
- it('delivers a pushed accountsChanged event to useParty()', async () => {
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
-
- const config = { appName: 'test', walletPicker: createAutoPicker() }
- const { result } = renderHook(() => ({ connect: useConnect(), party: useParty() }), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect.connect()
- })
-
- expect(result.current.party.party?.partyId).toBe('alice::1220ab')
-
- act(() => {
- wallet.push('accountsChanged', [
- {
- partyId: 'bob::9931cd',
- primary: true,
- hint: 'bob',
- publicKey: 'pub-bob',
- networkId: 'canton:local',
- },
- ])
- })
-
- await waitFor(() => expect(result.current.party.party?.partyId).toBe('bob::9931cd'))
- expect(result.current.party.party?.name).toBe('bob')
-
- wallet.dispose()
- })
-
- it('flips useWalletStatus().isLocked when a statusChanged push reports the wallet disconnected', async () => {
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
-
- const config = { appName: 'test', walletPicker: createAutoPicker() }
- const { result } = renderHook(() => ({ connect: useConnect(), status: useWalletStatus() }), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect.connect()
- })
-
- expect(result.current.status.isLocked).toBe(false)
-
- act(() => {
- wallet.push('statusChanged', {
- provider: { id: 'wallet-a', providerType: 'browser' },
- // Network stays up; only the wallet locks — proves the handler keys on isConnected alone.
- connection: { isConnected: false, isNetworkConnected: true },
- })
- })
-
- await waitFor(() => expect(result.current.status.isLocked).toBe(true))
-
- wallet.dispose()
- })
-
- it('advances useExecute().lastTx through a pending then executed txChanged push', async () => {
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
-
- const config = { appName: 'test', walletPicker: createAutoPicker() }
- const { result } = renderHook(() => ({ connect: useConnect(), execute: useExecute() }), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect.connect()
- })
-
- act(() => {
- wallet.push('txChanged', { status: 'pending', commandId: 'cmd-1' })
- })
-
- await waitFor(() => expect(result.current.execute.lastTx?.status).toBe('pending'))
- expect(result.current.execute.lastTx?.payload).toBe(undefined)
-
- act(() => {
- wallet.push('txChanged', {
- status: 'executed',
- commandId: 'cmd-1',
- payload: { updateId: 'update-1', completionOffset: 42 },
- })
- })
-
- await waitFor(() => expect(result.current.execute.lastTx?.status).toBe('executed'))
- expect(result.current.execute.lastTx?.payload).toEqual({
- updateId: 'update-1',
- completionOffset: 42,
- })
-
- wallet.dispose()
- })
-
- it('stops applying pushed events to the hooks after disconnect()', async () => {
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
-
- const config = { appName: 'test', walletPicker: createAutoPicker() }
- const { result } = renderHook(() => ({ connect: useConnect(), party: useParty() }), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect.connect()
- })
-
- expect(result.current.party.party?.partyId).toBe('alice::1220ab')
-
- await act(async () => {
- await result.current.connect.disconnect()
- })
-
- expect(result.current.party.party).toBe(undefined)
-
- act(() => {
- wallet.push('accountsChanged', [
- {
- partyId: 'carol::deadbeef',
- primary: true,
- hint: 'carol',
- publicKey: 'pub-carol',
- networkId: 'canton:local',
- },
- ])
- })
-
- // waitFor exhausts its retry window observing the change; rejecting proves it never arrived.
- await expect(
- waitFor(() => expect(result.current.party.party?.partyId).toBe('carol::deadbeef')),
- ).rejects.toThrow()
-
- expect(result.current.party.party).toBe(undefined)
-
- wallet.dispose()
- })
-
- it('tears down listeners when the provider unmounts', async () => {
- const wallet = createFakeWallet({
- id: 'wallet-a',
- target: 'wallet-a',
- accounts: [{ partyId: 'alice::1220ab', primary: true }],
- })
-
- const config = { appName: 'test', walletPicker: createAutoPicker() }
- const { result, unmount } = renderHook(() => useCantonConnectContext(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect()
- })
-
- const removeSpy = vi.spyOn(result.current.sdk, 'removeOnAccountsChanged')
-
- unmount()
-
- expect(removeSpy).toHaveBeenCalledTimes(1)
-
- wallet.dispose()
- })
-
- it('useSignMessage throws its not-connected guard before connecting', async () => {
- const config = { appName: 'test' }
- const { result } = renderHook(() => useSignMessage(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await expect(result.current.signMessage('hello')).rejects.toThrow(
- 'wallet is not connected — call useConnect().connect() first',
- )
- })
-
- it('useLedger throws its not-connected guard before connecting', async () => {
- const config = { appName: 'test' }
- const { result } = renderHook(() => useLedger(), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await expect(
- result.current.ledgerApi({ requestMethod: 'get', resource: '/v2/parties' }),
- ).rejects.toThrow('wallet is not connected — call useConnect().connect() first')
- })
-
- it('sets useExecute().error on a failing execute and clears it on reset()', async () => {
- const mock = createMockAdapter({
- id: 'mock-execute',
- accounts: [{ partyId: 'alice::mock1220' }],
- })
-
- const config = {
- appName: 'test',
- additionalAdapters: [mock],
- walletPicker: createAutoPicker('mock-execute'),
- }
- const { result } = renderHook(() => ({ connect: useConnect(), execute: useExecute() }), {
- wrapper: ({ children }) => (
- {children}
- ),
- })
-
- await act(async () => {
- await result.current.connect.connect()
- })
-
- // The mock only answers the connect flow — prepareExecuteAndWait throws naming itself.
- await act(async () => {
- await expect(result.current.execute.execute({ commands: [] })).rejects.toThrow(
- "mock adapter does not implement 'prepareExecuteAndWait'",
- )
- })
-
- expect(result.current.execute.error?.message).toBe(
- "mock adapter does not implement 'prepareExecuteAndWait'",
- )
-
- act(() => {
- result.current.execute.reset()
- })
-
- expect(result.current.execute.error).toBe(undefined)
- })
-})
diff --git a/canton-connect/src/CantonConnectProvider.tsx b/canton-connect/src/CantonConnectProvider.tsx
deleted file mode 100644
index dcd9ed08..00000000
--- a/canton-connect/src/CantonConnectProvider.tsx
+++ /dev/null
@@ -1,343 +0,0 @@
-// CantonConnectProvider owns the wallet connection lifecycle and exposes it
-// through React context. Hooks (useConnect, useParty, useSignMessage, etc.)
-// are thin readers that subscribe to this context.
-
-import type {
- AccountsChangedEvent,
- ProviderAdapter,
- StatusEvent,
- TxChangedEvent,
-} from '@canton-network/dapp-sdk'
-// dapp-sdk imports @walletconnect/sign-client statically, 1.5.1 included, so it is installed even
-// with no walletConnectProjectId set; see architecture.md before trusting its optional peer marker.
-import { DappSDK, WalletConnectAdapter } from '@canton-network/dapp-sdk'
-import {
- createContext,
- type JSX,
- type ReactNode,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react'
-import { PickerClosedError, toConnectError } from '#src/connectError'
-import { guardedConnect } from '#src/guardedConnect'
-import type { CantonConnectConfig, ConnectionStatus, Party } from '#src/types'
-import { selectPrimaryAccount, toParty } from '#src/walletAccount'
-
-/**
- * Mirrored from the SDK's `txChanged` event as a command moves through
- * pending, signed, executed or failed.
- *
- * @category Types
- */
-export interface TxStatusSnapshot {
- status: TxChangedEvent['status']
- commandId: TxChangedEvent['commandId']
- payload?: unknown
-}
-
-/**
- * Every slice of the session at once, plus the `DappSDK` the provider holds, whose identity churns
- * as sessions are retired — read it, never cache it. Prefer the narrower hooks — `useConnect`,
- * `useParty`, `useWalletStatus`, `useExecute`, `useSignMessage`, `useLedger` — one slice each.
- *
- * @category Hooks
- */
-export interface CantonConnectContextValue {
- config: CantonConnectConfig
- sdk: DappSDK
- party: Party | undefined
- status: ConnectionStatus
- isLocked: boolean
- connectError: Error | undefined
- isConnecting: boolean
- lastTx: TxStatusSnapshot | undefined
- connect: () => Promise
- disconnect: () => Promise
-}
-
-// Exported for src/testing's session double only; consumers reach it through the hooks.
-export const CantonConnectContext = createContext(undefined)
-
-/**
- * The whole context in one read, and the escape hatch behind every other hook here. Reach for a
- * narrower hook unless a component needs several slices at once, since this one re-renders on any
- * change to any of them.
- *
- * @throws with no {@link CantonConnectProvider} above it.
- *
- * @example
- * const { sdk, config } = useCantonConnectContext()
- * await sdk.listAccounts()
- *
- * @category Hooks
- */
-export const useCantonConnectContext = (): CantonConnectContextValue => {
- const ctx = useContext(CantonConnectContext)
- if (ctx === undefined) {
- throw new Error('canton-connect hooks must be used inside a ')
- }
- return ctx
-}
-
-/**
- * Props for {@link CantonConnectProvider}. `config` is read field by field, so hoist or memoise
- * `walletPicker` and `additionalAdapters`: a fresh one of either rebuilds the `DappSDK` or its
- * adapters and re-runs discovery on every render.
- *
- * @example
- * {children}
- *
- * @category Components
- */
-export interface CantonConnectProviderProps {
- config: CantonConnectConfig
- children: ReactNode
-}
-
-type AdapterConfig = Pick<
- CantonConnectConfig,
- 'appName' | 'appDescription' | 'appUrl' | 'walletConnectProjectId' | 'additionalAdapters'
->
-
-const buildAdditionalAdapters = (config: AdapterConfig, networkId: string): ProviderAdapter[] => {
- const adapters: ProviderAdapter[] = [...(config.additionalAdapters ?? [])]
-
- if (config.walletConnectProjectId !== undefined && config.walletConnectProjectId !== '') {
- adapters.push(
- WalletConnectAdapter.create({
- projectId: config.walletConnectProjectId,
- // The CAIP-2 chain the wallet must serve is the configured Canton network id, not the
- // SDK's devnet default.
- chainId: networkId,
- metadata: {
- name: config.appName,
- description: config.appDescription ?? config.appName,
- url: config.appUrl ?? (typeof window === 'undefined' ? '' : window.location.origin),
- icons: [],
- },
- }),
- )
- }
-
- return adapters
-}
-
-/**
- * Owns the connection lifecycle: creates the `DappSDK` from `config`, restores a previous session
- * on mount without opening the picker, and wires wallet-pushed events into the state hooks read.
- * With no `walletPicker` configured it also guards the SDK's own popup, so a close the SDK misses
- * settles the connect and retires the SDK under it. Those hooks mirror wagmi's naming, not its
- * TanStack Query result shapes, and every one throws with no provider above it, there being no
- * ambient session to fall back to. Renders no DOM of its own.
- *
- * @example
- *
- *
- *
- *
- * @category Components
- */
-export const CantonConnectProvider = ({
- config,
- children,
-}: CantonConnectProviderProps): JSX.Element => {
- const [status, setStatus] = useState('idle')
- const [party, setParty] = useState(undefined)
- const [isLocked, setIsLocked] = useState(false)
- const [lastTx, setLastTx] = useState(undefined)
- const [connectError, setConnectError] = useState(undefined)
-
- const networkId = config.networkId ?? 'canton:local'
-
- // The SDK owns the picker surface unless the consumer supplied one.
- const guardPicker = config.walletPicker === undefined
-
- const [retirements, setRetirements] = useState(0)
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: retirements rebuilds, it is not read
- const sdk = useMemo(
- () => new DappSDK({ walletPicker: config.walletPicker }),
- [config.walletPicker, retirements],
- )
-
- const additionalAdapters = useMemo(
- () =>
- buildAdditionalAdapters(
- {
- appName: config.appName,
- appDescription: config.appDescription,
- appUrl: config.appUrl,
- walletConnectProjectId: config.walletConnectProjectId,
- additionalAdapters: config.additionalAdapters,
- },
- networkId,
- ),
- [
- config.appName,
- config.appDescription,
- config.appUrl,
- config.walletConnectProjectId,
- config.additionalAdapters,
- networkId,
- ],
- )
-
- // A client must exist before wiring; teardownRef shares that wiring between mount-restore and
- // connect().
- const teardownRef = useRef<(() => void) | undefined>(undefined)
-
- const wireEvents = useCallback((): (() => void) => {
- const onAccounts = (accounts: AccountsChangedEvent): void => {
- const primary = selectPrimaryAccount(accounts)
- setParty(primary === undefined ? undefined : toParty(primary, networkId))
- }
- const onStatus = (event: StatusEvent): void => {
- setIsLocked(!event.connection.isConnected)
- }
- const onTx = (event: TxChangedEvent): void => {
- setLastTx({
- status: event.status,
- commandId: event.commandId,
- payload: 'payload' in event ? event.payload : undefined,
- })
- }
-
- void sdk.onAccountsChanged(onAccounts).catch(() => undefined)
- void sdk.onStatusChanged(onStatus).catch(() => undefined)
- void sdk.onTxChanged(onTx).catch(() => undefined)
-
- return () => {
- void sdk.removeOnAccountsChanged(onAccounts).catch(() => undefined)
- void sdk.removeOnStatusChanged(onStatus).catch(() => undefined)
- void sdk.removeOnTxChanged(onTx).catch(() => undefined)
- }
- }, [sdk, networkId])
-
- // Shared by mount-restore and a failed connect() that left a live client behind: wires events
- // unless already wired, then syncs isLocked, status and party from one status() read.
- const syncFromStatus = useCallback(
- async (restored: StatusEvent): Promise => {
- // Wire events regardless of lock state so a later unlock push isn't dropped silently.
- if (teardownRef.current === undefined) {
- teardownRef.current = wireEvents()
- }
-
- setIsLocked(!restored.connection.isConnected)
- setStatus('connected')
-
- if (!restored.connection.isConnected) {
- setParty(undefined) // locked — wait for the unlock push
- return
- }
-
- const accounts = await sdk.listAccounts()
- const primary = selectPrimaryAccount(accounts)
- setParty(primary === undefined ? undefined : toParty(primary, networkId))
- },
- [sdk, networkId, wireEvents],
- )
-
- useEffect(() => {
- let cancelled = false
-
- // defaultAdapters: [] keeps the SDK's bundled localhost dev gateway out of the picker.
- void sdk.init({ additionalAdapters, defaultAdapters: [] }).then(async () => {
- if (cancelled) return
-
- // status() throws when there's nothing to restore — that's normal, not an error.
- const restored = await sdk.status().catch(() => undefined)
- if (cancelled || restored === undefined) return
-
- await syncFromStatus(restored)
- })
-
- return () => {
- cancelled = true
- teardownRef.current?.()
- teardownRef.current = undefined
- }
- }, [sdk, additionalAdapters, syncFromStatus])
-
- const connect = useCallback(async (): Promise => {
- setStatus('connecting')
- setConnectError(undefined)
-
- // Remove listeners from the current client before connect() swaps in a new one.
- teardownRef.current?.()
- teardownRef.current = undefined
-
- try {
- const result = await (guardPicker ? guardedConnect(sdk) : sdk.connect())
- if (!result.isConnected) {
- throw new Error(result.reason ?? 'Wallet did not connect')
- }
-
- teardownRef.current = wireEvents()
-
- const accounts = await sdk.listAccounts()
- const primary = selectPrimaryAccount(accounts)
- setParty(primary === undefined ? undefined : toParty(primary, networkId))
-
- // connect() only resolves for an unlocked wallet, so clear any lock a restored session left.
- setIsLocked(false)
- setStatus('connected')
- } catch (err) {
- const error = toConnectError(err)
- setConnectError(error)
-
- // A cancelled picker fails before the SDK swaps its client, so probe rather than assume a
- // previous session is gone.
- const restored = await sdk.status().catch(() => undefined)
-
- if (restored === undefined) {
- setParty(undefined)
- setIsLocked(false)
- setStatus('disconnected')
- } else {
- await syncFromStatus(restored)
- }
-
- // The connect we walked out on still listens for a picker result and would swap this SDK's
- // client from under the wiring above; the mount effect re-restores the session.
- if (err instanceof PickerClosedError) {
- setRetirements((count) => count + 1)
- }
-
- throw error
- }
- }, [sdk, guardPicker, networkId, wireEvents, syncFromStatus])
-
- const disconnect = useCallback(async (): Promise => {
- teardownRef.current?.()
- teardownRef.current = undefined
-
- await sdk.disconnect().catch(() => undefined)
-
- setParty(undefined)
- setStatus('disconnected')
- setIsLocked(false)
- setLastTx(undefined)
- }, [sdk])
-
- const value = useMemo(
- () => ({
- config,
- sdk,
- party,
- status,
- isLocked,
- connectError,
- isConnecting: status === 'connecting',
- lastTx,
- connect,
- disconnect,
- }),
- [config, sdk, party, status, isLocked, connectError, lastTx, connect, disconnect],
- )
-
- return {children}
-}
diff --git a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.connect.test.tsx b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.connect.test.tsx
new file mode 100644
index 00000000..96401958
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.connect.test.tsx
@@ -0,0 +1,153 @@
+// The connect flow: picker to adapter to connected state, and how a refusal surfaces.
+
+import { act, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useConnect } from '#src/hooks/useConnect'
+import { createMockAdapter } from '#src/mock/mockAdapter'
+import { createAutoPicker } from '#src/testing/autoPicker'
+import { clearDiscoveryStorage } from '#src/testing/discoveryStorage'
+import { renderSession } from '#src/testing/renderSession'
+import { throwingPicker } from '#src/testing/throwingPicker'
+import { useSession } from '#src/testing/useSession'
+import { walletA } from '#src/testing/walletA'
+
+describe('CantonConnectProvider connect flow', () => {
+ afterEach(() => {
+ clearDiscoveryStorage()
+ vi.restoreAllMocks()
+ })
+
+ it('connects the announced wallet the picker selects', async () => {
+ const wallet = walletA()
+
+ const { result } = renderSession(() => useSession())
+
+ await act(async () => {
+ await result.current.connect()
+ })
+
+ // connect() resolves when the session lands; the accounts read follows it.
+ await waitFor(() => expect(result.current.party?.partyId).toBe('alice::1220ab'))
+ expect(result.current.status).toBe('connected')
+
+ wallet.dispose()
+ })
+
+ it('connects through the mock adapter with no real wallet installed', async () => {
+ const mock = createMockAdapter({ id: 'mock-test', accounts: [{ partyId: 'alice::mock1220' }] })
+
+ const { result } = renderSession(() => useSession(), {
+ additionalAdapters: [mock],
+ // Selecting by id, not ordering — a real announced wallet could also be in the entries.
+ walletPicker: createAutoPicker('mock-test'),
+ })
+
+ await act(async () => {
+ await result.current.connect()
+ })
+
+ expect(result.current.party?.partyId).toBe('alice::mock1220')
+ expect(result.current.status).toBe('connected')
+ })
+
+ it('mock party keeps its own networkId even when it disagrees with the config', async () => {
+ const mock = createMockAdapter({
+ id: 'mock-devnet',
+ networkId: 'canton:devnet',
+ accounts: [{ partyId: 'alice::mock1220' }],
+ })
+
+ const { result } = renderSession(() => useSession(), {
+ networkId: 'canton:testnet',
+ additionalAdapters: [mock],
+ walletPicker: createAutoPicker('mock-devnet'),
+ })
+
+ await act(async () => {
+ await result.current.connect()
+ })
+
+ expect(result.current.party?.networkId).toBe('canton:devnet')
+ })
+
+ it('sets connectError and rejects connect() when the picker throws', async () => {
+ const { result } = renderSession(() => useSession(), { walletPicker: throwingPicker })
+
+ // What the message says is toConnectError's classification, owned by connectError.test.ts;
+ // here the claim is only that the same thrown value reaches the consumer.
+ const rejection = await act(() => result.current.connect().catch((error: unknown) => error))
+
+ expect(rejection).toBeInstanceOf(Error)
+ expect(result.current.connectError).toBe(rejection)
+ expect(result.current.status).toBe('disconnected')
+ })
+
+ it('clears connectError on disconnect', async () => {
+ const { result } = renderSession(() => useSession(), { walletPicker: throwingPicker })
+
+ await act(async () => {
+ await expect(result.current.connect()).rejects.toThrow('cancel')
+ })
+
+ expect(result.current.connectError?.message).toBe('cancel')
+
+ await act(async () => {
+ await result.current.disconnect()
+ })
+
+ expect(result.current.connectError).toBeUndefined()
+ })
+
+ it('reset() forgets connectError without disconnecting', async () => {
+ const { result } = renderSession(() => useConnect(), { walletPicker: throwingPicker })
+
+ await act(async () => {
+ await expect(result.current.connect()).rejects.toThrow('cancel')
+ })
+
+ expect(result.current.connectError?.message).toBe('cancel')
+
+ act(() => {
+ result.current.reset()
+ })
+
+ expect(result.current.connectError).toBeUndefined()
+
+ // still connectable: reset cleared a message, not the machine
+ await act(async () => {
+ await expect(result.current.connect()).rejects.toThrow('cancel')
+ })
+
+ expect(result.current.connectError?.message).toBe('cancel')
+ })
+
+ // The pair below is the whole observable difference between the two picker configurations: the
+ // window.open borrow guardPicker turns on cannot be seen, because jsdom's window.open is an
+ // accessor and a spy on it survives the assignment.
+ it('runs the consumer picker and opens no popup of its own', async () => {
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
+ const picker = vi.fn(throwingPicker)
+
+ const { result } = renderSession(() => useSession(), { walletPicker: picker })
+
+ await act(async () => {
+ await expect(result.current.connect()).rejects.toThrow('cancel')
+ })
+
+ expect(picker).toHaveBeenCalledTimes(1)
+ expect(openSpy).not.toHaveBeenCalled()
+ })
+
+ it('opens the SDK picker popup when the consumer configures none', async () => {
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
+
+ const { result } = renderSession(() => useSession(), { walletPicker: undefined })
+
+ // A popup handle of null is what the SDK's own picker refuses on, which is how this settles.
+ await act(async () => {
+ await expect(result.current.connect()).rejects.toThrow()
+ })
+
+ expect(openSpy).toHaveBeenCalled()
+ })
+})
diff --git a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.events.test.tsx b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.events.test.tsx
new file mode 100644
index 00000000..d2b3ae50
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.events.test.tsx
@@ -0,0 +1,161 @@
+// Wallet pushes reaching the hooks, and the two moments they must stop: disconnect and unmount.
+
+import { act, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useConnect } from '#src/hooks/useConnect'
+import { useExecute } from '#src/hooks/useExecute'
+import { useParty } from '#src/hooks/useParty'
+import { clearDiscoveryStorage } from '#src/testing/discoveryStorage'
+import { renderSession } from '#src/testing/renderSession'
+import { useSession } from '#src/testing/useSession'
+import { walletA } from '#src/testing/walletA'
+
+describe('CantonConnectProvider wallet pushes', () => {
+ afterEach(() => {
+ clearDiscoveryStorage()
+ vi.restoreAllMocks()
+ })
+
+ it('delivers a pushed accountsChanged event to useParty()', async () => {
+ const wallet = walletA()
+
+ const { result } = renderSession(() => ({ connect: useConnect(), party: useParty() }))
+
+ await act(async () => {
+ await result.current.connect.connect()
+ })
+
+ await waitFor(() => expect(result.current.party.party?.partyId).toBe('alice::1220ab'))
+
+ act(() => {
+ wallet.push('accountsChanged', [
+ {
+ partyId: 'bob::9931cd',
+ primary: true,
+ hint: 'bob',
+ publicKey: 'pub-bob',
+ networkId: 'canton:local',
+ },
+ ])
+ })
+
+ await waitFor(() => expect(result.current.party.party?.partyId).toBe('bob::9931cd'))
+ expect(result.current.party.party?.name).toBe('bob')
+
+ wallet.dispose()
+ })
+
+ it('advances useExecute().lastTx through a pending then executed txChanged push', async () => {
+ const wallet = walletA()
+
+ const { result } = renderSession(() => ({ connect: useConnect(), execute: useExecute() }))
+
+ await act(async () => {
+ await result.current.connect.connect()
+ })
+
+ act(() => {
+ wallet.push('txChanged', { status: 'pending', commandId: 'cmd-1' })
+ })
+
+ await waitFor(() => expect(result.current.execute.lastTx?.status).toBe('pending'))
+ expect(result.current.execute.lastTx?.payload).toBe(undefined)
+
+ act(() => {
+ wallet.push('txChanged', {
+ status: 'executed',
+ commandId: 'cmd-1',
+ payload: { updateId: 'update-1', completionOffset: 42 },
+ })
+ })
+
+ await waitFor(() => expect(result.current.execute.lastTx?.status).toBe('executed'))
+ expect(result.current.execute.lastTx?.payload).toEqual({
+ updateId: 'update-1',
+ completionOffset: 42,
+ })
+
+ wallet.dispose()
+ })
+
+ it('stops applying pushed events to the hooks after disconnect()', async () => {
+ const wallet = walletA()
+
+ const { result } = renderSession(() => ({
+ connect: useConnect(),
+ party: useParty(),
+ execute: useExecute(),
+ }))
+
+ await act(async () => {
+ await result.current.connect.connect()
+ })
+
+ await waitFor(() => expect(result.current.party.party?.partyId).toBe('alice::1220ab'))
+
+ act(() => {
+ wallet.push('txChanged', { status: 'pending', commandId: 'cmd-1' })
+ })
+
+ await waitFor(() => expect(result.current.execute.lastTx?.status).toBe('pending'))
+
+ await act(async () => {
+ await result.current.connect.disconnect()
+ })
+
+ expect(result.current.party.party).toBe(undefined)
+ expect(result.current.execute.lastTx).toBe(undefined)
+
+ act(() => {
+ wallet.push('accountsChanged', [
+ {
+ partyId: 'carol::deadbeef',
+ primary: true,
+ hint: 'carol',
+ publicKey: 'pub-carol',
+ networkId: 'canton:local',
+ },
+ ])
+ wallet.push('txChanged', { status: 'executed', commandId: 'cmd-2' })
+ })
+
+ // waitFor exhausts its retry window trying to observe the change; rejecting proves it never
+ // arrived.
+ await expect(
+ waitFor(() => expect(result.current.party.party?.partyId).toBe('carol::deadbeef')),
+ ).rejects.toThrow()
+
+ expect(result.current.party.party).toBe(undefined)
+ // The tx push had the same retry window to arrive in.
+ expect(result.current.execute.lastTx).toBe(undefined)
+
+ wallet.dispose()
+ })
+
+ it('tears down listeners when the provider unmounts', async () => {
+ const wallet = walletA()
+
+ // useExecute rides along for the tx listener; it is the only hook that registers one.
+ const { result, unmount } = renderSession(() => ({
+ session: useSession(),
+ execute: useExecute(),
+ }))
+
+ await act(async () => {
+ await result.current.session.connect()
+ })
+
+ const { sdk } = result.current.session
+ const removeAccounts = vi.spyOn(sdk, 'removeOnAccountsChanged')
+ const removeStatus = vi.spyOn(sdk, 'removeOnStatusChanged')
+ const removeTx = vi.spyOn(sdk, 'removeOnTxChanged')
+
+ unmount()
+
+ expect(removeAccounts).toHaveBeenCalledTimes(1)
+ expect(removeStatus).toHaveBeenCalledTimes(1)
+ expect(removeTx).toHaveBeenCalledTimes(1)
+
+ wallet.dispose()
+ })
+})
diff --git a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.guards.test.tsx b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.guards.test.tsx
new file mode 100644
index 00000000..25ab40fd
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.guards.test.tsx
@@ -0,0 +1,153 @@
+// The action hooks' guards: not connected, locked, and useExecute's own error surface.
+
+import { act, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { useConnect } from '#src/hooks/useConnect'
+import { useExecute } from '#src/hooks/useExecute'
+import { useLedger } from '#src/hooks/useLedger'
+import { useSignMessage } from '#src/hooks/useSignMessage'
+import { useWalletStatus } from '#src/hooks/useWalletStatus'
+import { createMockAdapter } from '#src/mock/mockAdapter'
+import { createAutoPicker } from '#src/testing/autoPicker'
+import { clearDiscoveryStorage, persistRestorableSession } from '#src/testing/discoveryStorage'
+import { renderSession } from '#src/testing/renderSession'
+import { walletA } from '#src/testing/walletA'
+import { pushLock } from '#src/testing/walletLock'
+
+const NOT_CONNECTED_MESSAGE = 'wallet is not connected - call useConnect().connect() first'
+const LOCKED_MESSAGE = 'wallet is locked - unlock it in the wallet'
+
+describe('CantonConnectProvider hook guards', () => {
+ afterEach(() => {
+ clearDiscoveryStorage()
+ })
+
+ it('useSignMessage throws its not-connected guard before connecting', async () => {
+ const { result } = renderSession(() => useSignMessage())
+
+ await expect(result.current.signMessage('hello')).rejects.toThrow(NOT_CONNECTED_MESSAGE)
+ })
+
+ it('useLedger throws its not-connected guard before connecting', async () => {
+ const { result } = renderSession(() => useLedger())
+
+ await expect(
+ result.current.ledgerApi({ requestMethod: 'get', resource: '/v2/parties' }),
+ ).rejects.toThrow(NOT_CONNECTED_MESSAGE)
+ })
+
+ it('useExecute throws its non-connected guard before connecting', async () => {
+ const { result } = renderSession(() => useExecute())
+
+ await expect(result.current.execute({ commands: [] })).rejects.toThrow(NOT_CONNECTED_MESSAGE)
+ })
+
+ it('sets useExecute().error on a failing execute and clears it on reset()', async () => {
+ const mock = createMockAdapter({
+ id: 'mock-execute',
+ accounts: [{ partyId: 'alice::mock1220' }],
+ })
+
+ const { result } = renderSession(() => ({ connect: useConnect(), execute: useExecute() }), {
+ additionalAdapters: [mock],
+ walletPicker: createAutoPicker('mock-execute'),
+ })
+
+ await act(async () => {
+ await result.current.connect.connect()
+ })
+
+ // The mock only answers the connect flow — prepareExecuteAndWait throws naming itself.
+ await act(async () => {
+ await expect(result.current.execute.execute({ commands: [] })).rejects.toThrow(
+ "mock adapter does not implement 'prepareExecuteAndWait'",
+ )
+ })
+
+ expect(result.current.execute.error?.message).toBe(
+ "mock adapter does not implement 'prepareExecuteAndWait'",
+ )
+
+ act(() => {
+ result.current.execute.reset()
+ })
+
+ expect(result.current.execute.error).toBe(undefined)
+ })
+
+ it('rejects execute while the wallet is locked', async () => {
+ persistRestorableSession('browser:ext:wallet-a')
+
+ const wallet = walletA()
+
+ const { result } = renderSession(() => ({ status: useWalletStatus(), execute: useExecute() }))
+
+ // A status() read cannot report a locked session: the wallet gates `isConnected` and the
+ // presence of `session` on the same lookup. A lock only ever arrives as a push.
+ await waitFor(() => expect(result.current.status.isConnected).toBe(true))
+
+ act(() => {
+ pushLock(wallet)
+ })
+
+ await waitFor(() => expect(result.current.status.isLocked).toBe(true))
+
+ await act(async () => {
+ await expect(result.current.execute.execute({ commands: [] })).rejects.toThrow(LOCKED_MESSAGE)
+ })
+
+ wallet.dispose()
+ })
+
+ it('rejects signMessage while the wallet is locked', async () => {
+ persistRestorableSession('browser:ext:wallet-a')
+
+ const wallet = walletA()
+
+ const { result } = renderSession(() => ({ status: useWalletStatus(), sign: useSignMessage() }))
+
+ // A status() read cannot report a locked session: the wallet gates `isConnected` and the
+ // presence of `session` on the same lookup. A lock only ever arrives as a push.
+ await waitFor(() => expect(result.current.status.isConnected).toBe(true))
+
+ act(() => {
+ pushLock(wallet)
+ })
+
+ await waitFor(() => expect(result.current.status.isLocked).toBe(true))
+
+ await act(async () => {
+ await expect(result.current.sign.signMessage('hello')).rejects.toThrow(LOCKED_MESSAGE)
+ })
+
+ wallet.dispose()
+ })
+
+ it('rejects ledgerApi and reports not ready while the wallet is locked', async () => {
+ persistRestorableSession('browser:ext:wallet-a')
+
+ const wallet = walletA()
+
+ const { result } = renderSession(() => ({ status: useWalletStatus(), ledger: useLedger() }))
+
+ // A status() read cannot report a locked session: the wallet gates `isConnected` and the
+ // presence of `session` on the same lookup. A lock only ever arrives as a push.
+ await waitFor(() => expect(result.current.status.isConnected).toBe(true))
+
+ act(() => {
+ pushLock(wallet)
+ })
+
+ await waitFor(() => expect(result.current.status.isLocked).toBe(true))
+
+ expect(result.current.ledger.isReady).toBe(false)
+
+ await act(async () => {
+ await expect(
+ result.current.ledger.ledgerApi({ requestMethod: 'get', resource: '/v2/parties' }),
+ ).rejects.toThrow(LOCKED_MESSAGE)
+ })
+
+ wallet.dispose()
+ })
+})
diff --git a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.restore.test.tsx b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.restore.test.tsx
new file mode 100644
index 00000000..6add13dc
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.restore.test.tsx
@@ -0,0 +1,197 @@
+// Restored sessions: what a mount-restore wires, that connect() over a standing session reaches
+// the wallet as a wallet change, and what survives a failed or abandoned connect around one.
+
+import { act, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ConnectCancelledError } from '#src/connectError'
+import { useConnect } from '#src/hooks/useConnect'
+import { useParty } from '#src/hooks/useParty'
+import { clearDiscoveryStorage, persistRestorableSession } from '#src/testing/discoveryStorage'
+import { createFakeWallet } from '#src/testing/fakeWallet'
+import { renderSession } from '#src/testing/renderSession'
+import { type StubPopup, stubOpen, stubPopup } from '#src/testing/stubPopup'
+import { throwingPicker } from '#src/testing/throwingPicker'
+import { useSession } from '#src/testing/useSession'
+import { walletA } from '#src/testing/walletA'
+import { pushLock, pushUnlock } from '#src/testing/walletLock'
+import type { WalletSdk } from '#src/types'
+
+let restoreOpen: (() => void) | undefined
+
+// Drives a connect to the point a close strands it: the window only gets a URL once the SDK opened
+// it.
+const strandOnClosedPicker = async (
+ result: { current: { sdk: WalletSdk; connect: () => Promise } },
+ popup: StubPopup,
+): Promise => {
+ const stranded = result.current.sdk
+
+ await act(async () => {
+ const connecting = expect(result.current.connect()).rejects.toBeInstanceOf(
+ ConnectCancelledError,
+ )
+ await waitFor(() => expect(popup.location.href).not.toBe(''))
+ popup.closed = true
+ await connecting
+ })
+
+ await waitFor(() => expect(result.current.sdk).not.toBe(stranded))
+ return stranded
+}
+
+describe('CantonConnectProvider restored sessions', () => {
+ afterEach(() => {
+ clearDiscoveryStorage()
+
+ // A prototype spy survives a failed assertion; restoring here keeps it out of later tests.
+ vi.restoreAllMocks()
+ restoreOpen?.()
+ restoreOpen = undefined
+ })
+
+ it('keeps listening through a lock and recovers when the wallet unlocks', async () => {
+ persistRestorableSession('browser:ext:wallet-a')
+
+ const wallet = walletA()
+
+ const { result } = renderSession(() => useSession())
+
+ await waitFor(() => expect(result.current.party?.partyId).toBe('alice::1220ab'))
+
+ // A status() read cannot report a locked session: the wallet gates `isConnected` and the
+ // presence of `session` on the same lookup. A lock only ever arrives as a push.
+ act(() => {
+ pushLock(wallet)
+ })
+
+ await waitFor(() => expect(result.current.isLocked).toBe(true))
+ expect(result.current.status).toBe('connected')
+ // A wallet that will not serve requests has no party to offer, and this push cannot be told
+ // apart from a wallet-side disconnect.
+ expect(result.current.party).toBeUndefined()
+
+ act(() => {
+ pushUnlock(wallet)
+ })
+
+ await waitFor(() => expect(result.current.isLocked).toBe(false))
+ // The session outlived the lock, so the unlock push is heard and the party is read again
+ // without the user reconnecting.
+ await waitFor(() => expect(result.current.party?.partyId).toBe('alice::1220ab'))
+
+ wallet.dispose()
+ })
+
+ it('recovers a locked session when connect() is called, without waiting for an unlock', async () => {
+ persistRestorableSession('browser:ext:wallet-a')
+
+ const wallet = walletA()
+
+ const { result } = renderSession(() => useSession())
+
+ await waitFor(() => expect(result.current.status).toBe('connected'))
+
+ act(() => {
+ pushLock(wallet)
+ })
+
+ await waitFor(() => expect(result.current.isLocked).toBe(true))
+
+ const connectSpy = vi.spyOn(result.current.sdk, 'connect')
+
+ await act(async () => {
+ await result.current.connect()
+ })
+
+ // The wallet was asked and answered authenticated, so the party is back with no unlock push.
+ expect(connectSpy).toHaveBeenCalled()
+ await waitFor(() => expect(result.current.isLocked).toBe(false))
+ await waitFor(() => expect(result.current.party?.partyId).toBe('alice::1220ab'))
+
+ wallet.dispose()
+ })
+
+ it('answers a connect over a restored session with the session it lands', async () => {
+ persistRestorableSession('browser:ext:wallet-a')
+
+ const wallet = walletA()
+
+ const { result } = renderSession(() => useSession())
+
+ await waitFor(() => expect(result.current.party?.partyId).toBe('alice::1220ab'))
+
+ const connectSpy = vi.spyOn(result.current.sdk, 'connect')
+
+ await act(async () => {
+ await result.current.connect()
+ })
+
+ // The wallet change went to the wallet, answered with the same session: the party in hand.
+ expect(connectSpy).toHaveBeenCalled()
+ expect(result.current.status).toBe('connected')
+ await waitFor(() => expect(result.current.party?.partyId).toBe('alice::1220ab'))
+
+ wallet.dispose()
+ })
+
+ it('keeps delivering events to useParty() after a throwing picker leaves a restored session standing', async () => {
+ persistRestorableSession('browser:ext:wallet-a')
+
+ // Restore's internal check, our own restore check, and the post-failure probe all see the same
+ // still-live, connected client.
+ const wallet = createFakeWallet({
+ id: 'wallet-a',
+ target: 'wallet-a',
+ statusResponses: [true, true, true],
+ accounts: [{ partyId: 'alice::1220ab', primary: true }],
+ })
+
+ const { result } = renderSession(() => ({ connect: useConnect(), party: useParty() }), {
+ walletPicker: throwingPicker,
+ })
+
+ await waitFor(() => expect(result.current.party.party?.partyId).toBe('alice::1220ab'))
+ expect(result.current.party.status).toBe('connected')
+
+ // The attempt throws before the client is swapped, so the actor's own status read finds the
+ // session still live and hands it back: the machine lands in `session` again and connect()
+ // resolves. The failure is deliberately not surfaced (the finding-5 ruling); what matters
+ // here is that the session and its listeners come through intact.
+ await act(async () => {
+ await result.current.connect.connect()
+ })
+
+ await waitFor(() => expect(result.current.party.party?.partyId).toBe('alice::1220ab'))
+
+ act(() => {
+ wallet.push('accountsChanged', [
+ {
+ partyId: 'bob::9931cd',
+ primary: true,
+ hint: 'bob',
+ publicKey: 'pub-bob',
+ networkId: 'canton:local',
+ },
+ ])
+ })
+
+ await waitFor(() => expect(result.current.party.party?.partyId).toBe('bob::9931cd'))
+
+ wallet.dispose()
+ })
+
+ it('retires the SDK a closed picker left mid-connect', async () => {
+ const wallet = walletA()
+ const popup = stubPopup()
+ restoreOpen = stubOpen(popup)
+
+ const { result } = renderSession(() => useSession(), { walletPicker: undefined })
+
+ await strandOnClosedPicker(result, popup)
+
+ // The replacement sdk re-restores, so the settled answer arrives a tick later.
+ await waitFor(() => expect(result.current.status).toBe('disconnected'))
+
+ wallet.dispose()
+ })
+})
diff --git a/canton-connect/src/CantonConnectProvider/CantonConnectProvider.test.tsx b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.test.tsx
new file mode 100644
index 00000000..0e716e3d
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/CantonConnectProvider.test.tsx
@@ -0,0 +1,91 @@
+// Provider foundation: initial state, the context guard, config identity, picker entries.
+// Connect, restore, push, and guard behavior live in the sibling CantonConnectProvider.*.test.tsx
+// files, split so vitest can overlap their SDK timer waits.
+
+import type { WalletPickerEntry, WalletPickerFn } from '@canton-network/dapp-sdk'
+import { DappSDK } from '@canton-network/dapp-sdk'
+import { act, render, waitFor } from '@testing-library/react'
+import type { JSX } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useCantonConnectContext } from '#src/CantonConnectProvider'
+import { createAutoPicker } from '#src/testing/autoPicker'
+import { clearDiscoveryStorage } from '#src/testing/discoveryStorage'
+import { renderSession } from '#src/testing/renderSession'
+import { useSession } from '#src/testing/useSession'
+
+// Selecting the entry would start real pairing; capture what was offered and bail.
+const capturePicker =
+ (offered: WalletPickerEntry[]): WalletPickerFn =>
+ async (entries) => {
+ offered.push(...entries)
+ throw new Error('cancel')
+ }
+
+describe('CantonConnectProvider', () => {
+ afterEach(() => {
+ clearDiscoveryStorage()
+
+ // A prototype spy survives a failed assertion; restoring here keeps it out of later tests.
+ vi.restoreAllMocks()
+ })
+
+ it('initial state is idle with no party and not locked', () => {
+ const { result } = renderSession(() => useSession(), { appName: 'Test dApp' })
+
+ expect(result.current.status).toBe('idle')
+ expect(result.current.party).toBe(undefined)
+ expect(result.current.isLocked).toBe(false)
+ })
+
+ it('useCantonConnectContext throws when used outside the provider', () => {
+ const Naked = (): JSX.Element => {
+ useCantonConnectContext()
+ return
+ }
+ expect(() => render()).toThrow(/inside a /)
+ })
+
+ it('creates the connection actor once, so a rerender inits no second SDK', async () => {
+ const initSpy = vi.spyOn(DappSDK.prototype, 'init')
+
+ // Hoisted so only the wrapping config object is fresh on the rerender.
+ const walletPicker = createAutoPicker()
+
+ const { rerender } = renderSession(() => useSession(), { walletPicker })
+
+ await waitFor(() => expect(initSpy).toHaveBeenCalledTimes(1))
+
+ rerender()
+
+ expect(initSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('offers a WalletConnect entry when a project id is configured', async () => {
+ const offered: WalletPickerEntry[] = []
+
+ const { result } = renderSession(() => useSession(), {
+ walletConnectProjectId: 'test-project',
+ walletPicker: capturePicker(offered),
+ })
+
+ await act(async () => {
+ await result.current.connect().catch(() => undefined)
+ })
+
+ expect(offered).toEqual([
+ expect.objectContaining({ providerId: 'walletconnect', type: 'mobile' }),
+ ])
+ })
+
+ it('offers no WalletConnect entry without a project id', async () => {
+ const offered: WalletPickerEntry[] = []
+
+ const { result } = renderSession(() => useSession(), { walletPicker: capturePicker(offered) })
+
+ await act(async () => {
+ await result.current.connect().catch(() => undefined)
+ })
+
+ expect(offered).toEqual([])
+ })
+})
diff --git a/canton-connect/src/CantonConnectProvider/adapters.ts b/canton-connect/src/CantonConnectProvider/adapters.ts
new file mode 100644
index 00000000..c8282aba
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/adapters.ts
@@ -0,0 +1,35 @@
+import { type ProviderAdapter, WalletConnectAdapter } from '@canton-network/dapp-sdk'
+import type { CantonConnectConfig } from '#src/types'
+
+/** The config fields adapter construction reads, and no more of `CantonConnectConfig`. */
+type AdapterConfig = Pick<
+ CantonConnectConfig,
+ 'appName' | 'appDescription' | 'appUrl' | 'walletConnectProjectId' | 'additionalAdapters'
+>
+
+/** Builds the extra adapters for the SDK: WalletConnect when configured, plus any passed in. */
+export const buildAdditionalAdapters = (
+ config: AdapterConfig,
+ networkId: string,
+): ProviderAdapter[] => {
+ const adapters: ProviderAdapter[] = [...(config.additionalAdapters ?? [])]
+
+ if (config.walletConnectProjectId !== undefined && config.walletConnectProjectId !== '') {
+ adapters.push(
+ WalletConnectAdapter.create({
+ projectId: config.walletConnectProjectId,
+ // The CAIP-2 chain the wallet must serve is the configured Canton network id, not the SDK's
+ // devnet default.
+ chainId: networkId,
+ metadata: {
+ name: config.appName,
+ description: config.appDescription ?? config.appName,
+ url: config.appUrl ?? (typeof window === 'undefined' ? '' : window.location.origin),
+ icons: [],
+ },
+ }),
+ )
+ }
+
+ return adapters
+}
diff --git a/canton-connect/src/CantonConnectProvider/index.tsx b/canton-connect/src/CantonConnectProvider/index.tsx
new file mode 100644
index 00000000..90d08e89
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/index.tsx
@@ -0,0 +1,121 @@
+// CantonConnectProvider owns the wallet connection lifecycle and publishes the actor that holds
+// it. Hooks (useConnect, useParty, useSignMessage, etc.) select their own slice off that actor.
+
+import { DappSDK } from '@canton-network/dapp-sdk'
+import { createContext, type JSX, type ReactNode, useCallback, useContext, useMemo } from 'react'
+import { buildAdditionalAdapters } from '#src/CantonConnectProvider/adapters'
+import { useConnectBridge } from '#src/CantonConnectProvider/useConnectBridge'
+import { useConnectionActor } from '#src/CantonConnectProvider/useConnectionActor'
+import { useDisconnectBridge } from '#src/CantonConnectProvider/useDisconnectBridge'
+import type { CantonConnectConfig, CantonConnectContextValue } from '#src/types'
+
+// Exported for src/testing's session double only; consumers reach it through the hooks.
+export const CantonConnectContext = createContext(undefined)
+
+/**
+ * The whole context in one read, and the escape hatch behind every other hook here. Reach for a
+ * narrower hook unless a component needs several slices at once; this one hands back the config,
+ * the connection to select off, and the three actions.
+ *
+ * @throws with no {@link CantonConnectProvider} above it.
+ *
+ * @example
+ * const { config, connection } = useCantonConnectContext()
+ * const snapshot = connection.getSnapshot()
+ *
+ * @category Hooks
+ */
+export const useCantonConnectContext = (): CantonConnectContextValue => {
+ const ctx = useContext(CantonConnectContext)
+ if (ctx === undefined) {
+ throw new Error('canton-connect hooks must be used inside a ')
+ }
+ return ctx
+}
+
+/**
+ * Props for {@link CantonConnectProvider}. `config` is read once, when the connection actor is
+ * created: a `walletPicker` or `additionalAdapters` swapped later reaches `config` on the context,
+ * never the connection, which keeps the adapters it booted with. Pass the final values first.
+ *
+ * @example
+ * {children}
+ *
+ * @category Components
+ */
+export interface CantonConnectProviderProps {
+ config: CantonConnectConfig
+ children: ReactNode
+}
+
+/**
+ * Hands the connection machine what it needs to build its own `DappSDK`, and publishes the actor
+ * that goes through the states, plus the two bridges that drive it. Nothing here selects: a
+ * provider that pre-selected the whole session re-rendered every consumer on every tick of it.
+ * The hooks mirror wagmi's naming, not its TanStack Query result shapes.
+ *
+ * @example
+ *
+ *
+ *
+ *
+ * @category Components
+ */
+export const CantonConnectProvider = ({
+ config,
+ children,
+}: CantonConnectProviderProps): JSX.Element => {
+ const networkId = config.networkId ?? 'canton:local'
+
+ const additionalAdapters = useMemo(
+ () =>
+ buildAdditionalAdapters(
+ {
+ appName: config.appName,
+ appDescription: config.appDescription,
+ appUrl: config.appUrl,
+ walletConnectProjectId: config.walletConnectProjectId,
+ additionalAdapters: config.additionalAdapters,
+ },
+ networkId,
+ ),
+ [
+ config.appName,
+ config.appDescription,
+ config.appUrl,
+ config.walletConnectProjectId,
+ config.additionalAdapters,
+ networkId,
+ ],
+ )
+
+ const actorRef = useConnectionActor({
+ createSdk: () => new DappSDK({ walletPicker: config.walletPicker }),
+ initOptions: { additionalAdapters },
+ // A consumer's own picker owns its lifecycle, and guardedConnect would borrow window.open
+ // watching for a popup that never opens.
+ guardPicker: config.walletPicker === undefined,
+ networkId,
+ })
+
+ const resetConnectError = useCallback(
+ () => actorRef.send({ type: 'connectError.reset' }),
+ [actorRef],
+ )
+
+ const connect = useConnectBridge(actorRef)
+ const disconnect = useDisconnectBridge(actorRef)
+
+ const value = useMemo(
+ () => ({
+ config,
+ connection: actorRef,
+ connect,
+ disconnect,
+ resetConnectError,
+ }),
+ [config, actorRef, connect, disconnect, resetConnectError],
+ )
+
+ return {children}
+}
diff --git a/canton-connect/src/CantonConnectProvider/useConnectBridge.test.tsx b/canton-connect/src/CantonConnectProvider/useConnectBridge.test.tsx
new file mode 100644
index 00000000..f7779473
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/useConnectBridge.test.tsx
@@ -0,0 +1,136 @@
+import { act, renderHook } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { fromPromise } from 'xstate'
+import { useConnectBridge } from '#src/CantonConnectProvider/useConnectBridge'
+import type { AccountsInput } from '#src/machine/accountsActors'
+import { accountsMachine, type WalletAccounts } from '#src/machine/accountsMachine'
+import type { ConnectInput, InitInput } from '#src/machine/connectionActors'
+import { connectionMachine, type WalletStatusUpdate } from '#src/machine/connectionMachine'
+import { pause } from '#src/testing/pause'
+import { startConnection } from '#src/testing/startConnection'
+
+const connection: WalletStatusUpdate['connection'] = { isConnected: true, isNetworkConnected: true }
+const party = { partyId: 'alice::1220ab', networkId: 'canton:local' }
+
+const readingAccounts = (read: () => Promise) =>
+ accountsMachine.provide({
+ actors: { readAccounts: fromPromise(read) },
+ })
+
+describe('useConnectBridge', () => {
+ it('rejects when the wallet accounts cannot be read', async () => {
+ const readFailed = new Error('wallet rpc unavailable')
+ const machine = connectionMachine.provide({
+ actors: {
+ init: fromPromise(() => Promise.resolve()),
+ connect: fromPromise(() =>
+ Promise.resolve({ connection }),
+ ),
+ accounts: readingAccounts(() => Promise.reject(readFailed)),
+ },
+ })
+ const actor = startConnection(machine)
+
+ const { result } = renderHook(() => useConnectBridge(actor))
+
+ await act(async () => {
+ await expect(result.current()).rejects.toBe(readFailed)
+ })
+
+ // the session survives a failed read; the machine keeps the cause for the consumer to read
+ expect(actor.getSnapshot().matches({ session: 'authenticated' })).toBe(true)
+ expect(actor.getSnapshot().context.lastConnectError).toBe(readFailed)
+
+ actor.stop()
+ })
+
+ it('rejects with an Error when the account read fails with a JSON-RPC object', async () => {
+ const rpcError = { code: -32000, message: 'wallet locked' }
+ const machine = connectionMachine.provide({
+ actors: {
+ init: fromPromise(() => Promise.resolve()),
+ connect: fromPromise(() =>
+ Promise.resolve({ connection }),
+ ),
+ accounts: readingAccounts(() => Promise.reject(rpcError)),
+ },
+ })
+ const actor = startConnection(machine)
+
+ const { result } = renderHook(() => useConnectBridge(actor))
+
+ await act(async () => {
+ await expect(result.current()).rejects.toMatchObject({
+ message: 'wallet locked',
+ cause: rpcError,
+ })
+ await expect(result.current()).rejects.toBeInstanceOf(Error)
+ })
+
+ // the machine keeps what the wallet sent; the classification is the bridge's
+ expect(actor.getSnapshot().context.lastConnectError).toBe(rpcError)
+
+ actor.stop()
+ })
+
+ it('rejects when the provider goes away mid-connect', async () => {
+ const machine = connectionMachine.provide({
+ actors: {
+ init: fromPromise(() => Promise.resolve()),
+ connect: fromPromise(() => new Promise(() => {})),
+ },
+ })
+ const actor = startConnection(machine)
+
+ const { result } = renderHook(() => useConnectBridge(actor))
+
+ await act(async () => {
+ const attempt = result.current()
+
+ actor.stop()
+
+ // waitFor's own rejection: a promise left pending here is a caller waiting on a dead actor
+ await expect(attempt).rejects.toThrow()
+ })
+ })
+
+ it('resolves only once the party has landed', async () => {
+ let landAccounts: ((accounts: WalletAccounts) => void) | undefined
+ const machine = connectionMachine.provide({
+ actors: {
+ init: fromPromise(() => Promise.resolve()),
+ connect: fromPromise(() =>
+ Promise.resolve({ connection }),
+ ),
+ accounts: readingAccounts(
+ () =>
+ new Promise((resolve) => {
+ landAccounts = resolve
+ }),
+ ),
+ },
+ })
+ const actor = startConnection(machine)
+
+ const { result } = renderHook(() => useConnectBridge(actor))
+
+ const settled = vi.fn()
+
+ await act(async () => {
+ void result.current().then(settled)
+ await pause(0)
+ })
+
+ expect(actor.getSnapshot().matches({ session: 'authenticated' })).toBe(true)
+ expect(settled).not.toHaveBeenCalled()
+
+ await act(async () => {
+ landAccounts?.({ party })
+ await pause(0)
+ })
+
+ expect(settled).toHaveBeenCalledOnce()
+
+ actor.stop()
+ })
+})
diff --git a/canton-connect/src/CantonConnectProvider/useConnectBridge.ts b/canton-connect/src/CantonConnectProvider/useConnectBridge.ts
new file mode 100644
index 00000000..f9abd05f
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/useConnectBridge.ts
@@ -0,0 +1,38 @@
+import { useCallback } from 'react'
+import { waitFor } from 'xstate'
+import { ConnectCancelledError, toConnectError } from '#src/connectError'
+import type { ConnectionActorRef } from '#src/machine/connectionMachine'
+
+/**
+ * Turns the machine's `connect` event into a promise. The machine's own tags say when an attempt
+ * has been answered, and its context carries what to raise.
+ */
+export const useConnectBridge = (actorRef: ConnectionActorRef): (() => Promise) => {
+ const connect = useCallback(async (): Promise => {
+ // Send first: the transition is synchronous, so the machine is already mid-attempt when
+ // `waitFor` reads it, and an answer left over from the last attempt cannot settle this one.
+ actorRef.send({ type: 'connect' })
+
+ // Rejects if the actor is stopped without answering, which is a provider unmounting
+ // mid-attempt.
+ const settled = await waitFor(
+ actorRef,
+ (snapshot) =>
+ snapshot.hasTag('connect.settled') ||
+ snapshot.hasTag('connect.failed') ||
+ snapshot.hasTag('connect.cancelled'),
+ )
+
+ if (settled.hasTag('connect.settled')) {
+ return
+ }
+
+ if (settled.hasTag('connect.cancelled')) {
+ throw new ConnectCancelledError()
+ }
+
+ throw toConnectError(settled.context.lastConnectError)
+ }, [actorRef])
+
+ return connect
+}
diff --git a/canton-connect/src/CantonConnectProvider/useConnectionActor.ts b/canton-connect/src/CantonConnectProvider/useConnectionActor.ts
new file mode 100644
index 00000000..7f0d6524
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/useConnectionActor.ts
@@ -0,0 +1,19 @@
+import { useActorRef } from '@xstate/react'
+import { useEffect } from 'react'
+import {
+ type ConnectionActorRef,
+ type ConnectionInput,
+ connectionMachine,
+} from '#src/machine/connectionMachine'
+
+/** Creates the connection actor and sends `restore` once it starts, so a prior session resumes. */
+export const useConnectionActor = (input: ConnectionInput): ConnectionActorRef => {
+ const actorRef = useActorRef(connectionMachine, { input })
+
+ // Sent from an effect, not at creation: useActorRef starts the actor in one of its own.
+ useEffect(() => {
+ actorRef.send({ type: 'restore' })
+ }, [actorRef])
+
+ return actorRef
+}
diff --git a/canton-connect/src/CantonConnectProvider/useDisconnectBridge.test.tsx b/canton-connect/src/CantonConnectProvider/useDisconnectBridge.test.tsx
new file mode 100644
index 00000000..95375ad2
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/useDisconnectBridge.test.tsx
@@ -0,0 +1,137 @@
+import { act, renderHook } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { fromPromise } from 'xstate'
+import { useConnectBridge } from '#src/CantonConnectProvider/useConnectBridge'
+import { useDisconnectBridge } from '#src/CantonConnectProvider/useDisconnectBridge'
+import type { AccountsInput } from '#src/machine/accountsActors'
+import { accountsMachine, type WalletAccounts } from '#src/machine/accountsMachine'
+import type {
+ ConnectInput,
+ DisconnectInput,
+ InitInput,
+ RestoreInput,
+} from '#src/machine/connectionActors'
+import { connectionMachine, type WalletStatusUpdate } from '#src/machine/connectionMachine'
+import { pause } from '#src/testing/pause'
+import { startConnection } from '#src/testing/startConnection'
+
+const connection: WalletStatusUpdate['connection'] = { isConnected: true, isNetworkConnected: true }
+const party = { partyId: 'alice::1220ab', networkId: 'canton:local' }
+
+const accounts = accountsMachine.provide({
+ actors: {
+ readAccounts: fromPromise(() => Promise.resolve({ party })),
+ },
+})
+
+describe('useDisconnectBridge', () => {
+ it('settles once the wallet has answered the disconnect', async () => {
+ const sdkDisconnect = vi.fn(() => Promise.resolve(null))
+ const machine = connectionMachine.provide({
+ actors: {
+ init: fromPromise(() => Promise.resolve()),
+ restore: fromPromise(() =>
+ Promise.resolve({ connection }),
+ ),
+ disconnect: fromPromise(sdkDisconnect),
+ accounts,
+ },
+ })
+ const actor = startConnection(machine)
+
+ actor.send({ type: 'restore' })
+ await pause(0)
+
+ const { result } = renderHook(() => useDisconnectBridge(actor))
+
+ const disconnected = vi.fn()
+
+ await act(async () => {
+ void result.current().then(disconnected)
+ await pause(0)
+ })
+
+ expect(disconnected).toHaveBeenCalledOnce()
+ expect(sdkDisconnect).toHaveBeenCalledOnce()
+ expect(actor.getSnapshot().matches('disconnected')).toBe(true)
+ expect(actor.getSnapshot().hasTag('disconnect.settled')).toBe(true)
+
+ actor.stop()
+ })
+
+ it('settles a disconnect from idle without asking the wallet', async () => {
+ const sdkDisconnect = vi.fn(() => Promise.resolve(null))
+ const machine = connectionMachine.provide({
+ actors: { disconnect: fromPromise(sdkDisconnect), accounts },
+ })
+ const actor = startConnection(machine)
+
+ const { result } = renderHook(() => useDisconnectBridge(actor))
+
+ // A macrotask against the tag `idle` already carries: a settle needing an actor loses the race.
+ const outcome = await Promise.race([
+ result.current().then(() => 'settled' as const),
+ pause(0).then(() => 'pending' as const),
+ ])
+
+ expect(outcome).toBe('settled')
+ expect(sdkDisconnect).not.toHaveBeenCalled()
+ expect(actor.getSnapshot().matches('idle')).toBe(true)
+
+ actor.stop()
+ })
+
+ it('ignores a connect asked for during a disconnect, and settles the disconnect', async () => {
+ const connectStarted = vi.fn()
+ let endDisconnect: (() => void) | undefined
+ const machine = connectionMachine.provide({
+ actors: {
+ init: fromPromise(() => Promise.resolve()),
+ restore: fromPromise(() =>
+ Promise.resolve({ connection }),
+ ),
+ connect: fromPromise(() => {
+ connectStarted()
+ return Promise.resolve({ connection })
+ }),
+ disconnect: fromPromise(
+ () =>
+ new Promise((resolve) => {
+ endDisconnect = () => resolve(null)
+ }),
+ ),
+ accounts,
+ },
+ })
+ const actor = startConnection(machine)
+
+ actor.send({ type: 'restore' })
+ await pause(0)
+
+ const { result } = renderHook(() => ({
+ connect: useConnectBridge(actor),
+ disconnect: useDisconnectBridge(actor),
+ }))
+
+ const disconnected = vi.fn()
+ const rejected = vi.fn()
+
+ await act(async () => {
+ void result.current.disconnect().then(disconnected)
+ await pause(0)
+
+ // a connect during the disconnect is ignored, not queued: the connect actor never runs, and
+ // the call rejects as cancelled once the machine rests in `disconnected`
+ void result.current.connect().catch(rejected)
+ endDisconnect?.()
+ await pause(0)
+ })
+
+ expect(disconnected).toHaveBeenCalledOnce()
+ expect(rejected).toHaveBeenCalledOnce()
+ expect(connectStarted).not.toHaveBeenCalled()
+ expect(actor.getSnapshot().matches('disconnected')).toBe(true)
+
+ actor.stop()
+ })
+})
diff --git a/canton-connect/src/CantonConnectProvider/useDisconnectBridge.ts b/canton-connect/src/CantonConnectProvider/useDisconnectBridge.ts
new file mode 100644
index 00000000..73828e77
--- /dev/null
+++ b/canton-connect/src/CantonConnectProvider/useDisconnectBridge.ts
@@ -0,0 +1,13 @@
+import { useCallback } from 'react'
+import { waitFor } from 'xstate'
+import type { ConnectionActorRef } from '#src/machine/connectionMachine'
+
+/** Resolves once the wallet has been asked, whichever state the machine started from. */
+export const useDisconnectBridge = (actorRef: ConnectionActorRef): (() => Promise) =>
+ useCallback(async (): Promise => {
+ actorRef.send({ type: 'disconnect' })
+
+ // A disconnect now always lands in `disconnected`; a connect asked for mid-disconnect is
+ // ignored, not queued, so there is no `superseded` exit to wait on.
+ await waitFor(actorRef, (snapshot) => snapshot.hasTag('disconnect.settled'))
+ }, [actorRef])
diff --git a/canton-connect/src/connectError.test.ts b/canton-connect/src/connectError.test.ts
index 9bb33215..3f22c8a0 100644
--- a/canton-connect/src/connectError.test.ts
+++ b/canton-connect/src/connectError.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
-import { ConnectCancelledError, toConnectError } from '#src/connectError'
+import { ConnectCancelledError, toConnectError, toError } from '#src/connectError'
+
+const rpcError = { code: -32000, message: 'wallet locked', data: { reason: 'locked' } }
describe('toConnectError', () => {
it('translates the picker dismissal, keeping the original as cause', () => {
@@ -27,4 +29,32 @@ describe('toConnectError', () => {
ConnectCancelledError,
)
})
+
+ it('wraps a JSON-RPC error object rather than casting it', () => {
+ const error = toConnectError(rpcError)
+
+ expect(error).toBeInstanceOf(Error)
+ expect(error.message).toBe('wallet locked')
+ expect(error.cause).toBe(rpcError)
+ })
+})
+
+describe('toError', () => {
+ it('leaves an Error untouched', () => {
+ const failure = new Error('failure')
+
+ expect(toError(failure)).toBe(failure)
+ })
+
+ it('wraps a JSON-RPC error object into an Error, keeping it as cause', () => {
+ const error = toError(rpcError)
+
+ expect(error).toBeInstanceOf(Error)
+ expect(error.message).toBe('wallet locked')
+ expect(error.cause).toBe(rpcError)
+ })
+
+ it('stringifies a rejection that carries no message', () => {
+ expect(toError('nope').message).toBe('nope')
+ })
})
diff --git a/canton-connect/src/connectError.ts b/canton-connect/src/connectError.ts
index f559f741..f74891b6 100644
--- a/canton-connect/src/connectError.ts
+++ b/canton-connect/src/connectError.ts
@@ -50,6 +50,30 @@ export class ConnectCancelledError extends Error {
}
}
+/** Whether a rejection carries a string `message`, as a JSON-RPC error object does. */
+const hasMessage = (cause: unknown): cause is { message: string } =>
+ typeof cause === 'object' &&
+ cause !== null &&
+ 'message' in cause &&
+ typeof cause.message === 'string'
+
+/**
+ * Hands `cause` back as an `Error`, wrapping what a wallet answered with over JSON-RPC.
+ *
+ * @example
+ * const error = toError(await sdk.signMessage(params).catch((cause: unknown) => cause))
+ * error.cause // the wallet's `{ code, message }` when that is what it sent
+ */
+// The window transport rejects with the JSON-RPC error object itself, `{ code, message }` and no
+// prototype, so `instanceof Error` fails on every wallet-side refusal.
+export const toError = (cause: unknown): Error => {
+ if (cause instanceof Error) {
+ return cause
+ }
+
+ return new Error(hasMessage(cause) ? cause.message : String(cause), { cause })
+}
+
/** Classifies what `sdk.connect()` threw, so the cancel path is decided once. */
export const toConnectError = (cause: unknown): Error => {
if (cause instanceof ConnectCancelledError) {
@@ -58,5 +82,5 @@ export const toConnectError = (cause: unknown): Error => {
return cause instanceof Error && cause.message === PICKER_DISMISSED
? new ConnectCancelledError(cause)
- : (cause as Error)
+ : toError(cause)
}
diff --git a/canton-connect/src/hooks/useConnect.ts b/canton-connect/src/hooks/useConnect.ts
index e54aa744..ac65729a 100644
--- a/canton-connect/src/hooks/useConnect.ts
+++ b/canton-connect/src/hooks/useConnect.ts
@@ -1,9 +1,15 @@
+import { useSelector } from '@xstate/react'
+import { useMemo } from 'react'
import { useCantonConnectContext } from '#src/CantonConnectProvider'
+import { type ConnectCancelledError, toConnectError } from '#src/connectError'
+import { toConnectionStatus } from '#src/machine/connectionMachine'
/**
- * Return shape of {@link useConnect}. `connect` opens the picker and rejects with
- * {@link ConnectCancelledError} where the user closed it, which `connectError` mirrors;
- * `disconnect` clears the local party and status even if the wallet's own call fails.
+ * Return shape of {@link useConnect}.
+ *
+ * `connect` resolves once the party lands and rejects a cancel with {@link ConnectCancelledError}.
+ *
+ * `disconnect` settles within 10 s even unanswered; `reset` forgets only `connectError`.
*
* @category Hooks
*/
@@ -13,11 +19,13 @@ export interface UseConnectResult {
isConnecting: boolean
isConnected: boolean
connectError: Error | undefined
+ reset: () => void
}
/**
* Connects and disconnects the wallet, and reports that transition. `connect` takes no argument:
- * the picker chooses the wallet, so there is no mode to pass.
+ * the picker chooses the wallet, so there is no mode to pass. Gate a pending face on
+ * `isConnecting` and session-dependent content on `useParty().party`, not on `isConnected`.
* Wagmi: `useConnect` + `useDisconnect`, bundled because one provider owns the session.
*
* @throws with no {@link CantonConnectProvider} above it, as every hook here does.
@@ -31,12 +39,26 @@ export interface UseConnectResult {
* @category Hooks
*/
export const useConnect = (): UseConnectResult => {
- const ctx = useCantonConnectContext()
+ const { connect, connection, disconnect, resetConnectError } = useCantonConnectContext()
+
+ const status = useSelector(connection, toConnectionStatus)
+ const isConnecting = useSelector(connection, (snapshot) => snapshot.hasTag('connecting'))
+ const lastConnectError = useSelector(connection, (snapshot) => snapshot.context.lastConnectError)
+
+ // Classified in a memo rather than in the selector, so one failure keeps one identity: mapping it
+ // on every snapshot would hand back a new Error each time, and a consumer comparing it across
+ // renders would report the same failure twice.
+ const connectError = useMemo(
+ () => (lastConnectError === undefined ? undefined : toConnectError(lastConnectError)),
+ [lastConnectError],
+ )
+
return {
- connect: ctx.connect,
- disconnect: ctx.disconnect,
- isConnecting: ctx.isConnecting,
- isConnected: ctx.status === 'connected',
- connectError: ctx.connectError,
+ connect,
+ disconnect,
+ isConnecting,
+ isConnected: status === 'connected',
+ connectError,
+ reset: resetConnectError,
}
}
diff --git a/canton-connect/src/hooks/useExecute.ts b/canton-connect/src/hooks/useExecute.ts
index 63ed6460..d66d5865 100644
--- a/canton-connect/src/hooks/useExecute.ts
+++ b/canton-connect/src/hooks/useExecute.ts
@@ -1,6 +1,9 @@
import type { PrepareExecuteParams } from '@canton-network/dapp-sdk'
-import { useCallback, useState } from 'react'
-import { type TxStatusSnapshot, useCantonConnectContext } from '#src/CantonConnectProvider'
+import { useCallback } from 'react'
+import type { CantonConnectProvider } from '#src/CantonConnectProvider'
+import { useTxFeed } from '#src/hooks/useTxFeed'
+import { useWalletCall } from '#src/hooks/useWalletCall'
+import type { TxStatusSnapshot } from '#src/types'
/**
* Re-exported so callers need no direct `@canton-network/dapp-sdk` dependency for the type.
@@ -39,36 +42,15 @@ export interface UseExecuteResult {
* @category Hooks
*/
export const useExecute = (): UseExecuteResult => {
- const ctx = useCantonConnectContext()
- const [isExecuting, setIsExecuting] = useState(false)
- const [error, setError] = useState(undefined)
+ const { call, isBusy, error, reset, connection, sdk } = useWalletCall()
- const execute = useCallback(
- async (params: PrepareExecuteParams): Promise => {
- if (ctx.status !== 'connected') {
- throw new Error('wallet is not connected — call useConnect().connect() first')
- }
-
- setIsExecuting(true)
- setError(undefined)
+ const lastTx = useTxFeed(sdk, connection)
- try {
- return await ctx.sdk.prepareExecuteAndWait(params)
- } catch (err) {
- const e = err as Error
- setError(e)
- throw e
- } finally {
- setIsExecuting(false)
- }
- },
- [ctx.sdk, ctx.status],
+ const execute = useCallback(
+ (params: PrepareExecuteParams): Promise =>
+ call((walletSdk) => walletSdk.prepareExecuteAndWait(params)),
+ [call],
)
- const reset = useCallback((): void => {
- setError(undefined)
- setIsExecuting(false)
- }, [])
-
- return { execute, lastTx: ctx.lastTx, isExecuting, error, reset }
+ return { execute, lastTx, isExecuting: isBusy, error, reset }
}
diff --git a/canton-connect/src/hooks/useLedger.test.tsx b/canton-connect/src/hooks/useLedger.test.tsx
new file mode 100644
index 00000000..7bfb91da
--- /dev/null
+++ b/canton-connect/src/hooks/useLedger.test.tsx
@@ -0,0 +1,34 @@
+// The request path over a session that answers; the guards are covered against the real provider.
+
+import { act, renderHook } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { type LedgerApiParams, useLedger } from '#src/hooks/useLedger'
+import { FakeSessionProvider } from '#src/testing/fakeSession'
+import type { WalletSdk } from '#src/types'
+
+const party = { partyId: 'alice::1220ab', networkId: 'canton:local' }
+const request: LedgerApiParams = { requestMethod: 'get', resource: '/v2/parties' }
+
+describe('useLedger', () => {
+ it('passes the request to the sdk and hands its answer back untouched', async () => {
+ const answer = { parties: [] }
+ const ledgerApi = vi.fn().mockResolvedValue(answer)
+ const sdk = { ledgerApi }
+ const { result } = renderHook(() => useLedger(), {
+ wrapper: ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ ),
+ })
+
+ expect(result.current.isReady).toBe(true)
+
+ await act(async () => {
+ await expect(result.current.ledgerApi(request)).resolves.toBe(answer)
+ })
+
+ expect(ledgerApi).toHaveBeenCalledWith(request)
+ })
+})
diff --git a/canton-connect/src/hooks/useLedger.ts b/canton-connect/src/hooks/useLedger.ts
index 6c29e332..51c34446 100644
--- a/canton-connect/src/hooks/useLedger.ts
+++ b/canton-connect/src/hooks/useLedger.ts
@@ -1,6 +1,6 @@
import type { LedgerApiParams } from '@canton-network/dapp-sdk'
import { useCallback } from 'react'
-import { useCantonConnectContext } from '#src/CantonConnectProvider'
+import { assertUsable, useWalletCall } from '#src/hooks/useWalletCall'
/**
* Re-exported so callers need no direct `@canton-network/dapp-sdk` dependency for the type.
@@ -33,18 +33,17 @@ export interface UseLedgerResult {
* @category Hooks
*/
export const useLedger = (): UseLedgerResult => {
- const ctx = useCantonConnectContext()
+ // Guards without `call`: a stateless query needs no busy/error renders around it.
+ const { sdk, status, isLocked } = useWalletCall()
const ledgerApi = useCallback(
async (params: LedgerApiParams): Promise => {
- if (ctx.status !== 'connected') {
- throw new Error('wallet is not connected — call useConnect().connect() first')
- }
+ assertUsable(status, isLocked)
- return await ctx.sdk.ledgerApi(params)
+ return await sdk.ledgerApi(params)
},
- [ctx.sdk, ctx.status],
+ [isLocked, sdk, status],
)
- return { ledgerApi, isReady: ctx.status === 'connected' }
+ return { ledgerApi, isReady: status === 'connected' && !isLocked }
}
diff --git a/canton-connect/src/hooks/useParty.ts b/canton-connect/src/hooks/useParty.ts
index b6dd037e..2eb9bc89 100644
--- a/canton-connect/src/hooks/useParty.ts
+++ b/canton-connect/src/hooks/useParty.ts
@@ -1,4 +1,6 @@
+import { useSelector } from '@xstate/react'
import { useCantonConnectContext } from '#src/CantonConnectProvider'
+import { toConnectionStatus } from '#src/machine/connectionMachine'
import type { ConnectionStatus, Party } from '#src/types'
/**
@@ -27,10 +29,14 @@ export interface UsePartyResult {
* @category Hooks
*/
export const useParty = (): UsePartyResult => {
- const ctx = useCantonConnectContext()
+ const { connection } = useCantonConnectContext()
+
+ const party = useSelector(connection, (snapshot) => snapshot.context.party)
+ const status = useSelector(connection, toConnectionStatus)
+
return {
- party: ctx.party,
- status: ctx.status,
- isConnected: ctx.status === 'connected',
+ party,
+ status,
+ isConnected: status === 'connected',
}
}
diff --git a/canton-connect/src/hooks/useSignMessage.test.tsx b/canton-connect/src/hooks/useSignMessage.test.tsx
new file mode 100644
index 00000000..0f31dd9f
--- /dev/null
+++ b/canton-connect/src/hooks/useSignMessage.test.tsx
@@ -0,0 +1,96 @@
+// The hook's own state, over a session that answers: the `sdk` opt-in on FakeSessionProvider is
+// what makes a resolving signMessage reachable at all.
+
+import { act, renderHook } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { useSignMessage } from '#src/hooks/useSignMessage'
+import { FakeSessionProvider } from '#src/testing/fakeSession'
+import type { WalletSdk } from '#src/types'
+
+const party = { partyId: 'alice::1220ab', networkId: 'canton:local' }
+
+const liveSession = (sdk: Partial) => ({
+ wrapper: ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ ),
+})
+
+describe('useSignMessage', () => {
+ it('publishes the signature the wallet answered with', async () => {
+ const signMessage = vi.fn().mockResolvedValue({ signature: 'sig' })
+ const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }))
+
+ await act(async () => {
+ await expect(result.current.signMessage('hello')).resolves.toBe('sig')
+ })
+
+ expect(signMessage).toHaveBeenCalledWith({ message: 'hello' })
+ expect(result.current.signature).toBe('sig')
+ expect(result.current.error).toBeUndefined()
+ expect(result.current.isSigning).toBe(false)
+ })
+
+ it('captures the wallet refusal and rethrows it', async () => {
+ const refused = new Error('user refused to sign')
+ const signMessage = vi.fn().mockRejectedValue(refused)
+ const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }))
+
+ await act(async () => {
+ await expect(result.current.signMessage('hello')).rejects.toBe(refused)
+ })
+
+ expect(result.current.error).toBe(refused)
+ expect(result.current.signature).toBeUndefined()
+ expect(result.current.isSigning).toBe(false)
+ })
+
+ it('publishes a refusal that arrived as a JSON-RPC object as an Error', async () => {
+ const rpcError = { code: 4001, message: 'user refused to sign' }
+ const signMessage = vi.fn().mockRejectedValue(rpcError)
+ const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }))
+
+ await act(async () => {
+ await expect(result.current.signMessage('hello')).rejects.toBeInstanceOf(Error)
+ })
+
+ expect(result.current.error).toBeInstanceOf(Error)
+ expect(result.current.error?.message).toBe('user refused to sign')
+ expect(result.current.error?.cause).toBe(rpcError)
+ })
+
+ it('forgets a signature, and a refusal, on reset', async () => {
+ const refused = new Error('user refused to sign')
+ const signMessage = vi
+ .fn()
+ .mockResolvedValueOnce({ signature: 'sig' })
+ .mockRejectedValueOnce(refused)
+ const { result } = renderHook(() => useSignMessage(), liveSession({ signMessage }))
+
+ await act(async () => {
+ await result.current.signMessage('hello')
+ })
+
+ expect(result.current.signature).toBe('sig')
+
+ act(() => {
+ result.current.reset()
+ })
+
+ expect(result.current.signature).toBeUndefined()
+
+ await act(async () => {
+ await expect(result.current.signMessage('hello')).rejects.toBe(refused)
+ })
+
+ expect(result.current.error).toBe(refused)
+
+ act(() => {
+ result.current.reset()
+ })
+
+ expect(result.current.error).toBeUndefined()
+ })
+})
diff --git a/canton-connect/src/hooks/useSignMessage.ts b/canton-connect/src/hooks/useSignMessage.ts
index 067f1748..8b13b96e 100644
--- a/canton-connect/src/hooks/useSignMessage.ts
+++ b/canton-connect/src/hooks/useSignMessage.ts
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react'
-import { useCantonConnectContext } from '#src/CantonConnectProvider'
+import type { CantonConnectProvider } from '#src/CantonConnectProvider'
+import { useWalletCall } from '#src/hooks/useWalletCall'
/**
* Return shape of {@link useSignMessage}. `signMessage` throws when nothing is connected, and
@@ -29,39 +30,26 @@ export interface UseSignMessageResult {
* @category Hooks
*/
export const useSignMessage = (): UseSignMessageResult => {
- const ctx = useCantonConnectContext()
+ const { call, isBusy, error, reset: resetCall } = useWalletCall()
+
const [signature, setSignature] = useState(undefined)
- const [isSigning, setIsSigning] = useState(false)
- const [error, setError] = useState(undefined)
const signMessage = useCallback(
async (message: string): Promise => {
- if (ctx.status !== 'connected') {
- throw new Error('wallet is not connected — call useConnect().connect() first')
- }
- setIsSigning(true)
- setError(undefined)
setSignature(undefined)
- try {
- const result = await ctx.sdk.signMessage({ message })
- setSignature(result.signature)
- return result.signature
- } catch (err) {
- const e = err as Error
- setError(e)
- throw e
- } finally {
- setIsSigning(false)
- }
+
+ const result = await call((walletSdk) => walletSdk.signMessage({ message }))
+
+ setSignature(result.signature)
+ return result.signature
},
- [ctx.sdk, ctx.status],
+ [call],
)
const reset = useCallback((): void => {
setSignature(undefined)
- setError(undefined)
- setIsSigning(false)
- }, [])
+ resetCall()
+ }, [resetCall])
- return { signMessage, signature, isSigning, error, reset }
+ return { signMessage, signature, isSigning: isBusy, error, reset }
}
diff --git a/canton-connect/src/hooks/useTxFeed.ts b/canton-connect/src/hooks/useTxFeed.ts
new file mode 100644
index 00000000..a0f07bf8
--- /dev/null
+++ b/canton-connect/src/hooks/useTxFeed.ts
@@ -0,0 +1,40 @@
+import type { TxChangedEvent } from '@canton-network/dapp-sdk'
+import { useSelector } from '@xstate/react'
+import { useEffect, useState } from 'react'
+import type { ConnectionSubscription, TxStatusSnapshot, WalletSdk } from '#src/types'
+
+// Transactions are orthogonal to the connection lifecycle, so they stay React state rather than
+// machine context: nothing about the session depends on the last command's fate. One listener per
+// `useExecute`, since it is the only consumer and every listener hears every tx.
+/** Tracks the SDK's `txChanged` pushes while a session is active, clearing on session end. */
+export const useTxFeed = (
+ sdk: WalletSdk,
+ connection: ConnectionSubscription,
+): TxStatusSnapshot | undefined => {
+ const [lastTx, setLastTx] = useState(undefined)
+
+ const sessionActive = useSelector(connection, (snapshot) => snapshot.matches('session'))
+
+ useEffect(() => {
+ if (!sessionActive) {
+ setLastTx(undefined)
+ return
+ }
+
+ const onTx = (event: TxChangedEvent): void => {
+ setLastTx({
+ status: event.status,
+ commandId: event.commandId,
+ payload: 'payload' in event ? event.payload : undefined,
+ })
+ }
+
+ void sdk.onTxChanged(onTx).catch(() => undefined)
+
+ return () => {
+ void sdk.removeOnTxChanged(onTx).catch(() => undefined)
+ }
+ }, [sessionActive, sdk])
+
+ return lastTx
+}
diff --git a/canton-connect/src/hooks/useWalletCall.ts b/canton-connect/src/hooks/useWalletCall.ts
new file mode 100644
index 00000000..12da5c0d
--- /dev/null
+++ b/canton-connect/src/hooks/useWalletCall.ts
@@ -0,0 +1,87 @@
+import { useSelector } from '@xstate/react'
+import { useCallback, useState } from 'react'
+import { useCantonConnectContext } from '#src/CantonConnectProvider'
+import { toError } from '#src/connectError'
+import { toConnectionStatus } from '#src/machine/connectionMachine'
+import type { ConnectionStatus, ConnectionSubscription, WalletSdk } from '#src/types'
+
+/** The resting state, hoisted so a hook that never called keeps one identity across renders. */
+const IDLE = { isBusy: false, error: undefined } as const
+
+/** In-flight and last-failure bookkeeping for one wallet call. */
+type WalletCallState = { isBusy: boolean; error: Error | undefined }
+
+// The one home of the two guard messages the SDK-calling hooks throw.
+/** Throws when the wallet is disconnected or locked, the guard every SDK-calling hook shares. */
+export const assertUsable = (status: ConnectionStatus, isLocked: boolean): void => {
+ if (status !== 'connected') {
+ throw new Error('wallet is not connected - call useConnect().connect() first')
+ }
+
+ if (isLocked) {
+ throw new Error('wallet is locked - unlock it in the wallet')
+ }
+}
+
+/**
+ * Return shape of {@link useWalletCall}: busy/error state around one call, plus the session
+ * pieces the public hooks assemble into their own results.
+ */
+export interface UseWalletCallResult {
+ call: (run: (sdk: WalletSdk) => Promise) => Promise
+ isBusy: boolean
+ error: Error | undefined
+ reset: () => void
+ connection: ConnectionSubscription
+ sdk: WalletSdk
+ status: ConnectionStatus
+ isLocked: boolean
+}
+
+// The skeleton shared by the SDK-calling hooks: session selectors, the guards, and the
+// busy/error bookkeeping around one call. Internal; the public hooks shape its pieces.
+/**
+ * Selects the session and wraps one SDK call with the connect/lock guard and busy/error
+ * bookkeeping that `useExecute`, `useSignMessage` and `useLedger` share.
+ */
+export const useWalletCall = (): UseWalletCallResult => {
+ const { connection } = useCantonConnectContext()
+
+ const sdk = useSelector(connection, (snapshot) => snapshot.context.sdk)
+ const status = useSelector(connection, toConnectionStatus)
+ const isLocked = useSelector(connection, (snapshot) => snapshot.hasTag('unauthenticated'))
+
+ const [state, setState] = useState(IDLE)
+
+ const call = useCallback(
+ async (run: (walletSdk: WalletSdk) => Promise): Promise => {
+ assertUsable(status, isLocked)
+
+ setState({ isBusy: true, error: undefined })
+
+ try {
+ const result = await run(sdk)
+ setState(IDLE)
+ return result
+ } catch (err) {
+ const error = toError(err)
+ setState({ isBusy: false, error })
+ throw error
+ }
+ },
+ [isLocked, sdk, status],
+ )
+
+ const reset = useCallback((): void => setState(IDLE), [])
+
+ return {
+ call,
+ isBusy: state.isBusy,
+ error: state.error,
+ reset,
+ connection,
+ sdk,
+ status,
+ isLocked,
+ }
+}
diff --git a/canton-connect/src/hooks/useWalletStatus.ts b/canton-connect/src/hooks/useWalletStatus.ts
index 0199fd4e..2180781b 100644
--- a/canton-connect/src/hooks/useWalletStatus.ts
+++ b/canton-connect/src/hooks/useWalletStatus.ts
@@ -1,8 +1,12 @@
-import { useCantonConnectContext } from '#src/CantonConnectProvider'
+import { useSelector } from '@xstate/react'
+import { type CantonConnectProvider, useCantonConnectContext } from '#src/CantonConnectProvider'
+import { toConnectionStatus } from '#src/machine/connectionMachine'
/**
- * Return shape of {@link useWalletStatus}. Connected-but-locked is a real pair: a session exists,
- * but the wallet must be unlocked before it will serve a request.
+ * Return shape of {@link useWalletStatus}: connected-but-locked is a real pair.
+ *
+ * In CIP-0103 terms, locked is an unauthenticated session: it stands, but the wallet pushed
+ * `isConnected: false` and answers no requests until it pushes true again.
*
* @category Hooks
*/
@@ -12,21 +16,26 @@ export interface UseWalletStatusResult {
}
/**
- * Whether a session exists and whether the wallet is locked. Connected-but-locked is a CIP-0103
- * state wagmi has no equivalent for, and it follows the wallet's own pushes, so never poll it.
+ * Reports the session and lock state from the wallet's own pushes. A wallet that disconnected on
+ * its own pushed the same thing as a lock, so `isLocked` cannot tell them apart.
*
* @throws with no {@link CantonConnectProvider} above it.
*
* @example
- * const { isLocked } = useWalletStatus()
- * isLocked && Wallet locked — unlock it to continue.
+ * const { isConnected, isLocked } = useWalletStatus()
+ * if (!isConnected) return No session.
+ * return isLocked ? Unlock your wallet to continue.
: Ready.
*
* @category Hooks
*/
export const useWalletStatus = (): UseWalletStatusResult => {
- const ctx = useCantonConnectContext()
+ const { connection } = useCantonConnectContext()
+
+ const isLocked = useSelector(connection, (snapshot) => snapshot.hasTag('unauthenticated'))
+ const status = useSelector(connection, toConnectionStatus)
+
return {
- isLocked: ctx.isLocked,
- isConnected: ctx.status === 'connected',
+ isLocked,
+ isConnected: status === 'connected',
}
}
diff --git a/canton-connect/src/index.ts b/canton-connect/src/index.ts
index d5c46f9a..6be9aebd 100644
--- a/canton-connect/src/index.ts
+++ b/canton-connect/src/index.ts
@@ -8,11 +8,7 @@
// canton-connect — wagmi-style React hooks for connecting Canton dApps
// to CIP-0103 wallets. See README.md for the design rationale.
-export type {
- CantonConnectContextValue,
- CantonConnectProviderProps,
- TxStatusSnapshot,
-} from '#src/CantonConnectProvider'
+export type { CantonConnectProviderProps } from '#src/CantonConnectProvider'
export { CantonConnectProvider, useCantonConnectContext } from '#src/CantonConnectProvider'
export { ConnectCancelledError } from '#src/connectError'
export type { UseConnectResult } from '#src/hooks/useConnect'
@@ -34,8 +30,10 @@ export { createMockAdapter } from '#src/mock/mockAdapter'
export type {
CantonConnectConfig,
+ CantonConnectContextValue,
ConnectionStatus,
ConnectionSubscription,
Party,
+ TxStatusSnapshot,
WalletSdk,
} from '#src/types'
diff --git a/canton-connect/src/machine/connectionMachine.test.ts b/canton-connect/src/machine/connectionMachine.test.ts
index 01efbbc9..a27560f7 100644
--- a/canton-connect/src/machine/connectionMachine.test.ts
+++ b/canton-connect/src/machine/connectionMachine.test.ts
@@ -680,8 +680,8 @@ describe('connectionMachine', () => {
actor.stop()
})
- it('ignores a connect over a live session', async () => {
- const machine = connectionMachine.provide({ actors: { accounts, init, restore } })
+ it('takes a connect over a live session, as a wallet change', async () => {
+ const machine = connectionMachine.provide({ actors: { accounts, init, restore, connect } })
const actor = createActor(machine, { input: connectionInput() })
actor.start()
@@ -690,18 +690,19 @@ describe('connectionMachine', () => {
expect(actor.getSnapshot().matches({ session: { authenticated: 'ready' } })).toBe(true)
- const settled = actor.getSnapshot()
-
actor.send({ type: 'connect' })
+
+ expect(actor.getSnapshot().matches({ connecting: 'changing' })).toBe(true)
+
await pause(0)
- expect(actor.getSnapshot()).toBe(settled)
+ expect(actor.getSnapshot().matches({ session: { authenticated: 'ready' } })).toBe(true)
actor.stop()
})
- it('ignores a connect while locked', async () => {
- const machine = connectionMachine.provide({ actors: { accounts, init, restore } })
+ it('takes a connect while locked', async () => {
+ const machine = connectionMachine.provide({ actors: { accounts, init, restore, connect } })
const actor = createActor(machine, { input: connectionInput() })
actor.start()
@@ -710,12 +711,13 @@ describe('connectionMachine', () => {
actor.send(lockPush)
expect(actor.getSnapshot().matches({ session: 'unauthenticated' })).toBe(true)
- const settled = actor.getSnapshot()
-
actor.send({ type: 'connect' })
+
+ expect(actor.getSnapshot().matches({ connecting: 'changing' })).toBe(true)
+
await pause(0)
- expect(actor.getSnapshot()).toBe(settled)
+ expect(actor.getSnapshot().matches({ session: { authenticated: 'ready' } })).toBe(true)
actor.stop()
})
@@ -1473,6 +1475,37 @@ describe('connectionMachine', () => {
actor.stop()
})
+ // A status of disconnected or idle in between would unmount a status-gated app while its
+ // session survives, so every step of the resume must read as the attempt still running.
+ it('resumes the standing session when a wallet change is walked out on', async () => {
+ const machine = connectionMachine.provide({
+ actors: { accounts, init, restore, connect: closedPicker },
+ })
+ const actor = createActor(machine, { input: connectionInput() })
+
+ actor.start()
+ actor.send({ type: 'restore' })
+ await pause(0)
+
+ expect(actor.getSnapshot().matches({ session: { authenticated: 'ready' } })).toBe(true)
+
+ const reported: string[] = []
+ const subscription = actor.subscribe((snapshot) => {
+ reported.push(toConnectionStatus(snapshot))
+ })
+
+ actor.send({ type: 'connect' })
+ await waitFor(actor, (snapshot) => snapshot.matches({ session: { authenticated: 'ready' } }))
+
+ expect(reported).not.toContain('disconnected')
+ expect(reported).not.toContain('idle')
+ expect(reported[0]).toBe('connecting')
+ expect(reported.at(-1)).toBe('connected')
+
+ subscription.unsubscribe()
+ actor.stop()
+ })
+
it('takes a disconnect while the replacement is still booting', async () => {
const machine = connectionMachine.provide({
actors: { accounts, disconnect, connect: closedPicker },
diff --git a/canton-connect/src/machine/connectionMachine.ts b/canton-connect/src/machine/connectionMachine.ts
index 41a2196d..842eca37 100644
--- a/canton-connect/src/machine/connectionMachine.ts
+++ b/canton-connect/src/machine/connectionMachine.ts
@@ -17,7 +17,7 @@ import {
restore,
walletEvents,
} from '#src/machine/connectionActors'
-import type { ConnectionStatus, Party, WalletSdk } from '#src/types'
+import type { ConnectionStatus, ConnectionSubscription, Party, WalletSdk } from '#src/types'
// The SDK's disconnect awaits the wallet's answer with no deadline of its own, so this is the only
// bound on how long `disconnecting` can last.
@@ -67,6 +67,12 @@ export const toConnectionStatus = (
return 'connecting'
}
+ // A cancelled wallet change passes through `retiring.changing` and `restoring.changing` while the
+ // session it kept is restored; disconnected or idle here would unmount a status-gated app.
+ if (snapshot.matches({ retiring: 'changing' }) || snapshot.matches({ restoring: 'changing' })) {
+ return 'connecting'
+ }
+
if (snapshot.matches('session')) {
return 'connected'
}
@@ -95,7 +101,9 @@ const landAuthenticated = {
connection: output.connection,
}),
},
- target: 'session.authenticated',
+ // `askWallet` runs this inside `connecting`, where a relative `session.authenticated` would not
+ // resolve, so the target is an id.
+ target: '#connection.session.authenticated',
} as const
/** The exit from `disconnecting`, success and failure alike: nothing overlaps a disconnect. */
@@ -106,26 +114,88 @@ const afterDisconnect = { target: 'disconnected' } as const
// whichever client a later connect installs on that instance.
const afterSilentDisconnect = { actions: { type: 'retireSdk' }, target: 'disconnected' } as const
-/** The `init` invoke and what follows it, shared by `initializing` and `retiring`. */
-const bootSdk = {
- src: 'init',
- input: ({ context }: { context: ConnectionContext }) => ({
- sdk: context.sdk,
- initOptions: context.initOptions,
- }),
- onDone: { target: 'restoring' },
- onError: {
- target: 'failure',
- actions: [
+/** The `init` invoke shared by `initializing` and `retiring`. Each caller resumes in a different
+ * `restoring` state, so it names the `onDone` target. */
+const bootSdk = (onDone: string) =>
+ ({
+ src: 'init',
+ input: ({ context }: { context: ConnectionContext }) => ({
+ sdk: context.sdk,
+ initOptions: context.initOptions,
+ }),
+ onDone: { target: onDone },
+ onError: {
+ target: '#connection.failure',
+ actions: [
+ {
+ type: 'assignError',
+ params: ({ event: { error } }: { event: ErrorActorEvent }) => ({ error }),
+ },
+ // The rejection is cached on the instance forever, so only a replacement can retry.
+ { type: 'retireSdk' },
+ ],
+ },
+ }) as const
+
+/** The `connect` invoke. The caller names which `retiring` variant a closed picker lands in, so
+ * a wallet change stays one through the retirement. */
+const askWallet = (retiringTarget: string) =>
+ ({
+ src: 'connect',
+ input: ({ context }: { context: ConnectionContext }) => ({
+ sdk: context.sdk,
+ initOptions: context.initOptions,
+ guardPicker: context.guardPicker,
+ }),
+ onDone: [
+ landAuthenticated,
{
- type: 'assignError',
- params: ({ event: { error } }: { event: ErrorActorEvent }) => ({ error }),
+ target: '#connection.failure',
+ actions: {
+ type: 'assignDeclined',
+ params: ({ event: { output } }: { event: DoneActorEvent }) => ({
+ connection: output.connection,
+ }),
+ },
},
- // The rejection is cached on the instance forever, so only a replacement can retry.
- { type: 'retireSdk' },
],
- },
-} as const
+ onError: [
+ {
+ guard: {
+ type: 'isPickerClosed',
+ params: ({ event: { error } }: { event: ErrorActorEvent }) => ({ error }),
+ },
+ // Swapped before `retiring` is entered, so its init reads the replacement.
+ actions: { type: 'retireSdk' },
+ target: retiringTarget,
+ },
+ {
+ guard: {
+ type: 'isInitFailed',
+ params: ({ event: { error } }: { event: ErrorActorEvent }) => ({ error }),
+ },
+ target: '#connection.failure',
+ actions: [
+ {
+ type: 'assignError',
+ // The wrapper marked the route; the consumer reads the SDK's own error.
+ params: ({ event: { error } }: { event: ErrorActorEvent }) => ({
+ error: error instanceof InitFailedError ? error.cause : error,
+ }),
+ },
+ // The rejection is cached on the instance forever, so only a replacement can retry.
+ { type: 'retireSdk' },
+ ],
+ },
+ {
+ target: '#connection.failure',
+ actions: {
+ type: 'assignError',
+ params: ({ event: { error } }: { event: ErrorActorEvent }) => ({ error }),
+ },
+ },
+ ],
+ }) as const
/**
* The lifecycle itself: what a connect, a restore, a lock and a disconnect mean, and the tags the
@@ -145,6 +215,11 @@ export const connectionMachine = setup({
},
actions: {
assignError: assign((_, params: { error: unknown }) => ({ lastConnectError: params.error })),
+ assignDeclined: assign((_, params: { connection: WalletStatusUpdate['connection'] }) => ({
+ lastConnectError: new Error(
+ params.connection.reason ?? params.connection.networkReason ?? 'wallet declined connection',
+ ),
+ })),
forgetError: assign({ lastConnectError: undefined }),
// The walked-out connect keeps waiting inside the old sdk and nothing can stop it, so a later
// attempt on that sdk could have its client swapped mid-connect. Drop the instance, take a new
@@ -226,56 +301,12 @@ export const connectionMachine = setup({
connecting: {
tags: ['connecting'],
entry: { type: 'forgetError' },
- invoke: {
- src: 'connect',
- input: ({ context }) => ({
- sdk: context.sdk,
- initOptions: context.initOptions,
- guardPicker: context.guardPicker,
- }),
- onDone: [
- landAuthenticated,
- {
- target: 'failure',
- actions: assign(({ event: { output } }) => ({
- lastConnectError: new Error(
- output.connection.reason ??
- output.connection.networkReason ??
- 'wallet declined connection',
- ),
- })),
- },
- ],
- onError: [
- {
- guard: { type: 'isPickerClosed', params: ({ event: { error } }) => ({ error }) },
- // Swapped before `retiring` is entered, so its init reads the replacement.
- actions: { type: 'retireSdk' },
- target: 'retiring',
- },
- {
- guard: { type: 'isInitFailed', params: ({ event: { error } }) => ({ error }) },
- target: 'failure',
- actions: [
- {
- type: 'assignError',
- // The wrapper marked the route; the consumer reads the SDK's own error.
- params: ({ event: { error } }) => ({
- error: error instanceof InitFailedError ? error.cause : error,
- }),
- },
- // The rejection is cached on the instance forever, so only a replacement can retry.
- { type: 'retireSdk' },
- ],
- },
- {
- target: 'failure',
- actions: {
- type: 'assignError',
- params: ({ event: { error } }) => ({ error }),
- },
- },
- ],
+ initial: 'new',
+ // The variants carry what is at stake: `new` risks no session, `changing` is a
+ // wallet change over a standing one, and a closed picker resumes that session.
+ states: {
+ new: { invoke: askWallet('#connection.retiring.new') },
+ changing: { invoke: askWallet('#connection.retiring.changing') },
},
on: {
// Leaving the state is not enough: sdk.connect() keeps running past this, so the wallet
@@ -354,7 +385,9 @@ export const connectionMachine = setup({
},
},
on: {
- // `connect` is deliberately not accepted over a standing session.
+ // A wallet change; without it a consumer whose wallet disconnected on its own has no way
+ // back, because that push cannot be told apart from a lock.
+ connect: { target: 'connecting.changing' },
disconnect: { target: 'disconnecting' },
// A replaced sdk leaves this session's listeners bound to the old client, and exiting
// `session` is what tears them down, so restore has to be accepted here too.
@@ -375,13 +408,26 @@ export const connectionMachine = setup({
tags: ['connect.cancelled'],
// A cancel records no error; clearing it here keeps that rule on the state that answers.
entry: { type: 'forgetError' },
- invoke: bootSdk,
+ initial: 'new',
+ // A cancelled wallet change must not cost the standing session: `changing` resumes it
+ // through `restoring.changing`; `new` had nothing to lose.
+ states: {
+ new: { invoke: bootSdk('#connection.restoring.new') },
+ changing: { invoke: bootSdk('#connection.restoring.changing') },
+ },
on: {
connect: { target: 'connecting' },
disconnect: { target: 'disconnecting' },
},
},
restoring: {
+ initial: 'new',
+ // Both variants run the same `restore` invoke below; they exist so `toConnectionStatus`
+ // can report `changing` as connecting and `new` as idle.
+ states: {
+ new: {},
+ changing: {},
+ },
invoke: {
src: 'restore',
input: ({ context }) => ({ sdk: context.sdk }),
@@ -394,7 +440,7 @@ export const connectionMachine = setup({
},
},
initializing: {
- invoke: bootSdk,
+ invoke: bootSdk('#connection.restoring.new'),
on: {
// A connect asked for during boot wins over the restore instead of being dropped; the
// connect actor inits and reads status itself, so a standing session still comes back.
diff --git a/canton-connect/src/testing/discoveryStorage.ts b/canton-connect/src/testing/discoveryStorage.ts
new file mode 100644
index 00000000..a25453da
--- /dev/null
+++ b/canton-connect/src/testing/discoveryStorage.ts
@@ -0,0 +1,24 @@
+// The SDK's localStorage footprint, so suites can seed a restorable session and
+// leave nothing behind for the next file's tests.
+
+const KERNEL_DISCOVERY_KEY = 'splice_wallet_kernel_discovery'
+const DISCOVERY_SESSION_KEY = 'splice_discovery_client_session'
+const SUGGESTED_ENTRIES_KEY = 'splice_wallet_picker_suggested_entries'
+const RECENT_GATEWAYS_KEY = 'splice_wallet_picker_recent'
+
+/** Mirrors what a real connect() persists to localStorage, so init() takes the restore path. */
+export const persistRestorableSession = (providerId: string): void => {
+ localStorage.setItem(
+ KERNEL_DISCOVERY_KEY,
+ JSON.stringify({ walletType: 'extension', providerId }),
+ )
+ localStorage.setItem(DISCOVERY_SESSION_KEY, JSON.stringify({ providerId }))
+}
+
+/** Removes every SDK key, the two only the SDK itself writes included. */
+export const clearDiscoveryStorage = (): void => {
+ localStorage.removeItem(KERNEL_DISCOVERY_KEY)
+ localStorage.removeItem(DISCOVERY_SESSION_KEY)
+ localStorage.removeItem(SUGGESTED_ENTRIES_KEY)
+ localStorage.removeItem(RECENT_GATEWAYS_KEY)
+}
diff --git a/canton-connect/src/testing/fakeSession.test.tsx b/canton-connect/src/testing/fakeSession.test.tsx
new file mode 100644
index 00000000..03699039
--- /dev/null
+++ b/canton-connect/src/testing/fakeSession.test.tsx
@@ -0,0 +1,23 @@
+import { act, renderHook } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { describe, expect, it } from 'vitest'
+import { useConnect } from '#src/hooks/useConnect'
+import { FakeSessionProvider } from '#src/testing/fakeSession'
+
+describe('FakeSessionProvider', () => {
+ it('forgets the connectError it was given on reset(), as the real provider does', () => {
+ const failed = new Error('wallet rejected')
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+ const { result } = renderHook(() => useConnect(), { wrapper })
+
+ expect(result.current.connectError).toBe(failed)
+
+ act(() => {
+ result.current.reset()
+ })
+
+ expect(result.current.connectError).toBeUndefined()
+ })
+})
diff --git a/canton-connect/src/testing/fakeSession.tsx b/canton-connect/src/testing/fakeSession.tsx
index 36906dca..53a2f2e6 100644
--- a/canton-connect/src/testing/fakeSession.tsx
+++ b/canton-connect/src/testing/fakeSession.tsx
@@ -1,21 +1,87 @@
-import type { DappSDK } from '@canton-network/dapp-sdk'
-import { type JSX, type ReactNode, useCallback, useMemo, useState } from 'react'
-import { CantonConnectContext, type CantonConnectContextValue } from '#src/CantonConnectProvider'
-import type { CantonConnectConfig, ConnectionStatus, Party } from '#src/types'
+import { type JSX, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
+import { createActor, type StateValue } from 'xstate'
+import { CantonConnectContext } from '#src/CantonConnectProvider'
+import { type ConnectionActorRef, connectionMachine } from '#src/machine/connectionMachine'
+import { connectionInput } from '#src/testing/connectionInput'
+import type {
+ CantonConnectConfig,
+ CantonConnectContextValue,
+ ConnectionStatus,
+ Party,
+ WalletSdk,
+} from '#src/types'
const CONFIG: CantonConnectConfig = { appName: 'fake-session' }
-// Anything past the connect flow needs a real wallet; a canned answer would read as one.
-const SDK = new Proxy({} as DappSDK, {
- get: (_, key) => {
- throw new Error(`fake session has no sdk.${String(key)} — drive the real provider for that`)
- },
-})
+// Module scope so an omitted prop keeps its identity across renders, which is what stops the
+// session from being rebuilt on every one.
+const NO_SDK: Partial = {}
+
+// Anything past the connect flow needs a real wallet; a canned answer would read as one. Safe as
+// machine context because a rehydrated snapshot carries no children, so no actor reaches for it.
+/** A `sdk` wrapper that throws naming the method for anything the test never stubbed. */
+const refusingSdk = (supplied: Partial): WalletSdk =>
+ new Proxy({} as WalletSdk, {
+ get: (_, key) => {
+ const method = supplied[key as keyof WalletSdk]
+
+ if (method === undefined) {
+ throw new Error(`fake session has no sdk.${String(key)} — drive the real provider for that`)
+ }
+
+ return method
+ },
+ })
+
+/** The session a test asks for, before it is turned into a machine state. */
+type SessionShape = {
+ isLocked: boolean
+ readingAccounts: boolean
+ status: ConnectionStatus
+}
+
+// State names outside the machine, which only a double gets to hold: pinning states is its whole
+// job, and every state it names is one the SDK would otherwise have to be driven into.
+/** Turns a `SessionShape` into the state value the real machine would hold for it. */
+const toStateValue = ({ isLocked, readingAccounts, status }: SessionShape): StateValue => {
+ if (status !== 'connected') {
+ return status
+ }
+
+ if (isLocked) {
+ return { session: 'unauthenticated' }
+ }
+
+ return { session: { authenticated: readingAccounts ? 'reading' : 'ready' } }
+}
+
+/** Starts a real `connectionMachine` actor rehydrated at the given `SessionShape`. */
+const startSession = (
+ shape: SessionShape,
+ party: Party | undefined,
+ connectError: Error | undefined,
+ sdk: WalletSdk,
+): ConnectionActorRef => {
+ const input = connectionInput({}, { createSdk: () => sdk })
+
+ const snapshot = connectionMachine.resolveState({
+ value: toStateValue(shape),
+ context: {
+ ...input,
+ sdk,
+ lastConnectError: connectError,
+ // The machine clears it on leaving `session`, so a party outside one cannot be published.
+ party: shape.status === 'connected' ? party : undefined,
+ },
+ })
+
+ return createActor(connectionMachine, { input, snapshot }).start()
+}
/**
- * Props for {@link FakeSessionProvider}. `party` is what a connect resolves to, and omitting it
- * stands for a wallet reporting none; `status` starts the session mid-flight, and its default of
- * `'disconnected'` makes a component's connect face render first.
+ * Props for {@link FakeSessionProvider}. `status` starts the session mid-flight and `party` is
+ * what a connect resolves to; `readingAccounts` reaches the pending face over a live session, and
+ * `sdk` drives a hook's own pending, error and `reset()`, never what a wallet returns.
*
* @category Components
*/
@@ -24,14 +90,18 @@ export interface FakeSessionProviderProps {
connectError?: Error
isLocked?: boolean
party?: Party
+ readingAccounts?: boolean
+ sdk?: Partial
status?: ConnectionStatus
}
/**
* Stands in for `CantonConnectProvider` with the session already in a given shape, so a component
- * test asserts on markup without paying the SDK's discovery sleeps or its connect flow. `connect`
- * and `disconnect` move the session, but reach for the real provider plus `createMockAdapter` to
- * test connecting itself — the intermediate states here are not the SDK's.
+ * test asserts on markup without paying the SDK's discovery sleeps or its connect flow. The shape
+ * is a real `connectionMachine` actor rehydrated at the state the props ask for, so the hooks
+ * select from it exactly as they do in the app. `connect` and `disconnect` move the session, but
+ * reach for the real provider plus `createMockAdapter` to test connecting itself — the intermediate
+ * states here are not the SDK's.
*
* @example
* render(
@@ -47,10 +117,22 @@ export const FakeSessionProvider = ({
connectError,
isLocked = false,
party,
+ readingAccounts = false,
+ sdk = NO_SDK,
status: initialStatus = 'disconnected',
}: FakeSessionProviderProps): JSX.Element => {
const [status, setStatus] = useState(initialStatus)
+ // Rebuilt from the props rather than moved by events, so the double stays declarative: a state
+ // it can name is a state a test can ask for, in one step and with no actor to drive.
+ const connection = useMemo(
+ () =>
+ startSession({ isLocked, readingAccounts, status }, party, connectError, refusingSdk(sdk)),
+ [connectError, isLocked, party, readingAccounts, sdk, status],
+ )
+
+ useEffect(() => () => connection.stop(), [connection])
+
const connect = useCallback(async (): Promise => {
setStatus('connected')
}, [])
@@ -62,17 +144,12 @@ export const FakeSessionProvider = ({
const value = useMemo(
() => ({
config: CONFIG,
- sdk: SDK,
- party: status === 'connected' ? party : undefined,
- status,
- isLocked,
- connectError,
- isConnecting: status === 'connecting',
- lastTx: undefined,
+ connection,
connect,
disconnect,
+ resetConnectError: () => connection.send({ type: 'connectError.reset' }),
}),
- [status, party, isLocked, connectError, connect, disconnect],
+ [connection, connect, disconnect],
)
return {children}
diff --git a/canton-connect/src/testing/renderSession.tsx b/canton-connect/src/testing/renderSession.tsx
new file mode 100644
index 00000000..1ee2a554
--- /dev/null
+++ b/canton-connect/src/testing/renderSession.tsx
@@ -0,0 +1,26 @@
+import { renderHook } from '@testing-library/react'
+import { CantonConnectProvider } from '#src/CantonConnectProvider'
+import { createAutoPicker } from '#src/testing/autoPicker'
+import type { CantonConnectConfig } from '#src/types'
+
+const DEFAULT_CONFIG: CantonConnectConfig = { appName: 'test', walletPicker: createAutoPicker() }
+
+/**
+ * Renders a hook inside a `CantonConnectProvider`, defaulted to the auto-picker config every
+ * provider test starts from; a test only states what it overrides.
+ *
+ * @example
+ * const { result } = renderSession(() => useSession())
+ * const { result } = renderSession(() => useSession(), { walletPicker: throwingPicker })
+ */
+export const renderSession = (
+ hook: () => Result,
+ config: Partial = {},
+) =>
+ renderHook(hook, {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
diff --git a/canton-connect/src/testing/startConnection.ts b/canton-connect/src/testing/startConnection.ts
new file mode 100644
index 00000000..e6622449
--- /dev/null
+++ b/canton-connect/src/testing/startConnection.ts
@@ -0,0 +1,10 @@
+import { type AnyActorLogic, createActor } from 'xstate'
+import type { ConnectionActorRef } from '#src/machine/connectionMachine'
+import { connectionInput } from '#src/testing/connectionInput'
+
+/** Boots a `connectionMachine` variant on the fake-input actor input, already started. */
+export const startConnection = (machine: AnyActorLogic): ConnectionActorRef => {
+ const actor = createActor(machine, { input: connectionInput() }) as unknown as ConnectionActorRef
+ actor.start()
+ return actor
+}
diff --git a/canton-connect/src/testing/throwingPicker.ts b/canton-connect/src/testing/throwingPicker.ts
new file mode 100644
index 00000000..986ba6ac
--- /dev/null
+++ b/canton-connect/src/testing/throwingPicker.ts
@@ -0,0 +1,6 @@
+import type { WalletPickerFn } from '@canton-network/dapp-sdk'
+
+/** A picker a test can call connect() with when it never intends to succeed. */
+export const throwingPicker: WalletPickerFn = async () => {
+ throw new Error('cancel')
+}
diff --git a/canton-connect/src/testing/useSession.ts b/canton-connect/src/testing/useSession.ts
new file mode 100644
index 00000000..e9910b10
--- /dev/null
+++ b/canton-connect/src/testing/useSession.ts
@@ -0,0 +1,40 @@
+import { useSelector } from '@xstate/react'
+import { useCantonConnectContext } from '#src/CantonConnectProvider'
+import { useConnect } from '#src/hooks/useConnect'
+import { useParty } from '#src/hooks/useParty'
+import { useWalletStatus } from '#src/hooks/useWalletStatus'
+import type { ConnectionStatus, Party, WalletSdk } from '#src/types'
+
+/** Every slice of the session in one object, which is what the suites assert against. */
+type Session = {
+ connect: () => Promise
+ connectError: Error | undefined
+ disconnect: () => Promise
+ isConnecting: boolean
+ isLocked: boolean
+ party: Party | undefined
+ reset: () => void
+ sdk: WalletSdk
+ status: ConnectionStatus
+}
+
+/**
+ * Everything the reader hooks publish, in one object, so a provider test drives the real SDK and
+ * asserts on the public surface. `sdk` rides along because no hook publishes it and a test watching
+ * a stranded instance get replaced has nothing else to watch.
+ *
+ * @example
+ * const { result } = renderHook(() => useSession(), { wrapper })
+ * await waitFor(() => expect(result.current.status).toBe('connected'))
+ */
+export const useSession = (): Session => {
+ const { connection } = useCantonConnectContext()
+
+ const { connect, connectError, disconnect, isConnecting, reset } = useConnect()
+ const { party, status } = useParty()
+ const { isLocked } = useWalletStatus()
+
+ const sdk = useSelector(connection, (snapshot) => snapshot.context.sdk)
+
+ return { connect, connectError, disconnect, isConnecting, isLocked, party, reset, sdk, status }
+}
diff --git a/canton-connect/src/testing/walletA.ts b/canton-connect/src/testing/walletA.ts
new file mode 100644
index 00000000..ea618588
--- /dev/null
+++ b/canton-connect/src/testing/walletA.ts
@@ -0,0 +1,9 @@
+import { createFakeWallet, type FakeWallet } from '#src/testing/fakeWallet'
+
+/** The fake wallet the connect/events/guards/restore provider tests connect through. */
+export const walletA = (): FakeWallet =>
+ createFakeWallet({
+ id: 'wallet-a',
+ target: 'wallet-a',
+ accounts: [{ partyId: 'alice::1220ab', primary: true }],
+ })
diff --git a/canton-connect/src/testing/walletLock.ts b/canton-connect/src/testing/walletLock.ts
new file mode 100644
index 00000000..dcd41577
--- /dev/null
+++ b/canton-connect/src/testing/walletLock.ts
@@ -0,0 +1,16 @@
+import type { FakeWallet } from '#src/testing/fakeWallet'
+
+/** The statusChanged wallet-a pushes on lock. */
+export const pushLock = (wallet: FakeWallet): void =>
+ wallet.push('statusChanged', {
+ provider: { id: 'wallet-a', providerType: 'browser' },
+ // Network stays up; only the wallet locks: proves the handler keys on isConnected alone.
+ connection: { isConnected: false, isNetworkConnected: true },
+ })
+
+/** The statusChanged wallet-a pushes on unlock. */
+export const pushUnlock = (wallet: FakeWallet): void =>
+ wallet.push('statusChanged', {
+ provider: { id: 'wallet-a', providerType: 'browser' },
+ connection: { isConnected: true, isNetworkConnected: true },
+ })
diff --git a/canton-connect/src/types.ts b/canton-connect/src/types.ts
index 5371aa72..1916bcbb 100644
--- a/canton-connect/src/types.ts
+++ b/canton-connect/src/types.ts
@@ -1,6 +1,11 @@
// Public types exposed to consumers of canton-connect.
-import type { DappSDK, ProviderAdapter, WalletPickerFn } from '@canton-network/dapp-sdk'
+import type {
+ DappSDK,
+ ProviderAdapter,
+ TxChangedEvent,
+ WalletPickerFn,
+} from '@canton-network/dapp-sdk'
import type { ConnectionActorRef } from '#src/machine/connectionMachine'
/**
@@ -80,9 +85,21 @@ export interface CantonConnectConfig {
additionalAdapters?: ProviderAdapter[]
}
+/**
+ * Mirrored from the SDK's `txChanged` event as a command moves through
+ * pending, signed, executed or failed.
+ *
+ * @category Types
+ */
+export interface TxStatusSnapshot {
+ status: TxChangedEvent['status']
+ commandId: TxChangedEvent['commandId']
+ payload?: unknown
+}
+
/**
* The connection machine as `useSelector` sees it: subscribe and read, never send. Narrowed from
- * the actor ref so `connect` and `disconnect` stay the only senders; a transition asked for
+ * the actor ref so `connect` and `disconnect` stay the only senders — a transition asked for
* anywhere else is a lifecycle rule living outside the machine.
*
* @example
@@ -93,3 +110,18 @@ export interface CantonConnectConfig {
* @category Types
*/
export type ConnectionSubscription = Pick
+
+/**
+ * One connection and the actions on it, published once. Every hook selects its slice off
+ * `connection`: prefer the narrower hooks and reach for this only when none exposes the slice.
+ * The three actions are `useConnect`'s own, documented there.
+ *
+ * @category Types
+ */
+export interface CantonConnectContextValue {
+ config: CantonConnectConfig
+ connection: ConnectionSubscription
+ connect: () => Promise
+ disconnect: () => Promise
+ resetConnectError: () => void
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5b32a7c4..97e6b298 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
canton-connect:
dependencies:
+ '@xstate/react':
+ specifier: ^6.1.0
+ version: 6.1.0(@types/react@19.2.18)(react@19.2.8)(xstate@5.32.6)
xstate:
specifier: ^5.32.5
version: 5.32.6
@@ -1708,6 +1711,15 @@ packages:
'@walletconnect/window-metadata@1.0.1':
resolution: {integrity: sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==}
+ '@xstate/react@6.1.0':
+ resolution: {integrity: sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ xstate: ^5.28.0
+ peerDependenciesMeta:
+ xstate:
+ optional: true
+
'@yuku-codegen/binding-android-arm64@0.8.7':
resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==}
cpu: [arm64]
@@ -3839,6 +3851,15 @@ packages:
uploadthing:
optional: true
+ use-isomorphic-layout-effect@1.2.1:
+ resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@@ -5847,6 +5868,16 @@ snapshots:
'@walletconnect/window-getters': 1.0.1
tslib: 1.14.1
+ '@xstate/react@6.1.0(@types/react@19.2.18)(react@19.2.8)(xstate@5.32.6)':
+ dependencies:
+ react: 19.2.8
+ use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.18)(react@19.2.8)
+ use-sync-external-store: 1.6.0(react@19.2.8)
+ optionalDependencies:
+ xstate: 5.32.6
+ transitivePeerDependencies:
+ - '@types/react'
+
'@yuku-codegen/binding-android-arm64@0.8.7':
optional: true
@@ -7884,6 +7915,12 @@ snapshots:
optionalDependencies:
idb-keyval: 6.3.0
+ use-isomorphic-layout-effect@1.2.1(@types/react@19.2.18)(react@19.2.8):
+ dependencies:
+ react: 19.2.8
+ optionalDependencies:
+ '@types/react': 19.2.18
+
use-sync-external-store@1.6.0(react@19.2.8):
dependencies:
react: 19.2.8