diff --git a/CLAUDE.md b/CLAUDE.md index a67bb00e..18f058d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ Current distribution: | Scope | README | AGENTS | CLAUDE | architecture | Decision | |-------|--------|--------|--------|--------------|----------| | root | yes | shim | yes | yes | Canonical repo rules and cross-component seams. | -| `canton-connect/` | yes | shim | yes | yes, plus `architecture/` | Public hook API, the facade's adapter/picker seams, provider event wiring; a chapter for the connection machine. | +| `canton-connect/` | yes | shim | yes | yes, plus `architecture/` | Public hook API, the machine-owned lifecycle, the picker/adapter seams; chapters for the connection machine and the popup close guard. | | `dapp/frontend/` | yes | shim | yes | yes | Canton Coin vesting dApp; `CLAUDE.md` carries the page-owns-its-components layout and the naming rules an agent would otherwise get wrong, architecture.md its internal seams. Carries a `PROVENANCE.md` recording the vendored source. | | `dapp/daml/` | yes | no | no | no | Single DAML package (`amulet-vesting`), vendored source, built here. Carries a `PROVENANCE.md` recording the source commit and the two integration deltas. | | `canton-dappbooster/` | yes | shim | yes | yes | L2 headless components; `CLAUDE.md` carries the folder-per-component layout an agent would otherwise get wrong, architecture.md the authoring seam (anatomy contract, L2/L3 split, Zag boundary). | diff --git a/architecture.md b/architecture.md index dfb45261..6c58fc46 100644 --- a/architecture.md +++ b/architecture.md @@ -15,7 +15,7 @@ Each subproject's `architecture.md` is the index of its own seams. A subsystem that needs more than a seam gets a chapter in a sibling `architecture/` folder, linked from that index; `canton-connect/` -carries one today, the connection machine. +carries two today. ## Data Flow diff --git a/canton-connect/CLAUDE.md b/canton-connect/CLAUDE.md index e967f733..35ac52d9 100644 --- a/canton-connect/CLAUDE.md +++ b/canton-connect/CLAUDE.md @@ -1,60 +1,81 @@ -# Agent Configuration — canton-connect +# Agent Configuration: canton-connect -This file applies only to `canton-connect/`. For monorepo-wide rules, see [`../CLAUDE.md`](../CLAUDE.md). Deltas only below. +Applies only to `canton-connect/`. Repo-wide rules: [`../CLAUDE.md`](../CLAUDE.md). Deltas only. ## Scope -`canton-connect` is a thin React wrapper over `@canton-network/dapp-sdk`'s `DappSDK` facade, -exposing a stable wagmi-style hook surface. The SDK owns discovery, the picker, the session, and -all transports. Browser-only. A stopgap, meant to stay cheap to delete. +A thin React layer over `@canton-network/dapp-sdk`'s `DappSDK`, which owns discovery, the picker, +the session and the transports. Browser-only, and built to stay cheap to delete. ## Working rules -- **Wrap the facade; don't rebuild it.** `CantonConnectProvider` holds one `DappSDK` instance and drives `init`/`connect`/events. Do not reintroduce hand-rolled connectors, a `ConnectorProvider` type, or a connector abstraction — the facade replaced all of that. -- **Two lifecycle models live here for now; the machine replaces the provider's in #85.** `machine/connectionMachine.ts` is the connection lifecycle, internal, unwired, driven only by its own tests; `CantonConnectProvider.tsx` still owns the state the hooks read until #85 swaps it over. Put a new lifecycle rule in the machine, not in both, and never "sync" them: a rule living in two places is #76's original disease. -- **Import the SDK's types; never hand-copy them, and drop casts.** Hook params are the SDK's own (`PrepareExecuteParams`, `LedgerApiParams`); event names come from `@canton-network/core-types` (`WalletEvent`, `CANTON_*_PROVIDER_EVENT`; today only `testing/fakeWallet.ts` uses them). A `param as Parameters<…>` cast means you duplicated a type the SDK already exports: delete the duplicate, import the real type. -- **Teardown before the client swaps.** `sdk`'s `onX`/`removeOnX` bind to the current `this.client`, and `sdk.connect()` swaps it. Remove listeners *before* a connect (then re-wire after), or they leak on the old client. Keep the mount/connect/disconnect teardown paths consistent. -- **The picker is a config seam.** `CantonConnectConfig.walletPicker` — omit for the SDK popup; inject `createAutoPicker()` in tests, a themed component later. Don't wire a picker UI into this package; UI lives in `canton-dappbooster` + `canton-theme`. -- **Bumping `dapp-sdk` means a manual browser pass on the close path.** Two of `guardedConnect`'s assumptions are non-public SDK internals no test can pin: that the picker window comes from `window.open`, and the shape of the `SPLICE_WALLET_PICKER_RESULT` message it both reads and posts. Serve `dapp/frontend` and walk three cases. Close the picker without choosing, three times over: the button must re-enable each time, and a following real connect must raise exactly one approval prompt. Choose an extension, then close the picker: the button must stay pending and the connect must complete when the wallet is answered. Then repeat both on a wallet that reuses the popup the SDK left open (a gateway or WalletConnect one), where a close after choosing *must* fail the connect. Why in [`architecture.md`](architecture.md). -- **Never import `@canton-network/core-wallet-ui-components`.** It is `dapp-sdk`'s private popup layer, and declaring it to reach `pickWallet` puts a second copy of its module-level popup state in the store, which kills the SDK's retry prompt with `"Wallet picker is not open"`. This rules out reusing the SDK's picker component; it does not rule out writing our own. -- **The mock adapter answers the connect flow only.** `createMockAdapter()` implements `connect`/`disconnect`/`status`/`listAccounts` and throws naming the method for anything else. Don't extend it to fake `execute` or `signMessage` — a canned result there is indistinguishable from a real wallet's. -- **Keep hooks thin.** Read `CantonConnectProvider` context or delegate straight to a facade method. Shared state transitions belong in `CantonConnectProvider.tsx`. -- **The hook and config surface is documented in JSDoc, and nowhere else.** No hook table and no - config table in `README.md`: root [`CLAUDE.md`](../CLAUDE.md) puts reference material out of a - README, and both are generated from the doc blocks now. A table copied beside the code drifts from - it, and a reader who trusted the copy has no way to tell. Which wallets the picker offers is - decided by three fields, so `CantonConnectConfig` is where that is written down: - `walletConnectProjectId`, `walletPicker`, `additionalAdapters`. -- Keep it app-agnostic: no imports from `dapp/`; name no wallet. -- Internal modules are reached through this package's `#src/*` subpath imports, never a relative path, and imports carry **no** file extension. No semicolons, single quotes (root Biome). Terse why-only comments; vertical breathing room between logical groups. - -## Layout deltas from the root rules - -- **The lifecycle lives in `src/machine/`; the other modules sit at `src/` root.** `machine/` holds `connectionMachine`, `connectionActors`, `accountsMachine`, `accountsActors` and nothing else. `walletAccount`, `connectError` and `guardedConnect` are one flat layer under `src/`; a new one joins them rather than starting a `utils/`. The root rule's kind folders here are `hooks/`, `machine/`, `testing/` and `mock/`. -- **`CantonConnectProvider.tsx` lives at `src/` root, not in `components/`.** It renders only `{children}` — no markup, no visible state, no `ref` — so it is context infrastructure, and the component-authoring rules in [`../CLAUDE.md`](../CLAUDE.md) (a11y state exposure, `ref` as an ordinary prop, role-based tests) do not apply to it. Agreed on PR #45, which deliberately left this package out of its sweep. -- **`src/testing/` is a published subpath export** (`./testing` in `package.json`), unlike `canton-dappbooster`'s package-local `src/testing/`. The root rule that `testing/` is never imported from non-test code still holds here and is enforced by Biome; the export exists because the fake wallet is useful to *other* packages' test suites. - -## The machine - -- `setup()` with parameterized actions and guards. Every actor reads its sdk off the invoke's input, so leaving the state stops it and drops its listener. -- A state carries a tag for each question it can already answer, and none while an answer is pending. The tag union in `connectionMachine.ts` is the contract; [`architecture/connection-machine.md`](architecture/connection-machine.md) is the reference. When the two disagree, fix the chapter. -- The one delay, `disconnectTimeout`, is driven in tests by xstate's `SimulatedClock`. No test waits on wall-clock time. -- `testing/connectionInput.ts` and `testing/accountsInput.ts` build inputs whose sdk methods never answer; a test overrides the one it needs. +- **Wrap the facade.** One `DappSDK`, in the machine's context. No connectors, no + `ConnectorProvider`, no connector abstraction. +- **Lifecycle rules live in `machine/`**, never a second copy in React. The account read is + `accountsMachine`, invoked inside `session.authenticated`. +- **The sdk is machine context**, built by the input's `createSdk` and rebuilt by `retireSdk` + wherever an instance is poisoned. Never React state. +- **Input is read once, at actor creation.** A changed `config` prop reaches the hooks, not the + lifecycle; remount the provider (`key`) to change it. +- **A state carries a tag for what it means to the outside.** The tags union in + `machine/connectionMachine.ts` is the authority, and no other module names a state. A state that + answers an operation must carry its tag or the bridge waits forever: `waitFor` is unbounded here + (#105). +- **A state's `exit` clears what that state alone justified.** `party` is cleared on leaving + `session.authenticated`, because a wallet that stops serving requests has none to offer, and a + lock cannot be told from a wallet-side disconnect. `sdk` has no exit; nothing outlives it. +- **Listeners register only inside a state's `invoke`.** `sdk.onX` binds to the current client and + `sdk.connect()` swaps it. +- **The provider selects nothing.** It publishes the config, the actor and three actions; each hook + selects its own slice. Never add a field a hook could select. +- **Publish the narrowest type.** `ConnectionSubscription` puts `send` out of reach; `WalletSdk` + narrows `DappSDK` to the methods this package calls. +- **React owns two things:** `lastTx` (`useExecute`) and the `toConnectError` memo (`useConnect`). + Anything else that looks like state belongs in the machine. +- **Import the SDK's types.** A `param as Parameters<…>` cast is a duplicated type: import the real + one from `dapp-sdk` or `core-types`. +- **The picker is `CantonConnectConfig.walletPicker`.** No picker UI in this package; that lives in + `canton-dappbooster` and `canton-theme`. +- **Never import `@canton-network/core-wallet-ui-components`.** A second copy of its module-level + popup state breaks the SDK's retry prompt with `"Wallet picker is not open"`. +- **The mock adapter answers the connect flow only.** A canned `execute` or `signMessage` would be + indistinguishable from a real wallet's. +- **The hook and config surface is documented in JSDoc, and nowhere else.** The published reference + is generated from it, and a table beside the code drifts from it unnoticed. +- **App-agnostic.** No imports from `dapp/`; name no wallet. -## Testing +## Bumping `dapp-sdk` + +`guardedConnect` rests on two SDK internals no test can pin: that the picker window comes from +`window.open`, and the shape of the `SPLICE_WALLET_PICKER_RESULT` message it reads and posts. Why: +[`architecture/popup-close-guard.md`](architecture/popup-close-guard.md). Serve `dapp/frontend` and +walk all four: -- `pnpm -C canton-connect test` — **vitest + jsdom** (not `node:test`). -- Drive the real facade with the test doubles in `src/testing/`: `createFakeWallet` (a real CIP-0103 extension over postMessage) + `createAutoPicker` (headless picker), both exported on the `./testing` sub-path. -- **Test our seam, not the SDK's internals.** Discovery, pairing, the popup, session restore are the SDK's (trusted dependency). Cover: config → adapters → picker entries → connected state → events reaching the hooks. -- **Success paths test headless; connect-failure paths don't** — the facade's failure/retry calls a popup helper that throws without a popup window. Don't write a connect-failure test expecting a clean rejection. +1. Close the picker without choosing, three times over: the button re-enables each time, and the + next real connect raises exactly one approval prompt. +2. Choose an extension, then close the picker: the button stays pending, and the connect completes + when the wallet answers. +3. Both of those again on a wallet that reuses the popup the SDK left open (a gateway or + WalletConnect one), where a close after choosing must fail the connect. +4. Check that `new DappSDK()` still only initializes fields (true on 1.5.1). The machine constructs + one inside a plain `assign`; if construction turns effectful, move the ritual into the provider's + `createSdk` and dispose the abandoned instance on the same transition, never from an effect. -## Architecture +## Layout -See [`architecture.md`](architecture.md) for the facade wrapper, the picker/adapter seams, the event flow, and the teardown invariant. +- `machine/` holds the lifecycle: both machines and their actors. `connectError`, `guardedConnect`, + `walletAccount` and `types` stay flat at `src/`, and a new leaf module joins them; no `utils/`. +- `CantonConnectProvider/` is the provider plus the bridges it composes. It renders only + ``, 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