wagmi-style React hooks for connecting Canton dApps to CIP-0103 wallets, over
@canton-network/dapp-sdk's DappSDK facade.
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.
@partylayer/react 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 the hook results relate to wagmi's is in
coming-from-wagmi.md.
The connection lifecycle looks like four states (idle, connecting, connected,
disconnected) and isn't. The hard part isn't holding state, it's canceling work
when the state that started it is gone: a picker the user abandoned, a lock
that races the account read, a connect asked for mid-disconnect, a wallet that
answers late or never. Handled one at a time these were five separate races
(#76). A state machine folds them into one model: a state's invoked work is
canceled when the state is left, and the combinations that used to be bugs
(connected with no party, an error beside a live session) are states that name
their case: session.unauthenticated, session.authenticated.unavailable.
Most of that weight works around @canton-network/dapp-sdk gaps, not domain
complexity (below). When those close upstream, this layer collapses to its floor
(idle-vs-disconnected, the account-read states, the CIP-0103 lock/disconnect
ambiguity), small enough that a lighter store wins on bundle size. A spike
reimplementing it on zustand confirmed this: full behavioral parity, but the win
is bundle size and one fewer dependency, not less logic, and it points to
switching only once those gaps close. Until then, the machine earns its cost.
| dapp-sdk gap | what it costs us | gone when |
|---|---|---|
connect() can't be aborted; a closed popup hangs it (#49) |
guardedConnect, settleAbandonedConnect, PickerClosedError, the retiring state, retireSdk's picker swap |
connect(signal) truly aborts |
init() caches a rejected promise forever |
retireSdk, the retiring state, InitFailedError |
init() retries after a failure |
disconnect() has no timeout (#105) |
DISCONNECT_TIMEOUT_MS, and retireSdk when it fires |
disconnect() times out itself |
| lock and wallet-side disconnect are one push | session.unauthenticated, party-dropped-on-lock |
CIP-0103 separates them (spec, not SDK) |
Published to npm as @bootnodedev/canton-connect. Also consumed by dapp/frontend in this repo,
which links the local folder while its version satisfies the declared range.
Peers a consumer installs beside it: @canton-network/dapp-sdk, @canton-network/core-types,
react 19 and @walletconnect/sign-client. The last is required here, though dapp-sdk marks it
optional: 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.
import {
CantonConnectProvider,
useConnect,
useAccount,
useWalletStatus,
useSignMessage,
useExecute,
useLedger,
} from '@bootnodedev/canton-connect'
function App() {
return (
<CantonConnectProvider config={{ appName: 'My dApp', networkId: 'canton:local' }}>
<Dapp />
</CantonConnectProvider>
)
}
function Dapp() {
const { connect, isPending, isConnected, error } = useConnect()
const { account } = useAccount()
const { isLocked } = useWalletStatus()
const { signMessage } = useSignMessage()
const { execute } = useExecute()
const { ledgerApi } = useLedger()
if (!isConnected) {
return (
<div>
<button onClick={() => connect().catch(() => undefined)} disabled={isPending}>
Connect
</button>
{error !== undefined && <p>{error.message}</p>}
</div>
)
}
if (isLocked) {
return <p>Wallet locked. Unlock it to continue.</p>
}
// ... your dApp: account.partyId, signMessage(text), execute(params), ledgerApi(params)
}
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 error records it as well depends on which side saw
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
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 useAccount().account is undefined for the duration:
gate session content on the party, and use isLocked only to explain why it went away.
import { RemoteAdapter } from '@canton-network/dapp-sdk'
const config = {
appName: 'My dApp',
additionalAdapters: [
new RemoteAdapter({ name: 'Gateway', rpcUrl: 'http://localhost:3030/api/v0/dapp' }),
],
}
The dapp-sdk wallet picker also lets a user paste any gateway URL and connect to it without the dApp listing it. That session does not survive a reload; one with a gateway listed here does.
Details in architecture.md.
const config = {
appName: 'My dApp',
networkId: 'canton:devnet',
walletConnectProjectId: 'YOUR_REOWN_PROJECT_ID',
}
networkId is the Canton network the dApp targets, as a CAIP-2 chain id.
Details in architecture.md.
Every hook and every config field is documented in JSDoc, which your editor surfaces at the call
site and which is published at
docs.dappbooster.cc. Start at
CantonConnectProvider and CantonConnectConfig.
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.@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.import { CantonConnectProvider, createMockAdapter } from '@bootnodedev/canton-connect'
import { createAutoPicker } from '@bootnodedev/canton-connect/testing'
const config = {
appName: 'My dApp',
additionalAdapters: [createMockAdapter()],
walletPicker: createAutoPicker(),
}
architecture.md
maps the seams; its architecture/ chapters carry the connection machine and the popup close guard.
pnpm test: vitest + jsdom + Testing Library. pnpm coverage runs the same suite under v8, with
testing/, mock/ and the barrel excluded.