Skip to content

Provider Discovery

Provider discovery lets a dApp enumerate the Canton wallet providers available in the page and let the user pick one — instead of assuming a single injected window.canton. It is sigilry’s answer to the multi-wallet problem, modeled on EIP-6963 (multi-injected provider discovery) and adapted to Canton’s window.canton provider shape.

Discovery is a symmetric pair of window CustomEvents, exactly like EIP-6963:

  1. A wallet (browser extension) announces itself by dispatching canton:announceProvider carrying its detail.
  2. A dApp requests providers by dispatching canton:requestProvider; every wallet listening re-announces in response.

Because either side may load first, both events are always live: the dApp announces a request on mount, and wallets also announce eagerly on injection. The dApp accumulates whatever arrives.

import { requestProviders } from "@sigilry/dapp/discovery";
// Listen for announcements and trigger a request for any wallet already present.
const unsubscribe = requestProviders((detail) => {
console.log("announced:", detail.rdns, detail.name);
});
// Later, when the picker unmounts:
unsubscribe?.();

What a wallet announces vs. what a dApp sees

Section titled “What a wallet announces vs. what a dApp sees”

A wallet announces a flat detail — SpliceAnnounceDetail:

type SpliceAnnounceDetail = {
id: string; // provider id (e.g. the extension id)
name: string; // human label, e.g. "Send Connect"
icon: `data:image/${string}`; // inline icon
target: string; // the postMessage target the provider listens on
rdns: string; // reverse-DNS identity, e.g. "it.send.connect"
uuid: string; // stable per-announcement id
};

rdns and uuid are required on the published type — a wallet author must supply both. A pre-discovery wallet that announces without them is normalized at the store boundary instead (see Fallback semantics), not through the public type.

The discovery store normalizes each detail on arrival into a SpliceProviderInfo and pairs it with a lazy provider factory — a DiscoveredWallet:

type SpliceProviderInfo = {
uuid: string;
rdns: string;
name: string;
icon: `data:image/${string}`;
};
type DiscoveredWallet = {
info: SpliceProviderInfo;
getProvider(opts?: TransportOptions): SpliceProvider;
};

info.rdns is the lookup key — store.findProvider({ rdns }) resolves a wallet by it. The store dedupes announcements by info.uuid, not by rdns: two entries that share an rdns but differ in uuid can coexist unless a higher tier coalesces them. Well-known rdns values:

rdnsMeaning
it.send.connectSend Connect (the reference wallet extension).
canton.injectedThe legacy window.canton exposed as a discovered wallet (fallback, below).
canton.legacyA pre-discovery wallet that announced without rdns/uuid; normalized.

For non-React consumers, drive discovery through the store:

import { createDiscoveryStore } from "@sigilry/dapp/discovery";
const store = createDiscoveryStore();
// React to the live provider set; `emitImmediately` fires once with the current snapshot.
const unsubscribe = store.subscribe(
(wallets, meta) => {
render(wallets); // wallets: readonly DiscoveredWallet[]
console.log(meta?.added, meta?.removed);
},
{ emitImmediately: true },
);
// Look one up by identity, then build a live provider from it.
const wallet = store.findProvider({ rdns: "it.send.connect" });
const provider = wallet?.getProvider(); // a SpliceProvider bound to that target
// Teardown removes the window listeners.
store.destroy();

getProvider(opts?) is createProvider(detail, opts) under the hood: it constructs a SpliceProvider whose transport is bound to the announced target, so calls and events route to that specific wallet. The optional opts is a TransportOptions — pass a longer timeout for approval-gated calls (prepareExecuteAndWait, signMessage) that a user may take longer than the default 30s to approve; omit it to keep the 30s default. The announced target is always authoritative and cannot be overridden through opts. The canton.injected fallback (below) is the exception: its getProvider() returns the in-page window.canton object itself and ignores opts.

Discovery is announce-first, so it degrades cleanly for pages with a single legacy wallet:

  • If any provider announces, the store reflects exactly the announced set.
  • If no provider announces but window.canton is present, the store exposes it as a single DiscoveredWallet with rdns: "canton.injected" — so a dApp written against discovery still finds the injected wallet with no special-casing.
  • A wallet that announces without rdns/uuid (a pre-discovery build) is normalized to rdns: "canton.legacy" with a deterministic uuid derived from its target, so it stays discoverable and dedupes stably.

This is why a dApp can adopt discovery unconditionally: the single-wallet and zero-discovery cases both resolve to a usable provider.

A provider obtained from an announced wallet is wired for the CIP-103 push events out of the box. createProvider (which getProvider() calls for announced wallets) attaches its WindowTransport’s inbound notification listener to the provider’s emitter, so events arrive through the standard .on() API. The subscription is lazy: it starts with the first listener and stops when the last one is removed. SpliceProviderBase does not do this on its own — a custom subclass must call the protected attachEventTransport(transport) once it has a transport. The CIP-103 §4.2.2 sync event set is accountsChanged, statusChanged, and txChanged; connected is an additional CIP-103 login-flow event (not one of the §4.2.2 three) that the same channel delivers:

const provider = store.findProvider({ rdns: "it.send.connect" })?.getProvider();
provider?.on("statusChanged", (status) => {
/* §4.2.2 ongoing status change */
});
provider?.on("accountsChanged", (accounts) => {
/* §4.2.2 active-account / session change */
});
provider?.on("connected", (status) => {
/* login-flow completion (login event, not §4.2.2) */
});

Push frames are delivered over the same transport as requests, disambiguated by the JSON-RPC id: a request carries an id, a notification does not. Frames carrying a different target than the discovered provider’s are dropped, so a page with multiple wallets never cross-delivers events. See CIP-103 Conformance for the normative event semantics.

@sigilry/react wraps all of the above: useDiscovery() returns the live DiscoveredWallet[], WalletPicker renders the selection UI, and passing the chosen provider to CantonReactProvider via its controlled provider prop binds the rest of the hooks to that wallet. Push events surface as onStatusChanged / onConnected / onAccountsChanged subscriptions on useCanton().