PartyLayerDocs
Try Demo

CIP-0103: the Canton dApp Standard, implemented

CIP-0103 is the Canton dApp Standard, the specification for how wallets and dApps communicate on the Canton Network. PartyLayer fully implements CIP-0103 with 10 methods, 4 events, and a typed error model.

For which wallets declare CIP-0103 native support, with the evidence recorded for each, see the Canton wallet directory.

ℹ️ Note
Sources. Semantics on this page come from the CIP-0103 specification. Request and response shapes were read from the published type declarations of @canton-network/core-wallet-dapp-rpc-client at version 1.11.0, which the standard's authors ship, verified 2026-09-03. That version is pinned here on purpose: it is the one claim on this page that can go stale without anything telling us, so it is stated where a reader can judge its age. For the reasoning behind the standard rather than its mechanics, read Canton's own Scaling Canton Apps and Canton Unlocks the Wallet Stack rather than a paraphrase here. Where those sources and this SDK disagree, the divergences section says so.

Two Integration Paths

PartyLayer supports two ways to integrate:

  • Adapter SDK (recommended), Use PartyLayerKit and React hooks. The SDK abstracts CIP-0103 behind a higher-level API.
  • Native CIP-0103 Provider, Work directly with the CIP-0103 provider interface. Useful for non-React apps or when you need raw CIP-0103 compliance.

Provider API

The CIP-0103 provider uses a JSON-RPC-style request() method:

typescript
interface CIP0103Provider {
  request<T>(args: { method: string; params?: unknown }): Promise<T>;
  on<T>(event: string, listener: (data: T) => void): CIP0103Provider;
  emit<T>(event: string, ...args: T[]): boolean;
  removeListener<T>(event: string, listener: (data: T) => void): CIP0103Provider;
}

10 Mandatory Methods

connect

Establish a connection to the wallet.

typescript
const result = await provider.request<CIP0103ConnectResult>({
  method: 'connect',
});
// → { isConnected: true, isNetworkConnected: true }

disconnect

typescript
await provider.request({ method: 'disconnect' });

isConnected

typescript
const status = await provider.request<CIP0103ConnectResult>({
  method: 'isConnected',
});
// → { isConnected: true/false }

status

Get full provider status including connection, provider info, network, and session.

typescript
const status = await provider.request<CIP0103StatusEvent>({
  method: 'status',
});
// → { connection: {...}, provider: { id, version, providerType }, network?: {...}, session?: {...} }

getActiveNetwork

Get the active network in CAIP-2 format.

typescript
const network = await provider.request<CIP0103Network>({
  method: 'getActiveNetwork',
});
// → { networkId: 'canton:da-mainnet', ledgerApi: '...', accessToken: '...' }

listAccounts

typescript
const accounts = await provider.request<CIP0103Account[]>({
  method: 'listAccounts',
});
// → [{ primary: true, partyId: '...', status: 'allocated', ... }]

getPrimaryAccount

typescript
const account = await provider.request<CIP0103Account>({
  method: 'getPrimaryAccount',
});
// → { primary: true, partyId: '...', publicKey: '...', status: 'allocated' }

signMessage

typescript
const result = await provider.request<{ signature: string }>({
  method: 'signMessage',
  params: { message: 'Hello Canton!' },
});
// → { signature: '0x...' }

prepareExecute

Prepare and submit a Daml command for execution.

typescript
await provider.request({
  method: 'prepareExecute',
  params: {
    commands: [{ templateId: '...', choiceId: '...', argument: {...} }],
  },
});

ledgerApi

Proxy requests to the Canton Ledger API through the wallet.

typescript
const result = await provider.request<CIP0103LedgerApiResponse>({
  method: 'ledgerApi',
  params: {
    requestMethod: 'POST',
    resource: '/v2/state/active-contracts',
    body: JSON.stringify({
      filter: {
        filtersByParty: {
          [partyId]: {
            inclusive: {
              templateFilters: [{ templateId: 'Splice.Amulet:Amulet' }],
            },
          },
        },
      },
    }),
  },
});

4 Events

statusChanged

Emitted when the provider status changes.

typescript
provider.on('statusChanged', (status: CIP0103StatusEvent) => {
  console.log('Connection:', status.connection.isConnected);
  console.log('Provider:', status.provider.id);
});

accountsChanged

typescript
provider.on('accountsChanged', (accounts: CIP0103Account[]) => {
  console.log('Accounts:', accounts.map(a => a.partyId));
});

txChanged

Transaction lifecycle events (pending → signed → executed or failed).

typescript
provider.on('txChanged', (event: CIP0103TxChangedEvent) => {
  switch (event.status) {
    case 'pending':
      console.log('TX pending:', event.commandId);
      break;
    case 'signed':
      console.log('TX signed:', event.payload.signature);
      break;
    case 'executed':
      console.log('TX executed:', event.payload.updateId);
      break;
    case 'failed':
      console.log('TX failed:', event.commandId);
      break;
  }
});

connected

Emitted when an async connect completes.

typescript
provider.on('connected', (result: CIP0103ConnectResult) => {
  console.log('Async connect completed:', result.isConnected);
});

Where the upstream client and this SDK diverge

This section exists because we are probably the only people positioned to notice. We wrote the conformance suite and took wallets through it, so the gaps below come from comparing three things that should agree and do not entirely: the specification's method table, the type declarations shipped by @canton-network/core-wallet-dapp-rpc-client (read at version 1.11.0), and this SDK.

A documented divergence is more useful than a false consensus. None of these is a defect in anyone's code; they are places where the standard leaves room and implementations took different readings.

1. prepareExecuteAndWait is in the client, not in the spec's method table

The upstream RpcTypes map declares prepareExecuteAndWait as a first-class key returning { tx: TxChangedExecutedEvent }. The specification's synchronous method table does not list it: there, prepareExecute returns void and the result arrives as a txChanged event.

This SDK uses it. The WalletConnect and Send adapters both call it, because a single call that returns the executed transaction is far easier to build a UI around than a void call plus an event subscription. Our CIP0103_METHODS constant nevertheless lists ten names and omits it, so the constant tracks the spec while the adapters track the client. That is a real inconsistency on our side, not a reading of the standard.

2. messageSignature exists upstream and not here

Upstream declares a messageSignature key with three payload shapes, pending, signed and failed, mirroring the transaction lifecycle for message signing. It appears in neither the spec's event table nor this SDK. If you need to track a signature request through a multi-step flow, the upstream client can express it and our hooks cannot.

3. Events and methods share one map upstream

Upstream RpcTypes has fourteen keys. Ten are the spec's methods, and accountsChanged and txChanged sit alongside them as keys of the same map, each typed as a function returning its event payload. The spec lists those two as events, not methods.

This is our interpretive choice: we split them, exposing methods through request() and events through on(), because a dApp subscribes to an event and calls a method, and collapsing both into one map makes that distinction invisible at the call site. The upstream shape is a faithful description of the transport, where everything is a JSON-RPC exchange. Neither is wrong; they describe different layers.

4. statusChanged is a spec event with no upstream key

The spec lists statusChanged as an event carrying StatusEvent. Upstream has no such key: it exposes status as a method returning StatusEvent and leaves the change notification out of the typed map. We implement the event, following the spec.

5. connected only exists in the asynchronous variant

Our CIP0103_EVENTS constant lists four events, and the fourth, connected, comes from the spec's asynchronous dApp API, where connect returns a userUrl and the wallet emits connected once login completes. A purely synchronous provider never emits it.

Our choice, and a debatable one: the constant does not distinguish sync from async, so it over-declares for a synchronous wallet. We kept one list because a dApp does not know in advance which variant a wallet implements, and a subscription to an event that never fires is harmless. The cost is that the constant is not a conformance target for a synchronous provider.

6. completionOffset is dropped

Upstream's executed-transaction payload is { updateId, completionOffset }. Our TxReceipt has no field for an offset, so the SDK reads updateId and discards completionOffset. If you need the offset, reach for ledgerApi rather than the receipt.

ℹ️ Note
Sourcing note. The request and response shapes on this page were read from the published type declarations of @canton-network/core-wallet-dapp-rpc-client@1.11.0, which the standard's authors ship and nobody here wrote. Semantics come from the specification. Our own constants are corroboration, not the source: a page about a standard we did not author should not cite our implementation as evidence about the standard.

Implementing CIP-0103 in a dApp

You have two options and they differ in how much of the standard you touch. Use the SDK and you never write a request() call; use the provider directly and you own the whole surface.

The SDK route, which is what most applications want: Quick Start has the working version. The provider route, when you are building something the SDK does not cover:

typescript
import { discoverInjectedProviders, isCIP0103Provider } from '@partylayer/provider';

// 1. Find providers that announced themselves.
const providers = await discoverInjectedProviders();
const cip = providers.filter((p) => isCIP0103Provider(p.provider));

// 2. Connect. Note both flags: a wallet can be reachable but not on a network.
const provider = cip[0].provider;
const conn = await provider.request<{
  isConnected: boolean;
  isNetworkConnected: boolean;
  reason?: string;
  networkReason?: string;
  userUrl?: string;
}>({ method: 'connect' });

if (conn.userUrl) {
  // Asynchronous wallet: send the user here, then wait for the 'connected' event.
  window.open(conn.userUrl, '_blank');
}

// 3. Read the account. partyId is what the ledger knows the user as.
const account = await provider.request<{ partyId: string; primary: boolean }>({
  method: 'getPrimaryAccount',
});

Two things worth doing that the standard does not force you to do. Check isNetworkConnected as well as isConnected, because they are separate flags with separate reason strings and a wallet that is open but has no network will satisfy the first and fail every subsequent call. And handle userUrl on the connect result even if you only expect synchronous wallets, since its presence is the only signal that you are talking to an asynchronous one.

Implementing CIP-0103 in a wallet

The obligation is narrower than it looks. A wallet must answer the ten methods, emit the three synchronous events, and use the error codes below. Everything else is transport.

  • Answer every method, including ones you do not support. A method you cannot honour returns error 4200 (Unsupported Method) or -32004. Silence, or a rejection with no code, is what breaks dApps: the caller cannot tell refusal from failure.
  • Announce yourself. Provider discovery is how a dApp finds you without a per-wallet adapter. See Generic bridge for the two discovery paths and what each requires.
  • Emit txChanged for every phase you reach. The lifecycle is pending, signed, executed, failed. A wallet that only emits the terminal state leaves a dApp unable to show progress, which is where the pressure to poll comes from.
  • If you are a remote or server-side wallet, implement the async variant. Return a userUrl from connect and prepareExecute, and emit connected after login. That is what the variant is for, and it avoids pretending a multi-step flow is synchronous.

Verifying compliance

We ship the conformance suite we used to take wallets through this standard. It runs against any CIP-0103 provider, not only ours, and it checks interface shape, that all ten mandatory methods are handled, event subscription, error shape, and lifecycle.

typescript
import { runCIP0103ConformanceTests } from '@partylayer/conformance-runner';

const report = await runCIP0103ConformanceTests(provider);

console.log(`${report.passed}/${report.total} passed`);
for (const r of report.results.filter((x) => !x.passed)) {
  console.log(r.category, r.name, r.error);
}

Results are grouped as interface, method, event, error and lifecycle, so a failure tells you which obligation you missed rather than only that something is wrong. What it does not do is tell you whether your wallet is correct: it checks that the interface is honoured, not that a signature is valid or a transaction reached the ledger.

Provider Bridge

Wrap your PartyLayerClient as a CIP-0103 provider using asProvider():

typescript
import { createPartyLayer } from '@partylayer/sdk';

const client = createPartyLayer({
  network: 'mainnet',
  app: { name: 'My dApp' },
});

// Bridge to CIP-0103
const provider = client.asProvider();

// Now use standard CIP-0103 methods
const result = await provider.request({ method: 'connect' });
const accounts = await provider.request({ method: 'listAccounts' });
💡 Tip
Use the bridge when you need to expose a CIP-0103 compliant interface to third-party libraries or tools that expect a raw CIP-0103 provider.

Provider Discovery

typescript
import {
  discoverInjectedProviders,
  waitForProvider,
  isCIP0103Provider,
} from '@partylayer/provider';

// Scan window.canton.* for all injected providers
const providers = discoverInjectedProviders();
// → [{ id: 'console', provider: CIP0103Provider }, ...]

// Wait for a specific provider to appear (returns null if not found)
const discovered = await waitForProvider('nightly', 5000);
if (discovered) {
  console.log('Found:', discovered.id, discovered.provider);
}

// Duck-type check
if (isCIP0103Provider(window.canton?.console)) {
  console.log('Console wallet is CIP-0103 compliant');
}

Network Utilities (CAIP-2)

Convert between PartyLayer network IDs and CAIP-2 format:

typescript
import { toCAIP2Network, fromCAIP2Network, isValidCAIP2 } from '@partylayer/provider';

toCAIP2Network('mainnet');           // → { networkId: 'canton:da-mainnet' }
fromCAIP2Network('canton:da-testnet'); // → 'testnet'
isValidCAIP2('canton:da-mainnet');  // → true
isValidCAIP2('not-a-network');      // → false (no colon separator)

Error Model

CIP-0103 uses ProviderRpcError with EIP-1193 and EIP-1474 numeric error codes:

EIP-1193 Codes

typescript
// 4001, User Rejected
// 4100, Unauthorized
// 4200, Unsupported Method
// 4900, Disconnected
// 4901, Chain Disconnected

EIP-1474 Codes

typescript
// -32700, Parse Error
// -32600, Invalid Request
// -32601, Method Not Found
// -32602, Invalid Params
// -32603, Internal Error
// -32000, Invalid Input
// -32003, Transaction Rejected
// -32005, Limit Exceeded

Error Mapping

Convert between PartyLayer errors and CIP-0103 RPC errors:

typescript
import { toProviderRpcError, toPartyLayerError } from '@partylayer/provider';

// PartyLayer → CIP-0103
const rpcError = toProviderRpcError(new UserRejectedError('connect'));
// → ProviderRpcError { code: 4001, message: 'User Rejected' }

// CIP-0103 → PartyLayer
const plError = toPartyLayerError(rpcError);
// → UserRejectedError { code: 'USER_REJECTED' }
PreviousSend (Beta)NextGeneric Bridge