Skip to content

Model the canton-connect connection lifecycle as an XState machine #76

Description

@fernandomg

Objective

One place owns the canton-connect connection lifecycle: an XState machine where the invalid state combinations are unrepresentable by construction.

Rationale

CantonConnectProvider mirrors SDK state into ~10 useState fields written from six places. No single place owns the rules, so status: 'connected' with no party, isConnecting while disconnected, and an error beside a live session are all representable.

Five races were patched one at a time across #66, #67 and #63:

  1. re-entrant connect;
  2. disconnect racing a connect;
  3. unmount racing a connect;
  4. a late answer overwriting fresh state;
  5. and an SDK swap leaving stale state.

All five are the same shape, an effect outliving the state that started it, so the fixes do not compose.

Scope

In scope:

Out of scope:

  • Picker UI (lives in canton-dappbooster + canton-theme)
  • Workarounds for SDK gaps (recorded in docs/dapp-sdk-issues.md instead)
  • New SDK capabilities

Architecture & technical considerations

There is no official lifecycle to transcribe: the OpenRPC dApp API spec carries no state model, the dApp API page defers to the SDK docs, and no SDK reference doc exists beyond release notes. The chart is our model of dapp-sdk 1.4.0's verified behavior and changes when the SDK does.

Decision (2026-08-11): the wallet picker is a black-box seam. The SDK popup path reports no intermediate steps, so the machine models no picker phases; a custom picker honors the same interface: final answer only. This supersedes the connecting inner states of the original sketch.

stateDiagram-v2
    state session {
        [*] --> unauthenticated
        unauthenticated --> connected: wallet.statusChanged [isAuthenticated]
        connected --> unauthenticated: wallet.statusChanged [isNotAuthenticated]
        connected --> connected: wallet.statusChanged / wallet.accountsChanged / party.selected
    }
    [*] --> idle
    idle --> restoring: session.restore
    idle --> connecting: connect.requested
    restoring --> connected: onDone [isRestoredAuthenticated]
    restoring --> unauthenticated: onDone [hasRestoredSession]
    restoring --> idle: onDone
    restoring --> idle: connect.cancelled
    restoring --> failed: onError
    connecting --> connected: wallet.answered [isAuthenticated]
    connecting --> unauthenticated: wallet.answered
    connecting --> failed: wallet.errored
    connecting --> idle: wallet.disconnected / connect.cancelled
    session --> disconnecting: wallet.disconnected
    session --> failed: readAccounts onError
    disconnecting --> idle: onDone / onError
    failed --> idle: connect.reset
    failed --> connecting: connect.requested
Loading

Issue breakdown

Acceptance criteria

  • An xstate@5 machine owns the connection lifecycle, and the invalid combinations above are non representable by construction rather than guarded case by case
  • connect() publishes the connection state and also hands back an answer, and the wallet's pushes reach the dApp for exactly as long as a session exists
  • The five races above each have a regression test, and no test infers machine state from elapsed time

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions