Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions canton-connect/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ the session and the transports. Browser-only, and built to stay cheap to delete.
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.
- **The machine owns the session; a hook owns what it asked for.** `sdk`, `party`, status and the
last connect error are machine context, never React state. A call's result or in-flight flag
(`lastTx`, a signature) is React state: the session does not depend on it.
- **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
Expand Down
4 changes: 3 additions & 1 deletion canton-connect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ the close, so do not gate on that.
`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.
`signMessage` and `execute` also refuse with no party, which `ledgerApi` does not need. The
SDK's status carries one `isConnected` flag, so a lock and a wallet-side disconnect look the same
reference gateway also refuses `signMessage` for a local party; `usePartyType().readPartyType()`
tells local from external when you ask. 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.

Expand Down
14 changes: 12 additions & 2 deletions canton-connect/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ The context value is the config, the actor as `ConnectionSubscription` (`send` i
through it, so the bridges stay the only senders) and four identity-stable actions. Each hook
selects its own slice, which is wagmi's shape: `WagmiProvider` publishes, `useAccount` subscribes
itself. `useConnect`, `useDisconnect`, `useParty` and `useWalletStatus` read session state;
`useLedger`, `useExecute` and `useSignMessage` select a guard plus the sdk and call it directly,
never entering the machine.
`useLedger`, `useExecute`, `useSignMessage` and `usePartyType` 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
Expand All @@ -113,6 +113,16 @@ init actor passes `defaultAdapters: []`, dropping the SDK's bundled `localhost:3
`networkId` (default `'canton:local'`) is both the WalletConnect `chainId` and the fallback
`Party.networkId` for a wallet that reports none.

### The party type

A party under the hosting participant's namespace is local, any other is external. A dApp cares
because the reference gateway refuses `signMessage` for a local party. CIP-0103 has no field for
it, so `usePartyType().readPartyType` derives it when the consumer asks, never in the machine: one
`ledgerApi` read of the participant id (`GET /v2/parties/participant-id`, open to a `CanActAs`
token), its namespace compared with `Party.namespace`, which arrives from the wallet unchanged, as
`signingProviderId` does. A failed read rejects; what follows is the consumer's call. `isLocal` on
the parties endpoint means hosted here, external parties included, so it is not the signal.

### Testing doubles

`createFakeWallet` is a real CIP-0103 extension over `postMessage`, so a test walks the SDK's own
Expand Down
1 change: 1 addition & 0 deletions canton-connect/coming-from-wagmi.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The hook names follow wagmi, so a developer arriving from it knows which one to
| `useAccount().address` | `useParty().party.partyId` | A Canton identity is a party. |
| `useAccount().addresses`, `.connector`, `.chain` | none | Not exposed yet. |
| none | `useWalletStatus().isLocked` | Connected-but-locked is a CIP-0103 state. |
| none | `usePartyType().readPartyType()`, resolving `'local'` or `'external'` | Canton parties come in two kinds and the reference gateway refuses `signMessage` for a local one; wagmi has one kind of account. |
| `useWriteContract` then `useWaitForTransactionReceipt` | `useExecute().execute`, resolving after execution | The wallet submits and waits; one call covers both. |
| none | `useExecute().lastTx` | The wallet pushes `pending`, `signed`, `executed`, `failed` as it goes; wagmi has no hook returning a stream. |
| `useSignMessage().data`, a hex string | `useSignMessage().signature` | The name says the type. |
Expand Down
1 change: 1 addition & 0 deletions canton-connect/doc-fixtures.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type FixtureElement = import('react').ReactElement

declare const partyId: string
declare const PARTY: string
declare const party: import('#src/types').Party
declare const error: Error | undefined

/* Consumer-side wiring */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ 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 { testParty } from '#src/testing/party'
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 party = testParty('alice::1220ab')

const readingAccounts = (read: () => Promise<WalletAccounts>) =>
accountsMachine.provide({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import type {
RestoreInput,
} from '#src/machine/connectionActors'
import { connectionMachine, type WalletStatusUpdate } from '#src/machine/connectionMachine'
import { testParty } from '#src/testing/party'
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 party = testParty('alice::1220ab')

const accounts = accountsMachine.provide({
actors: {
Expand Down
3 changes: 2 additions & 1 deletion canton-connect/src/hooks/useExecute.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import type { ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useExecute } from '#src/hooks/useExecute'
import { FakeSessionProvider } from '#src/testing/fakeSession'
import { testParty } from '#src/testing/party'
import type { Party, WalletSdk } from '#src/types'

const party = { partyId: 'alice::1220ab', networkId: 'canton:local' }
const party = testParty('alice::1220ab')

const executed: PrepareExecuteAndWaitResult = {
tx: {
Expand Down
3 changes: 2 additions & 1 deletion canton-connect/src/hooks/useLedger.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ 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 { testParty } from '#src/testing/party'
import type { WalletSdk } from '#src/types'

const party = { partyId: 'alice::1220ab', networkId: 'canton:local' }
const party = testParty('alice::1220ab')
const request: LedgerApiParams = { requestMethod: 'get', resource: '/v2/parties' }

describe('useLedger', () => {
Expand Down
86 changes: 86 additions & 0 deletions canton-connect/src/hooks/usePartyType.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// The rule over a session that answers; a refusal reaches the caller as the rejection it was.

import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { usePartyType } from '#src/hooks/usePartyType'
import { FakeSessionProvider } from '#src/testing/fakeSession'
import { testParty } from '#src/testing/party'
import type { Party, WalletSdk } from '#src/types'

const party = testParty('alice::1220ab')

const liveSession = (sdk: Partial<WalletSdk>, connectedParty: Party | undefined) => ({
wrapper: ({ children }: { children: ReactNode }) => (
<FakeSessionProvider party={connectedParty} sdk={sdk} status="connected">
{children}
</FakeSessionProvider>
),
})

type LedgerAnswer = Awaited<ReturnType<WalletSdk['ledgerApi']>>

const answering = (answer: LedgerAnswer) =>
vi.fn<WalletSdk['ledgerApi']>().mockResolvedValue(answer)

describe('usePartyType', () => {
it('reads the participant id and calls a party under its namespace local', async () => {
const ledgerApi = answering({ participantId: 'participant::1220ab' })
const { result } = renderHook(() => usePartyType(), liveSession({ ledgerApi }, party))

expect(result.current.isReady).toBe(true)

await act(async () => {
await expect(result.current.readPartyType()).resolves.toBe('local')
})

expect(ledgerApi).toHaveBeenCalledTimes(1)
expect(ledgerApi).toHaveBeenCalledWith({
requestMethod: 'get',
resource: '/v2/parties/participant-id',
})
})

it('calls a party under another namespace external', async () => {
const ledgerApi = answering({ participantId: 'participant::1220ff' })
const { result } = renderHook(() => usePartyType(), liveSession({ ledgerApi }, party))

await act(async () => {
await expect(result.current.readPartyType()).resolves.toBe('external')
})
})

it('hands a refusal back as the rejection it was', async () => {
const refused = new Error('RPC error: -32601 - method not allowed')
const ledgerApi = vi.fn<WalletSdk['ledgerApi']>().mockRejectedValue(refused)
const { result } = renderHook(() => usePartyType(), liveSession({ ledgerApi }, party))

await act(async () => {
await expect(result.current.readPartyType()).rejects.toBe(refused)
})
})

it('rejects an answer carrying no participant id', async () => {
const ledgerApi = answering({ parties: [] })
const { result } = renderHook(() => usePartyType(), liveSession({ ledgerApi }, party))

await act(async () => {
await expect(result.current.readPartyType()).rejects.toThrow(
'participant id not found in {"parties":[]}',
)
})
})

it('refuses over a session that reports no party, without asking the ledger', async () => {
const ledgerApi = answering({ participantId: 'participant::1220ab' })
const { result } = renderHook(() => usePartyType(), liveSession({ ledgerApi }, undefined))

expect(result.current.isReady).toBe(false)

await act(async () => {
await expect(result.current.readPartyType()).rejects.toThrow('wallet reports no usable party')
})

expect(ledgerApi).not.toHaveBeenCalled()
})
})
71 changes: 71 additions & 0 deletions canton-connect/src/hooks/usePartyType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useSelector } from '@xstate/react'
import { useCallback } from 'react'
import type { CantonConnectProvider } from '#src/CantonConnectProvider'
import { assertUsable, useWalletCall } from '#src/hooks/useWalletCall'
import type { PartyType, WalletSdk } from '#src/types'

const namespaceOf = (id: string): string | undefined => /::(.+)$/.exec(id)?.[1]

const readParticipantNamespace = async (sdk: WalletSdk): Promise<string> => {
const answer = await sdk.ledgerApi({
requestMethod: 'get',
resource: '/v2/parties/participant-id',
})
const namespace =
typeof answer.participantId === 'string' ? namespaceOf(answer.participantId) : undefined

if (namespace === undefined) {
throw new Error(`participant id not found in ${JSON.stringify(answer)}`)
}

return namespace
}

/**
* Return shape of {@link usePartyType}. `readPartyType` throws when nothing is connected or no
* party is reported, which `isReady` is there to check first.
*
* @category Hooks
*/
export interface UsePartyTypeResult {
readPartyType: () => Promise<PartyType>
isReady: boolean
}

/**
* Tells a local party from an external one when asked. Each `readPartyType` call is one
* `ledgerApi` read of the participant id, its namespace compared with the party's; nothing is
* cached, so hold the answer where several components need it. Reach for it before an action a
* local party cannot take, such as `signMessage`, which the reference gateway refuses.
*
* @throws with no {@link CantonConnectProvider} above it, and from `readPartyType` where nothing
* is connected or no party is reported, which `isReady` is there to check first.
*
* @example
* const { readPartyType } = usePartyType()
* if ((await readPartyType()) === 'local') {
* toast.error('This wallet cannot sign messages for a local party')
* }
*
* @category Hooks
*/
export const usePartyType = (): UsePartyTypeResult => {
// Guards without `call`: a stateless query needs no pending/error renders around it.
const { connection, sdk, status, isLocked } = useWalletCall()
const party = useSelector(connection, (snapshot) => snapshot.context.party)

const readPartyType = useCallback(async (): Promise<PartyType> => {
assertUsable(status, isLocked)

if (party === undefined) {
throw new Error('wallet reports no usable party - allocate one in the wallet')
}

const participantNamespace = await readParticipantNamespace(sdk)

// Canton's rule, not a wallet's: a local party shares the participant's namespace.
return party.namespace === participantNamespace ? 'local' : 'external'
}, [isLocked, party, sdk, status])

return { readPartyType, isReady: status === 'connected' && !isLocked && party !== undefined }
}
3 changes: 2 additions & 1 deletion canton-connect/src/hooks/useSignMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ 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 { testParty } from '#src/testing/party'
import type { Party, WalletSdk } from '#src/types'

const party = { partyId: 'alice::1220ab', networkId: 'canton:local' }
const party = testParty('alice::1220ab')

const liveSession = (sdk: Partial<WalletSdk>, connectedParty: Party | undefined) => ({
wrapper: ({ children }: { children: ReactNode }) => (
Expand Down
3 changes: 3 additions & 0 deletions canton-connect/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type { LedgerApiParams, UseLedgerResult } from '#src/hooks/useLedger'
export { useLedger } from '#src/hooks/useLedger'
export type { UsePartyResult } from '#src/hooks/useParty'
export { useParty } from '#src/hooks/useParty'
export type { UsePartyTypeResult } from '#src/hooks/usePartyType'
export { usePartyType } from '#src/hooks/usePartyType'
export type { UseSignMessageResult } from '#src/hooks/useSignMessage'
export { useSignMessage } from '#src/hooks/useSignMessage'
export type { UseWalletStatusResult } from '#src/hooks/useWalletStatus'
Expand All @@ -36,6 +38,7 @@ export type {
ConnectionStatus,
ConnectionSubscription,
Party,
PartyType,
TxStatusSnapshot,
WalletSdk,
} from '#src/types'
5 changes: 3 additions & 2 deletions canton-connect/src/machine/accountsMachine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import { connectionMachine, type WalletStatusUpdate } from '#src/machine/connect
// DOM.
import { accountsInput } from '#src/testing/accountsInput'
import { connectionInput } from '#src/testing/connectionInput'
import { testParty } from '#src/testing/party'
import { pause } from '#src/testing/pause'

const connection: WalletStatusUpdate['connection'] = { isConnected: true, isNetworkConnected: true }
const party = { partyId: 'alice::1220ab', networkId: 'canton:local' }
const party = testParty('alice::1220ab')
const accounts: WalletAccounts = { party }
const pushedParty = { partyId: 'bob::1220cd', networkId: 'canton:local' }
const pushedParty = testParty('bob::1220cd')

const sessionWith = (accountsLogic: typeof accountsMachine) =>
connectionMachine.provide({
Expand Down
3 changes: 2 additions & 1 deletion canton-connect/src/machine/connectionMachine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ import {
// Not the '#src/testing' barrel: it re-exports fakeSession, whose Lit-backed SDK import needs a
// DOM.
import { connectionInput } from '#src/testing/connectionInput'
import { testParty } from '#src/testing/party'
import { pause } from '#src/testing/pause'

const connection: WalletStatusUpdate['connection'] = { isConnected: true, isNetworkConnected: true }
const party = { partyId: 'alice::1220ab', networkId: 'canton:local' }
const party = testParty('alice::1220ab')

// Provided wherever a recorded sequence walks through a session: the real read reaches an sdk
// double that never answers, which would park those sequences in `reading`.
Expand Down
2 changes: 1 addition & 1 deletion canton-connect/src/testing/fakeSession.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export interface FakeSessionProviderProps {
*
* @example
* render(
* <FakeSessionProvider status="connected" party={{ partyId: PARTY, networkId: 'canton:local' }}>
* <FakeSessionProvider status="connected" party={party}>
* <ConnectButton />
* </FakeSessionProvider>,
* )
Expand Down
9 changes: 9 additions & 0 deletions canton-connect/src/testing/party.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Party } from '#src/types'

/** A `Party` for tests; the namespace is the id's suffix, as the fake and mock wallets report. */
export const testParty = (partyId: string, networkId = 'canton:local'): Party => ({
partyId,
networkId,
namespace: partyId.split('::')[1] ?? partyId,
signingProviderId: 'test',
})
15 changes: 13 additions & 2 deletions canton-connect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,25 @@ export type ConnectionStatus =
| 'disconnected'

/**
* The connected account, normalized from the wallet's CIP-0103 account entry. `networkId` is the
* wallet's own, falling back to `CantonConnectConfig.networkId` where the wallet reports none.
* Canton's terms: a local party lives under the hosting participant's namespace and the participant
* signs for it; an external party lives under its own key's and signs for itself.
*
* @category Types
*/
export type PartyType = 'local' | 'external'

/**
* The connected account, normalized from the wallet's CIP-0103 account entry. `networkId` falls
* back to `CantonConnectConfig.networkId` where the wallet reports none. `namespace` and
* `signingProviderId` come through as reported; CIP-0103 names no `signingProviderId` values.
*
* @category Types
*/
export interface Party {
partyId: string
networkId: string
namespace: string
signingProviderId: string
name?: string
publicKey?: string
}
Expand Down
Loading
Loading