Reference implementation of @zoffwallet/provider-interface for Canton Network — a browser SDK that any dApp can install from npm and target the canonical CantonWalletProvider contract end-to-end against the Zoff wallet.
Pre-release. v0.1.0-rc.1 publish target: 2026-05-05 EOD under npm next tag. v0.1.0 GA: 2026-05-08 under latest. See CHANGELOG.md.
npm install @zoffwallet/sdk @zoffwallet/provider-interface
@zoffwallet/provider-interface is a peer dependency: dApps install it directly so they can import the canonical types without coupling to a wallet implementation.
import { ZoffProvider } from '@zoffwallet/sdk';
const zoff = new ZoffProvider();
await zoff.init({ appName: 'my-dapp', network: 'devnet' });
const { partyId, authToken } = await zoff.connect();
const prepared = await zoff.prepareTransfer({
recipient: 'Recipient::1220...',
amount: '1.0',
instrument: { instrumentAdmin: 'DSO::1220...', instrumentId: 'Amulet' },
});
const result = await zoff.submitAndWaitForTransaction(prepared);
// result.updateId, result.completionOffsetThe SDK dispatches canton:announceProvider on window as init() resolves. dApps that want multi-wallet support listen for it:
import type { CantonWalletAnnounceEvent } from '@zoffwallet/provider-interface';
const providers = new Map<string, { info: CantonWalletInfo; provider: CantonWalletProvider }>();
window.addEventListener('canton:announceProvider', (e) => {
const { info, provider } = (e as CantonWalletAnnounceEvent).detail;
providers.set(info.uuid, { info, provider });
});
window.dispatchEvent(new Event('canton:requestProvider'));The SDK also listens for canton:requestProvider and re-announces, so late-registered listeners still receive the provider.
| Network | v0.1.x | Wallet origin | Backend origin |
|---|---|---|---|
devnet |
✅ | https://devnet.zoff.app |
https://api.devnet.zoff.app |
mainnet |
✅ | https://zoff.app |
https://api.zoff.app |
testnet |
— | not supported | not supported |
init({network: 'testnet'}) throws WalletError { code: 'INVALID_COMMAND' }. Mainnet origins are wired (added in 0.1.0-rc.3) so dApps can finalize their integration ahead of the public flag flip — until the wallet flips its maintenance gate, mainnet HTTP calls will surface as VALIDATOR_ERROR (backend in mock mode).
Every error a ZoffProvider method throws is a WalletError from the canonical interface — discriminate on error.code, never on error.message. The seven canonical codes:
| Code | Meaning |
|---|---|
USER_REJECTED |
User declined in the wallet UI. |
RATE_LIMITED |
Backend rate-limited; check details.retryAfterMs when present. |
NOT_CONNECTED |
Method called before connect() or after disconnect(). |
TIMEOUT |
Operation exceeded its window. |
VALIDATOR_ERROR |
Backend or ledger-API failure. |
INVALID_COMMAND |
Malformed payload — unknown network, missing required field, etc. |
UNKNOWN |
Unclassified failure. |
@zoffwallet/sdk calls the canton-wallet backend via HTTPS. The mapping from HTTP status to canonical code is, in order:
| HTTP status | code |
|---|---|
401 |
NOT_CONNECTED |
429 |
RATE_LIMITED |
408, 504 |
TIMEOUT |
400 (or backend code: INVALID_ARGUMENT) |
INVALID_COMMAND |
5xx |
VALIDATOR_ERROR |
| anything else | UNKNOWN |
Network failures (fetch rejection) map to VALIDATOR_ERROR with details.cause: 'fetch_failed'. The original HTTP status and any backend code are preserved on details.backendStatus / details.backendCode for tooling — but per the canonical contract callers MUST discriminate on code.
See examples/devnet-end-to-end.ts for a self-contained EIP-6963 discovery → init → connect → getHoldings → prepareTransfer → submitAndWaitForTransaction smoke against devnet.
For CI smokes that exercise the SDK end-to-end without a real popup or a human, use ZoffProvider.withTestingTransport:
import { ZoffProvider } from '@zoffwallet/sdk';
const provider = ZoffProvider.withTestingTransport({
autoApprove: true,
// Optional — every fixture has a deterministic default
party: { partyId: 'TestParty::1220...test', authToken: 'test-jwt' },
submit: { transactionId: 'test-tx-id', completionOffset: 1 },
});
await provider.init({ appName: 'ci-smoke', network: 'devnet' });
await provider.connect(); // resolves instantly with the fixture party
const result = await provider.submitAndWaitForTransaction({ // resolves with fixture transactionId
commands: [...],
actAs: provider.partyId!,
});The HTTPS-direct routes (prepareTransfer, getHoldings, getActiveContracts) are NOT mocked — they still hit backendOrigin. Mock those at the fetch level for full isolation, or point backendOrigin to a test backend.
Empty-commands validation still throws INVALID_COMMAND — canonical-contract guarantees are not bypassed by the testing transport. Only the popup approval is faked.
Do not use in production code paths. Any provider returned by withTestingTransport silently bypasses user consent. The name is deliberately easy to grep for in a publish checklist.
MIT