Wallet Balances
PartyLayer does not have a dedicated getBalance() method. Token holdings on the Canton Network live as contracts in the Active Contract Set (ACS), not as a single numeric value. You query the ACS for the party's holding contracts, then sum the amounts.
ℹ️ Note
Think of it like a UTXO model. A party's balance for a given instrument is the sum of all active holding contracts they own for that instrument.
💡 Tip
See the full working example in
examples/wallet-balance-loop/, a minimal Vite + React + TypeScript app that connects Loop wallet, queries balance, and displays the result.
Prerequisites
- Wallet connected, see Quick Start
ledgerApi capability supported by the connected wallet (Console, Loop, Nightly, and Bron all support this)
ℹ️ Note
Session persistence: After a page reload the SDK automatically restores the active session from storage. Your component may mount
isDisconnected (or
reconnecting) for a moment while the restore runs, so always guard with
if (!isConnected) return null or render a
<ConnectButton /> fallback. See
Advanced, Session Persistence for per-wallet behavior.
How the query works
A correct Canton JSON Ledger API active-contracts query has three parts. Getting any of them wrong returns an empty set.
- Read at an offset.
activeAtOffset is required. First call GET /v2/state/ledger-end to get the current offset. - Wrap the filter in
eventFormat. The filter lives under eventFormat.filtersByParty[party].cumulative[]. A bare filtersByParty object returns nothing. - Filter by the Token Standard Holding interface. Use an
InterfaceFilter on #splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding with includeInterfaceView: true, so every token holding comes back through one query.
Each returned entry is { contractEntry: { JsActiveContract: { createdEvent } } }. The contract id is at createdEvent.contractId, and the Holding view (owner, instrumentId, amount, lock) is at createdEvent.interfaceViews[0].viewValue. There is no top-level payload field on an interface query.
React
Single instrument balance
import { useState, useEffect } from 'react';
import { useAccount, usePartyLayer } from '@partylayer/react';
const HOLDING_INTERFACE =
'#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding';
function TokenBalance({ instrumentId }: { instrumentId: string }) {
const { isConnected, party } = useAccount();
const client = usePartyLayer();
const [balance, setBalance] = useState<number | null>(null);
useEffect(() => {
if (!isConnected || !party) return;
(async () => {
// 1. activeAtOffset is required: read the current ledger end.
const end = await client.ledgerApi({
requestMethod: 'GET',
resource: '/v2/state/ledger-end',
});
const { offset } = JSON.parse(end.response);
// 2. Query holdings via the Token Standard Holding interface.
const acs = await client.ledgerApi({
requestMethod: 'POST',
resource: '/v2/state/active-contracts',
body: JSON.stringify({
activeAtOffset: offset,
eventFormat: {
filtersByParty: {
[party]: {
cumulative: [
{
identifierFilter: {
InterfaceFilter: {
value: {
interfaceId: HOLDING_INTERFACE,
includeInterfaceView: true,
},
},
},
},
],
},
},
verbose: false,
},
}),
});
// 3. Sum amounts from each Holding view, filtered to this instrument.
const parsed = JSON.parse(acs.response);
const entries = Array.isArray(parsed) ? parsed : parsed.activeContracts ?? [];
const total = entries.reduce((sum: number, e: any) => {
const view = e.contractEntry?.JsActiveContract?.createdEvent?.interfaceViews?.[0]?.viewValue;
if (!view || view.instrumentId?.id !== instrumentId) return sum;
return sum + parseFloat(view.amount ?? '0');
}, 0);
setBalance(total);
})();
}, [isConnected, party, client, instrumentId]);
if (!isConnected) return null;
return <span>{balance ?? '…'}</span>;
}
// Usage
<TokenBalance instrumentId="Amulet" />
💡 Tip
Prefer the dedicated hook: The
useLedgerApi hook provides built-in
isLoading and
error state, saving you from managing them manually. See
React Hooks, useLedgerApi for full documentation.
All holdings, grouped by instrument
The interface query returns every token holding in one call, so you can group by instrumentId instead of querying each instrument separately.
import { useState, useEffect } from 'react';
import { useAccount, usePartyLayer } from '@partylayer/react';
const HOLDING_INTERFACE =
'#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding';
function AllBalances() {
const { isConnected, party } = useAccount();
const client = usePartyLayer();
const [balances, setBalances] = useState<Record<string, number>>({});
useEffect(() => {
if (!isConnected || !party) return;
(async () => {
const end = await client.ledgerApi({
requestMethod: 'GET',
resource: '/v2/state/ledger-end',
});
const { offset } = JSON.parse(end.response);
const acs = await client.ledgerApi({
requestMethod: 'POST',
resource: '/v2/state/active-contracts',
body: JSON.stringify({
activeAtOffset: offset,
eventFormat: {
filtersByParty: {
[party]: {
cumulative: [
{
identifierFilter: {
InterfaceFilter: {
value: { interfaceId: HOLDING_INTERFACE, includeInterfaceView: true },
},
},
},
],
},
},
verbose: false,
},
}),
});
const parsed = JSON.parse(acs.response);
const entries = Array.isArray(parsed) ? parsed : parsed.activeContracts ?? [];
const byInstrument: Record<string, number> = {};
for (const e of entries) {
const view = e.contractEntry?.JsActiveContract?.createdEvent?.interfaceViews?.[0]?.viewValue;
if (!view) continue;
const id = view.instrumentId?.id ?? 'unknown';
byInstrument[id] = (byInstrument[id] ?? 0) + parseFloat(view.amount ?? '0');
}
setBalances(byInstrument);
})();
}, [isConnected, party, client]);
return (
<ul>
{Object.entries(balances).map(([instrument, amount]) => (
<li key={instrument}>
{instrument}: {amount}
</li>
))}
</ul>
);
}
Vanilla JS
Factor the offset-then-query steps into a reusable helper that returns the parsed Holding views, then sum or group them however you like.
import { createPartyLayer } from '@partylayer/sdk';
const HOLDING_INTERFACE =
'#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding';
const client = createPartyLayer({
network: 'mainnet',
app: { name: 'My App' },
});
const session = await client.connect();
const party = session.partyId;
// Returns every Holding view for the party.
async function getHoldings(): Promise<any[]> {
const end = await client.ledgerApi({
requestMethod: 'GET',
resource: '/v2/state/ledger-end',
});
const { offset } = JSON.parse(end.response);
const acs = await client.ledgerApi({
requestMethod: 'POST',
resource: '/v2/state/active-contracts',
body: JSON.stringify({
activeAtOffset: offset,
eventFormat: {
filtersByParty: {
[party]: {
cumulative: [
{
identifierFilter: {
InterfaceFilter: {
value: { interfaceId: HOLDING_INTERFACE, includeInterfaceView: true },
},
},
},
],
},
},
verbose: false,
},
}),
});
const parsed = JSON.parse(acs.response);
const entries = Array.isArray(parsed) ? parsed : parsed.activeContracts ?? [];
return entries
.map((e: any) => e.contractEntry?.JsActiveContract?.createdEvent?.interfaceViews?.[0]?.viewValue)
.filter(Boolean);
}
// Balance for one instrument.
async function getBalance(instrumentId: string): Promise<number> {
const holdings = await getHoldings();
return holdings
.filter((v) => v.instrumentId?.id === instrumentId)
.reduce((sum, v) => sum + parseFloat(v.amount ?? '0'), 0);
}
const balance = await getBalance('Amulet');
console.log('Balance:', balance);
Notes
Interface ID vs template ID
Querying by the Token Standard Holding interface returns holdings for every instrument in one call, which is the recommended approach. The interface id is the fully-qualified Daml form with the package-name prefix: #splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding. If you need a specific template instead, replace the InterfaceFilter with a TemplateFilter whose value.templateId is the fully-qualified template id (for example #splice-amulet:Splice.Amulet:Amulet); a template query exposes the contract under createdEvent.createArgument rather than interfaceViews.
Response parsing
ledgerApi returns { response: string }, a raw JSON payload from the Canton Ledger API. Parse it with JSON.parse(result.response). The active-contracts response is the list of entries (some wallet proxies wrap it as { activeContracts }, so the examples above normalize both with Array.isArray(parsed) ? parsed : parsed.activeContracts). Each entry exposes its created event at contractEntry.JsActiveContract.createdEvent.
Wallet support
Console, Nightly, and Bron provide full ledgerApi proxy access to the Canton Ledger API endpoints, including GET /v2/state/ledger-end and POST /v2/state/active-contracts. Loop exposes a higher-level provider.getActiveContracts({ templateId | interfaceId }) and routes a /v2/state/acs alias through its own SDK; that alias is Loop-specific, not the generic Canton endpoint, so prefer getActiveContracts when targeting Loop directly. Cantor8 (mobile deep link) does not support ledgerApi; calling it with a Cantor8 session throws CapabilityNotSupportedError.
ℹ️ Note
Loop note: Loop expects the fully-qualified Daml id with the
#package-name: prefix for both template and interface filters, and it always filters by template or interface (it does not serve a bare unfiltered ACS read). Pass the same
HOLDING_INTERFACE id shown above.
Large result sets
The active-contracts response is the full snapshot at the activeAtOffset you requested. Read the offset once with GET /v2/state/ledger-end and reuse it for every query in the same pass so all reads are consistent at the same point in ledger history.