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 detailSpliceAnnounceDetail:

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.

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 discovery is wired for the CIP-103 push events out of the box. SpliceProviderBase auto-attaches the transport’s inbound notification listener to its emitter, so events arrive through the standard .on() API. 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 whose target does not match the discovered provider’s target 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().