Connect a dApp to Grofty Wallet over CIP-0103, the Canton dApp standard.
- Zero runtime dependencies. Nothing is pulled into your bundle but this package.
- SSR-safe. Importing it on a server does nothing; every
windowaccess is guarded. - Typed end to end, including the places where the wallet diverges from the bare spec.
- Errors carry stable numeric codes. Branch on
code, never on message text.
You do not need this package to support Grofty. Grofty announces itself over
canton:announceProvider, so any CIP-0103 aggregator — PartyLayer, for
one — already reaches it with no wallet-specific code. Reach for this SDK when you want to talk to
Grofty directly, with types.
npm install @groftylabs/dapp-sdkimport { createGroftyClient, isUserRejection } from '@groftylabs/dapp-sdk';
const grofty = await createGroftyClient();
if (!grofty) {
// Not installed, or this page is server-rendered.
return;
}
try {
await grofty.connect();
const account = await grofty.getPrimaryAccount();
console.log('connected as', account?.partyId);
} catch (error) {
if (isUserRejection(error)) {
console.log('the user said no');
} else {
throw error;
}
}import { GroftyProvider, useGroftyAccount, useConnect } from '@groftylabs/dapp-sdk/react';
function App() {
return (
<GroftyProvider>
<Wallet />
</GroftyProvider>
);
}
function Wallet() {
const { isConnected, account, networkId } = useGroftyAccount();
const { connect, isConnecting, error } = useConnect();
if (!isConnected) {
return (
<>
<button onClick={() => connect()} disabled={isConnecting}>
{isConnecting ? 'Connecting…' : 'Connect Grofty'}
</button>
{error && <p>Error {error.code}: {error.message}</p>}
</>
);
}
return <p>{account?.partyId} on {networkId}</p>;
}React is an optional peer dependency. The core entry point never imports it.
| Function | Returns |
|---|---|
createGroftyClient(options?) |
Promise<GroftyClient | null> — null when not installed or during SSR |
requireGroftyClient(options?) |
Promise<GroftyClient> — throws GroftyNotFoundError instead |
getGroftyClient(options?) |
GroftyClient | null, synchronous, only if already injected |
getGroftyProvider() |
the raw CIP-0103 provider, or null |
collectAnnouncedWallets(options?) |
every wallet answering canton:requestProvider |
Options: discoveryTimeoutMs (default 1000) bounds discovery; timeoutMs (default 240 000) bounds
a single request afterwards. They are separate on purpose — one waits for the extension to show up,
the other waits for the user to answer a prompt.
client.connect() // → ConnectResult, prompts the user
client.disconnect()
client.isConnected() // never prompts
client.status() // → StatusEvent, answers before connecting
client.getActiveNetwork() // → { networkId: 'canton:da-mainnet' }
client.listAccounts() // → CantonAccount[]
client.getPrimaryAccount() // → the account that will sign
client.signMessage(message) // → signature string
client.prepareExecute(params) // submit; resolves with undefined
client.prepareExecuteAndWait(params) // submit and resolve with { tx: TxExecutedEvent }
client.submitAndWait(params) // same receipt, correlated via events; see Quirks
client.ledgerApi({ resource, body? }) // narrow read surface, see Quirks
client.getBalance() // ledgerApi({ resource: 'balance' })
client.getUpdateById(updateId) // a submitted tx, with createdEventBlobs
client.getActiveContracts({ … }) // your party's contracts, optionally by template
client.request(method, params?) // escape hatch, still normalizes errors
client.on(event, handler) // → unsubscribe function
client.once(event, handler)statusChanged, accountsChanged, txChanged, connected — all typed through GroftyEventMap.
const off = client.on('txChanged', (event) => {
if (event.status === 'executed') console.log(event.payload.updateId);
});Every rejection is a GroftyRpcError with a numeric code.
| Code | Meaning |
|---|---|
4001 |
user rejected the request |
4100 |
unauthorized — origin not connected, or the user is signed out of the wallet |
-32601 |
method (or ledgerApi resource) not found |
-32602 |
invalid params |
-32603 |
internal error, including an approval that timed out |
import { isUserRejection, isUnauthorized, UNAUTHORIZED } from '@groftylabs/dapp-sdk';Note that a timed-out approval is -32603, not 4001. The wallet distinguishes "the user declined"
from "the user never answered", and so should you.
Grofty's surface differs from the plain reading of CIP-0103 in the places below. Each one is encoded in the types, but they are worth knowing.
ledgerApi is a narrow reader, not a Ledger API proxy. It is read-only and serves four real
Ledger API paths — /v2/state/ledger-end, /v2/state/active-contracts, /v2/updates/update-by-id,
/v2/events/events-by-contract-id — plus the older wallets and balance shorthands. Any other
path returns -32601.
Every path read is scoped to the connected wallet's own party, server-side. Party filters in
your body are ignored, not honoured: the wallet rebuilds each request around your party, so these
answer about your contracts and nobody else's. body carries only the non-authority arguments —
updateId, contractId, templateIds, activeAtOffset, and includeCreatedEventBlob (default
true).
Because it is a subset rather than the whole surface, Grofty still does not claim the ledgerApi
capability in the PartyLayer registry — a dApp feature-detecting it would expect more than this.
prepareExecute resolves with undefined, per the spec — it waits for execution to finish, but
the ledger's updateId arrives on the txChanged event rather than as a return value. Use
prepareExecuteAndWait() to get { tx } back from the call itself. submitAndWait() returns the
same receipt but correlates by watching the pending event it triggered, so concurrent submissions
from one page can be mixed up; it remains for wallets that predate prepareExecuteAndWait, and
because it reports a failed transaction as a terminal event instead of throwing.
prepareExecute accepts two shapes. Either a plain transfer, { receiver, amount, tokenSymbol?, memo? }, or generic Daml commands with the CIP-0103 envelope: commands, disclosedContracts,
commandId, readAs, synchronizerId, packageIdSelectionPreference.
actAs is refused, and readAs may only name your own party. Grofty submits as a single party,
always the connected wallet's own. Supplying another party is rejected outright rather than
silently dropped, so a dApp relying on multi-party submission finds out on the first call.
Cross-participant settlement, end to end. Submit, read back what you created, then hand those
contracts to the counterparty as disclosedContracts:
const { tx } = await client.prepareExecuteAndWait({
commands,
disclosedContracts: registryContext, // Amulet rules, factories, …
});
const update = await client.getUpdateById(tx.payload.updateId);
// each created event carries createdEventBlob, ready to pass onPass all four fields of a disclosed contract. CIP-0103 marks only createdEventBlob as required,
but the Canton Ledger API also needs contractId and synchronizerId, so send templateId,
contractId, createdEventBlob, and synchronizerId together.
signMessage versus wallet_signMessage. The spec method resolves to { signature }; the
legacy method returns a bare string. client.signMessage() accepts either and always hands you the
string.
Mainnet only. Grofty reports canton:da-mainnet and does not implement network switching.
Grofty implements status and getPrimaryAccount without prompting, so a page reload can restore a
session silently: call status(), and if connection.isConnected is true the session is live. The
React bindings do this for you in useGroftyAccount.
Requires Grofty Wallet 2.0.4 or newer. That build is the first to resolve
prepareExecuteAndWait() with { tx }, to carry the full CIP-0103 prepare envelope, and to serve
the Ledger API read paths. Against 2.0.2 and 2.0.3 the SDK still loads, but those three return
undefined, drop the extra envelope fields, and answer -32601 respectively — failures that look
like your code rather than the wallet's.
Below 2.0.2 nothing works at all: the dApp bridge never reached web pages, because the content
script shipped as an ES module the browser refuses to run. Against those, createGroftyClient()
resolves to null.
- The SDK never sees a key, a seed or a password. It speaks
postMessageto the extension, which does all signing behind its own approval prompts. - Inbound messages are checked for origin and source before being trusted.
- Every request has a ceiling, so a wallet that stops answering surfaces an error instead of hanging your UI forever.
- No dynamic code evaluation, and no runtime dependencies — there is no third-party code in the path between your dApp and a signature prompt.
Apache-2.0. See LICENSE.