Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8930526
refactor(connect): the provider runs the machine instead of mirroring it
fernandomg Aug 26, 2026
796f912
feat(connect): bridges turn a transition into a promise
fernandomg Aug 26, 2026
d30eb9f
refactor(connect): every hook selects its own slice off the connection
fernandomg Aug 26, 2026
cf8edfe
test(connect): doubles that stand a session up without an sdk
fernandomg Aug 26, 2026
7b8e5bb
test(connect): the bridges settle on the tag they wait for
fernandomg Aug 26, 2026
d0b50a9
test(connect): the provider suites, split by concern
fernandomg Aug 26, 2026
2b9ad34
docs(connect): a chapter for the popup close guard
fernandomg Aug 26, 2026
88047ab
fix(connect): wallet-side refusals reach consumers as Errors, not JSO…
fernandomg Aug 26, 2026
196ae50
docs(connect): the hook docs say what the machine does about locks, c…
fernandomg Aug 26, 2026
1a0669e
fix(connect): the fake session forgets its connectError on reset(), a…
fernandomg Aug 26, 2026
eeb4761
fix(connect): the disconnect bridge waits only on disconnect.settled
fernandomg Aug 27, 2026
26b4a4c
docs(connect): the package docs say what it does, not how xstate works
fernandomg Aug 26, 2026
5dad87f
docs: an architecture index may carry chapters in a sibling folder
fernandomg Aug 26, 2026
cce61d8
docs(connect): the bridges section says which wait the machine bounds
fernandomg Aug 26, 2026
9c545c3
docs(connect): the chapter stops scoping itself to the machine PR
fernandomg Aug 28, 2026
19b2dc5
Merge branch 'feat/76-doubles' into feat/76-provider
fernandomg Aug 31, 2026
cfb7931
docs(connect): the import rule stops naming the extracted canton-bare…
fernandomg Aug 31, 2026
cc3342c
Merge branch 'feat/76-doubles' into feat/76-provider
fernandomg Aug 31, 2026
1cac7e1
test(connect): the restore suite follows the ignored connect
fernandomg Aug 31, 2026
c76c9e2
Merge branch 'feat/76-doubles' into feat/76-provider
fernandomg Aug 31, 2026
0c8218c
docs(connect): the connection chapter skims what the machine file owns
fernandomg Aug 31, 2026
9912c67
Merge branch 'feat/76-doubles' into feat/76-provider
fernandomg Aug 31, 2026
f45c9c5
Merge branch 'feat/76-doubles' into feat/76-provider
fernandomg Aug 31, 2026
ca6bb6f
Merge branch 'main' into feat/76-provider
fernandomg Aug 31, 2026
b0a19ae
fix(connect): a connect over a standing session runs as a wallet change
fernandomg Aug 31, 2026
cddcd0d
Merge branch 'main' into feat/76-provider
fernandomg Aug 31, 2026
7a723a7
Merge remote-tracking branch 'origin/main' into feat/76-provider
fernandomg Sep 1, 2026
b1eb829
Merge remote-tracking branch 'origin/feat/76-provider' into feat/76-p…
fernandomg Sep 1, 2026
a07e822
chore: rebuild the lockfile as main's plus @xstate/react
fernandomg Sep 1, 2026
6e78cee
docs(connect): drop member captions from result and props types
fernandomg Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
2 changes: 1 addition & 1 deletion architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
115 changes: 68 additions & 47 deletions canton-connect/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 `<Context.Provider>{children}</Context.Provider>` — 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
`<Context.Provider>`, 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.
Loading