Skip to content

feat(connect): add in-page wallet selection and remember the session wallet - #63

Closed
fernandomg wants to merge 9 commits into
fix/57-connect-lifecyclefrom
feat/51-discovered-wallets-and-parties
Closed

feat(connect): add in-page wallet selection and remember the session wallet#63
fernandomg wants to merge 9 commits into
fix/57-connect-lifecyclefrom
feat/51-discovered-wallets-and-parties

Conversation

@fernandomg

@fernandomg fernandomg commented Aug 1, 2026

Copy link
Copy Markdown
Member

Summary

Closes #51

Top of the #51 stack (#47, #48, #66, #67, this). #66 landed useParties() and the party mapping;
#67 the #57 lifecycle fixes. This closes #51's other half: the offered wallet list on the public
surface, opt-in in-page selection, the session's wallet surviving a reload, and a guard on the
SDK-instance identity.

Changes

  • Opt-in in-page wallet selection: walletSelection: 'in-page' publishes the offered entries and a
    select/cancel pair through useWalletPicker(), so a dApp can draw its own chooser. The
    default popup path is untouched, and an explicit walletPicker still wins.
  • Cancellation carries a reason (user / disconnect / unmount) so a killed attempt cannot write
    state after the thing that killed it finished.
  • useParty() gains wallet: which wallet the session belongs to, surviving a reload.
  • A replacement SDK instance that restores no session now resets connection state.
  • disconnect() settles even when a selected wallet never answers: the kill is recorded on the
    attempt itself and a condemnation race settles the caller, so a wallet window closed without
    answering can no longer deadlock disconnect() (found by hand in the harness, see Manual
    verification). The pre-undraft review pass hardened the same fences: the in-page picker fails
    closed after a kill (a choice can no longer open over the clean slate), cancel-recovery
    re-checks its fate after every await, and a mount restore yields to an attempt in flight.
  • A comment sweep over canton-connect/src, and README/architecture coverage of wallet selection,
    its limits, and the picker seam.

Acceptance criteria

From #51 (two criteria landed in #66 and are listed here for the closing picture):

Diverged from #51 as written: #50's criteria say the picker wires through the existing
walletPicker seam and that canton-connect needs no new API. This routes it through a new mode
and hook instead, deliberately: a React panel is rendered and waits, where that seam wants a
function that answers. #50 needs its criteria updated before it is picked up.

#47 review threads answered here

Thread Answered by
SDK rebuilt on inline config identity, leaving stale connected state 51c096a

The other two threads (unhandled startup rejections; the error half of rejected-lands-as-locked)
are answered by #67.

Accepted limits

Documented rather than worked around, because every available workaround means guessing at wallet
state we cannot observe:

  • A failure after selection reports the wrong error. The SDK nulls its client and its retry
    helper throws "Wallet picker is not open", swallowing the real cause.
  • No wallet list before a connect attempt. The SDK builds the entries inside connect() and
    exposes them nowhere else.
  • Popup mode reports no wallet identity. By choice: observing the SDK's own popup would mean a
    direct dependency on its UI bundle.

An earlier draft of this PR listed a fourth limit: a cancelled attempt costing the user their
persisted session. The harness walkthrough disproved it; see the correction under Manual
verification.

Stopgaps, with their removal triggers

  • canton-connect:connected-wallet in localStorage. We remember which wallet a session
    belongs to only because getConnectedProvider() discards the session's provider id. Remove it
    the moment the SDK exposes the restored session's provider id.
    The record is never
    authoritative; the SDK's session is, and the record is deleted whenever no session backs it.
  • Provider state shape. The transitions are named helpers over useState, not a reducer. That
    was analysed and deliberately deferred. Revisit trigger: a seventh piece of work touching these
    transitions
    , then fold status/party/parties/isLocked/connectedWallet into one reducer
    with named transitions, keeping lastTx, offeredWallets and the refs outside it.

Test plan

Automated tests

pnpm -C canton-connect test: 57 to 85 tests (the stack as a whole takes the suite from 38). The
ones that matter here are the concurrency cases the old suite structurally could not catch:

  • disconnect() during a pending choice ends disconnected with no error and no party
  • disconnect() while a selected wallet never answers resolves, resets, and rejects the caller
  • a later connect() after abandoning a never-answering wallet starts a fresh attempt, and a late
    answer from the abandoned one writes no state
  • a choice arriving after disconnect() killed the attempt never opens
  • disconnect() landing during cancel-recovery keeps the clean slate
  • a restore resolving during a connect attempt leaves no wiring behind
  • unmounting mid-choice settles the attempt with no unhandled rejection
  • a replacement SDK instance mid-session resets, while a StrictMode first mount stays idle

Root gate at undraft time: lint, typecheck, build, knip clean; 387 tests across five suites, exit
0. That total includes the two node:test suites (91 and 83) whose output a Tests N grep
silently hides. Shipped-surface check: dist resolves for both the root and ./testing entries,
useParties/useWalletPicker/ConnectedWallet are in dist/index.d.ts, and the JSDoc survived
into the declarations; runtime behaviour of the built bundle is what the harness exercised (the SDK
graph is browser-only, so a plain-Node smoke import cannot execute it). #59 tracks committing this
check.

Manual verification

Task 12 walked the harness (Vite serving the built dist, the real SDK, the mock adapter,
walletSelection: 'in-page') through the six planned flows:

  1. Connect renders the offered entries in-page (mock, walletconnect); the window.open
    instrumentation stayed silent, so no popup opened.
  2. Selecting the mock wallet connects: party is the primary, parties holds only allocated
    accounts (an initialized one is dropped), and useParty().wallet names the wallet.
  3. Reload restores the session, the parties, and the wallet name (read back from the
    canton-connect:connected-wallet record).
  4. Cancelling a choice re-enables connect immediately; connectError reads "Wallet selection
    cancelled" and the caller's connect() rejected with that same error.
  5. Cancel-then-reload: the user is not logged out; see the correction below.
  6. Disconnect with the chooser open ends disconnected, no error, no party; the killed connect()
    rejects to its caller.
  7. Off-script, with a real extension wallet: selecting it and closing the wallet's own window
    without answering left the attempt pending forever, and disconnect() deadlocked awaiting it —
    the page was stuck on "connecting" with no recovery. Fixed in fc696cf (see Changes); the
    wallet-side half (an extension should reject pending requests when its window closes) belongs to
    the wallet, not this package.

Correction to a claimed limit. The draft body and the plan said a cancelled attempt costs the
user their persisted session because connect() clears the SDK's persisted state before the picker
opens. Observed instead: connect() clears only the kernel-session hints
(splice_wallet_kernel_session, splice_wallet_kernel_discovery); the discovery session record
(splice_discovery_client_session) and the live in-memory session both survive a cancelled
attempt. Cancelling a chooser opened over a connected session recovers to connected in-page, and a
reload after the cancel restores the session for any adapter implementing restore(), which all
three SDK adapters do.

A transitional state worth knowing: while an attempt is open over a live session, isConnected
reads false with party/wallet still populated until the attempt settles.

Caveat: createMockAdapter() implements no restore(), so the reload flows were driven through a
harness-local wrapper that persists the mock's connection and answers restore(); the package path
exercised (init, discovery restore, status, listAccounts, record read) is the real one. #69 tracks
giving the mock opt-in persistence so the shim can be retired.

Breaking changes

None. walletSelection defaults to 'popup', which is exactly today's behaviour.

Checklist

  • Self-reviewed my own diff
  • Tests added or updated
  • Docs updated (if applicable)
  • No unrelated changes bundled in

Screenshots

None.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@fernandomg fernandomg changed the title feat(connect): expose the wallet list, in-page selection and multi-party reads feat(connect): add in-page wallet selection and remember the session wallet Aug 3, 2026
@fernandomg
fernandomg changed the base branch from docs/42-jsdoc-public-api to fix/57-connect-lifecycle August 3, 2026 05:56
@fernandomg
fernandomg marked this pull request as ready for review August 3, 2026 08:32
Copilot AI review requested due to automatic review settings August 3, 2026 08:32
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR closes the second half of #51 in canton-connect. It adds an opt-in in-page wallet selection mode (walletSelection: 'in-page') that publishes the SDK's offered wallet entries and a select/cancel pair through a new useWalletPicker() hook, letting a dApp render its own chooser instead of the SDK popup. It also remembers which wallet a session belongs to across reloads (useParty().wallet, backed by a canton-connect:connected-wallet localStorage record), and hardens the connect/disconnect lifecycle so a killed attempt can no longer write state or deadlock disconnect().

Changes:

  • New walletSelection: 'in-page' mode + useWalletPicker() hook; explicit config.walletPicker still takes precedence, popup remains the default (non-breaking).
  • Attempt-scoped cancellation model (cancelledBy + condemn) so disconnect()/unmount settle a never-answering wallet, and a late/abandoned answer writes nothing; a replacement SDK instance that restores nothing resets state.
  • useParty() gains wallet (persisted via connectedWallet.ts); comment sweep plus README/architecture coverage of the picker seam and its documented limits.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.

Show a summary per file
File Description
canton-connect/src/CantonConnectProvider.tsx Core: in-page picker bridge, attempt condemnation/cancellation, remember/forget wallet, replacement-instance reset.
canton-connect/src/connectedWallet.ts New localStorage read/write/clear for the session's wallet label, with a defensive type guard.
canton-connect/src/hooks/useWalletPicker.ts New hook exposing the pending in-page choice (isOpen, wallets, select, cancel).
canton-connect/src/hooks/useParty.ts Adds wallet to the result; wagmi mapping note updated.
canton-connect/src/types.ts New ConnectedWallet type and walletSelection config field.
canton-connect/src/index.ts Barrel exports useWalletPicker and ConnectedWallet.
canton-connect/src/hooks/useParties.ts, useConnect.ts, mock/mockAdapter.ts, walletAccount.ts Comment/JSDoc sweep; no behavioral change.
canton-connect/src/connectedWallet.test.ts New unit tests for the record round-trip and corrupt/partial reads.
canton-connect/src/CantonConnectProvider.test.tsx Large suite of concurrency/lifecycle tests for the new modes and cancellation races.
canton-connect/README.md, architecture.md Document wallet selection, precedence, and the documented limits.

I found no concrete, blocking defects: the cancellation/condemnation logic correctly attaches handlers to avoid unhandled rejections, the remember/forget ordering tracks the SDK session as the authority, and the new behavior is backed by extensive tests. However, this is a large, intricate concurrency change to the core connection lifecycle whose runtime behavior was verified by hand in a harness (the SDK graph is browser-only and not installed here), which makes it a poor candidate for automated sign-off.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- walletSelection: 'in-page' hands the wallet choice to the dApp's own UI
- the provider keeps the picker's resolve/reject pair and publishes the offered entries
- useWalletPicker() exposes isOpen/wallets/select/cancel; select answers the SDK
- an explicit walletPicker still wins over the mode; the default popup path is untouched
- select with an unoffered id rejects the attempt with a plain Error naming the id
- ConnectedWallet type defined for the wallet-identity task that follows
…d connect attempt

- record why a pending choice was cancelled: user, disconnect, or unmount
- connect()'s failure path recovers only after a user cancel; disconnect and unmount just re-throw
- disconnect() cancels the choice, waits for the in-flight attempt to settle, then resets
- a choice arriving while disconnect or unmount kills the attempt rejects instead of pending
- unmount cancels the pending choice, so the attempt settles without unhandled rejections
- fold the init-failure path onto resetToDisconnected(err)
- new connectedWallet.ts: the record under canton-connect:connected-wallet
- written only when an attempt lands: connect success or the probe ending connected-and-locked
- restored sessions read the record; a probe finding no session deletes it, as does disconnect()
- a supplied walletPicker is wrapped only to note its result; the wrapper identity stays stable
- corrupt or partial records read as absent, never throw
- useParty() gains wallet (wagmi: useAccount().connector); popup mode reports none by design
- an inline walletPicker or additionalAdapters rebuilds the SDK per re-render (kept, documented)
- a replacement instance whose restore probe finds nothing now resets status and party too
- replacement keys on instance identity, so a StrictMode re-run never resets a first mount
- an idle provider stays idle across swaps: only non-idle state is stale enough to clear
- pin in-page mode: rerenders keep one SDK instance and a single init()
- tests: dispose each fake wallet when its role ends, so a failing assertion leaks no listeners
- delete comments that restate the identifier below them: file headers, toParty, toParties
- shorten every survivor running past ~100 characters
- remove a stray /** that doubled the walletPicker JSDoc opener
…seam

- add useParties, useWalletPicker, and walletSelection to the README tables
- render connectError outside the isConnected branch in the quickstart
- state that a rejected wallet is indistinguishable from a locked one from outside the SDK
- note that an inline walletPicker or additionalAdapters rebuilds the SDK every render
- state the two limits: wallet list only during an attempt, no wallet identity in popup mode
- architecture: the themed picker is issue #50; describe the in-page context seam
…g state

- a wallet window closed without answering leaves the connect RPC pending
  forever; disconnect() awaited that attempt and deadlocked before any reset.
  The kill is now recorded on the attempt itself and a condemnation race
  settles the caller, so disconnect cleans up immediately (found by hand in
  the harness with a real extension wallet, task 12)
- the same fences close what the review pass then found: an in-page choice
  arriving after disconnect no longer opens over the clean slate (the picker
  fails closed and each choice dooms its own attempt); the cancel-recovery
  probe re-checks its fate after every await instead of only at catch entry;
  a mount restore resolving mid-attempt yields instead of wiring beneath it
- guards runConnect's success path too, so a late wallet answer writes no
  state and never evicts a newer attempt
- also braces the single-line guards and trues up the JSDoc the rework left
  stale
- the config identity note claimed additionalAdapters churn rebuilds the SDK;
  only walletPicker does, a new adapters array re-runs init() on the instance
- the mount bullet now carries the replacement-instance rule from 51c096a
- useConnect's disconnect JSDoc says it settles an unanswered connect
@gabitoesmiapodo
gabitoesmiapodo force-pushed the feat/51-discovered-wallets-and-parties branch from c3f7c30 to f293a46 Compare August 3, 2026 16:34
Comment on lines +481 to +485
const result = await sdk.connect() // opens the picker
// A condemned attempt may still get the wallet's answer later; it owns no state by then.
if (attempt.cancelledBy !== undefined) {
throw new UserRejectedError('Connect attempt cancelled')
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(CC feedback)

Disconnect leaves a live SDK session behind

disconnect() (L587-L600) condemns the attempt and runs sdk.disconnect() while the wallet's connect RPC is still pending. When the wallet answers later, sdk.connect() resolves and establishes a session; runConnect sees cancelledBy and throws here without tearing that session down. Measured after disconnect + late answer:

sdk.status() → {"connection":{"isConnected":true,...}}
localStorage → splice_discovery_client_session, splice_wallet_kernel_discovery

UI reads disconnected, SDK is connected, and a reload restores the session the user just left. This is the exact scenario 928423b set out to fix; the existing test (a late answer from an abandoned attempt writes no state) asserts only React state.

Reproduced against this branch with a silent-wallet probe (the same createSilentWallet shape the suite already uses) asserting sdk.status() rather than React state.

Comment on lines +459 to +470
cancelAttempt('unmount')
teardownWiring()
}
}, [sdk, additionalAdapters, syncFromStatus, teardownWiring])
}, [
sdk,
additionalAdapters,
syncFromStatus,
teardownWiring,
cancelAttempt,
resetToDisconnected,
forgetConnectedWallet,
])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(CC feedback)

Any re-render with inline additionalAdapters kills an in-flight connect

The mount effect's cleanup now calls cancelAttempt('unmount'). Its deps include additionalAdapters, so a fresh array identity re-runs the effect and aborts the pending choice. Probe result: one rerender() while the picker was open → rejected: Wallet selection cancelled, picker closed. Previously an identity churn only re-ran init(); now it destroys the user's connect. The README note about memoizing is no longer sufficient given the new consequence.

Reproduced against this branch: provider rendered with config={{ appName, walletSelection: 'in-page', additionalAdapters: [adapter] }} written inline, connect() started, picker open, then a single rerender().

@gabitoesmiapodo
gabitoesmiapodo self-requested a review August 3, 2026 17:59

@gabitoesmiapodo gabitoesmiapodo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manual testing

  • ✅ Connect renders the offered wallet entries in-page with no popup
  • ✅ picker.wallets is empty before any connect attempt
  • ✅ select() connects: party is the primary, parties are usable-only, wallet is named
  • ✅ Reload restores the session, the parties and the wallet name
  • ✅ Cancel settles the attempt, re-enables Connect, and rejects the caller with the same error
  • ✅ Cancel-then-reload keeps the session
  • ✅ Cancelling a chooser opened over a live session recovers to connected
  • ✅ A chooser over a live session reads isConnected: false with party and wallet still populated
  • ✅ Disconnect with the chooser open ends disconnected, no error, no party, caller rejects
  • ✅ select("not-offered") rejects naming the id, not as a cancellation
  • ✅ A second connect() joins the first, only one choice opens
  • ✅ Unmounting mid-choice rejects connect() with no unhandled rejection
  • ✅ walletSelection: 'popup' opens the SDK popup and no in-page choice
  • ✅ An explicit walletPicker wins over in-page mode
  • ✅ Disconnect clears the connected-wallet record
  • ✅ Clearing storage leaves no stale wallet record
  • ✅ A replacement SDK instance that restores nothing resets connection state
  • ✅ A mount restore landing during an attempt leaves consistent state
  • ✅ A rejection after selection surfaces the documented Wallet picker is not open limit
  • ❌ Disconnect while a selected wallet has not answered leaves no live session. Expected result: after disconnect() resolves, no session survives: sdk.status() reports isConnected: false or throws, no splice_discovery_client_session or splice_wallet_kernel_discovery keys remain in localStorage, and reloading the page lands disconnected.
    • Load the dApp with walletSelection: 'in-page' and a CIP-0103 extension wallet whose origin has not been approved yet.
    • Click Connect and select the extension wallet from the in-page list.
    • When the wallet's approval prompt appears, leave it unanswered.
    • In the dApp, click Disconnect.
    • Return to the wallet and approve the still-pending request.
    • Call sdk.status() and inspect localStorage.
    • Reload the page.
  • ❌ A parent re-render with inline additionalAdapters does not kill the connect attempt. Expected result: The in-page choice opens and stays open across parent re-renders, and selecting a wallet completes the connection.
    • Render CantonConnectProvider with walletSelection: 'in-page' and additionalAdapters written as an inline array literal in JSX, so its identity is fresh on every parent render.
    • Make the parent component re-render on ordinary activity during a connect (state update, spinner, toast, log line).
    • Click Connect.
    • Observe whether the in-page choice opens.
    • If it opens, trigger one parent re-render while it is open.

Also left a few code review comments.

Image

@fernandomg

Copy link
Copy Markdown
Member Author

put on hold, recalibrating

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose the discovered wallet list and a useParties() hook

3 participants