Skip to content

Instantly share code, notes, and snippets.

@0xBigBoss
Last active March 27, 2026 22:36
Show Gist options
  • Select an option

  • Save 0xBigBoss/8888ca2f0ce137efea4ccded601ae6fa to your computer and use it in GitHub Desktop.

Select an option

Save 0xBigBoss/8888ca2f0ce137efea4ccded601ae6fa to your computer and use it in GitHub Desktop.
Canton dApp SDK demo — query active contracts via window.canton (Send extension)
// Canton dApp SDK demo — query active contracts via window.canton
//
// Run this in the browser console on any page where the Send extension is installed.
// The extension injects window.canton which provides JSON-RPC access to the Canton ledger.
//
// Key points:
// - All requests go through the extension's service worker (no CORS issues)
// - Use `eventFormat` (not `filter`) for active-contracts queries
// - Must fetch ledger-end offset and pass as `activeAtOffset`
// - `cumulative: []` inside eventFormat means "all contracts for this party"
//
// Docs: https://sigilry.org
(async () => {
if (!window.canton) { console.error('window.canton not found — is the Send extension installed?'); return; }
// 1. Connect wallet (triggers approval popup for new sites)
const status = await window.canton.request({ method: 'connect' });
console.log('[connect]', status.network.networkId, status.isConnected ? 'connected' : 'disconnected');
// 2. Get the primary account (partyId)
const account = await window.canton.request({ method: 'getPrimaryAccount' });
console.log('[account]', account.partyId);
// 3. Get current ledger-end offset (required for active-contracts query)
const endResult = await window.canton.request({
method: 'ledgerApi',
params: { requestMethod: 'GET', resource: '/v2/state/ledger-end' },
});
const ledgerEnd = JSON.parse(endResult.response);
console.log('[ledger-end]', ledgerEnd.offset);
// 4. Query active contracts
// - eventFormat (not filter) is the correct top-level field
// - cumulative: [] means "all contracts for this party" (wildcard)
// - activeAtOffset must be the current ledger-end offset
// - verbose: true goes inside eventFormat
const result = await window.canton.request({
method: 'ledgerApi',
params: {
requestMethod: 'POST',
resource: '/v2/state/active-contracts',
body: JSON.stringify({
eventFormat: {
filtersByParty: {
[account.partyId]: {
cumulative: []
}
},
verbose: true,
},
activeAtOffset: ledgerEnd.offset,
}),
},
});
const contracts = JSON.parse(result.response);
console.log(`[active-contracts] ${contracts.length} contracts`);
// Group by template type for readability
const byTemplate = {};
for (const c of contracts) {
const evt = c.contractEntry?.JsActiveContract?.createdEvent;
if (!evt) continue;
const parts = evt.templateId.split(':');
const template = parts.slice(1).join(':');
if (!byTemplate[template]) byTemplate[template] = [];
byTemplate[template].push(evt);
}
console.log('[templates]', Object.keys(byTemplate).length, 'unique types');
for (const [template, events] of Object.entries(byTemplate)) {
console.groupCollapsed(`${template} (${events.length})`);
for (const evt of events) {
const args = evt.createArgument;
const amount = args.amount?.initialAmount || args.amount || args.transfer?.amount;
const instrument = args.instrument?.id || args.transfer?.instrumentIdentifier?.id || '';
console.log(
` ${evt.contractId.slice(0, 16)}...`,
amount ? `${amount} ${instrument}` : '',
new Date(evt.createdAt).toLocaleDateString()
);
}
console.groupEnd();
}
// 5. Version check
const ver = await window.canton.request({
method: 'ledgerApi',
params: { requestMethod: 'GET', resource: '/v2/version' },
});
console.log('[version]', JSON.parse(ver.response).version);
})();
// Canton dApp SDK demo — sign & verify arbitrary messages via window.canton
//
// Run this in the browser console on any page where the Send extension is installed.
// Demonstrates signMessage + client-side ECDSA P-256 verification.
//
// Flow:
// 1. Connect and get the account's public key (base64 SPKI)
// 2. Request a message signature (opens approval popup)
// 3. Verify the signature locally using Web Crypto
//
// Signing uses ECDSA P-256 / SHA-256. The signature is hex-encoded ASN.1 DER.
// The public key from listAccounts is base64-encoded SPKI (SubjectPublicKeyInfo).
//
// Docs: https://sigilry.org
(async () => {
if (!window.canton) {
console.error('window.canton not found — is the Send extension installed?');
return;
}
// ── helpers ──────────────────────────────────────────────────────────
const hexToBytes = (hex) => new Uint8Array(hex.match(/.{2}/g).map((b) => parseInt(b, 16)));
const b64ToBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
/**
* Import a base64-encoded SPKI public key for ECDSA P-256 verification.
*/
async function importPublicKey(b64Spki) {
return crypto.subtle.importKey(
'spki',
b64ToBytes(b64Spki),
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['verify'],
);
}
/**
* Convert a DER-encoded ECDSA signature to the IEEE P1363 format
* that Web Crypto's verify() expects.
* DER: SEQUENCE { INTEGER r, INTEGER s }
* P1363: r (32 bytes, zero-padded) || s (32 bytes, zero-padded)
*/
function derToP1363(derBytes) {
let offset = 2; // skip SEQUENCE tag + length
// r
const rLen = derBytes[offset + 1];
offset += 2;
let r = derBytes.slice(offset, offset + rLen);
offset += rLen;
// s
const sLen = derBytes[offset + 1];
offset += 2;
let s = derBytes.slice(offset, offset + sLen);
// Strip leading zero padding (DER uses it for positive sign)
if (r.length > 32) r = r.slice(r.length - 32);
if (s.length > 32) s = s.slice(s.length - 32);
// Left-pad to 32 bytes
const out = new Uint8Array(64);
out.set(r, 32 - r.length);
out.set(s, 64 - s.length);
return out;
}
// ── 1. connect & get account ─────────────────────────────────────────
const status = await window.canton.request({ method: 'connect' });
console.log('[connect]', status.isConnected ? 'connected' : 'failed');
const accounts = await window.canton.request({ method: 'listAccounts' });
const account = accounts.find((a) => a.primary) || accounts[0];
if (!account) { console.error('No accounts'); return; }
console.log('[account]', account.partyId);
console.log('[publicKey]', account.publicKey.slice(0, 32) + '...');
// ── 2. sign a message ────────────────────────────────────────────────
const message = 'Hello from Canton dApp SDK demo';
console.log('[signing] "%s"', message);
console.log(' → approval popup will open...');
const { signature } = await window.canton.request({
method: 'signMessage',
params: { message },
});
console.log('[signature]', signature.slice(0, 32) + '...');
// ── 3. verify the signature ──────────────────────────────────────────
const publicKey = await importPublicKey(account.publicKey);
const messageBytes = new TextEncoder().encode(message);
const signatureP1363 = derToP1363(hexToBytes(signature));
const valid = await crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
publicKey,
signatureP1363,
messageBytes,
);
console.log('[verify]', valid ? '✓ signature is valid' : '✗ signature is INVALID');
// ── summary ──────────────────────────────────────────────────────────
console.log('\n--- Summary ---');
console.log('Party: ', account.partyId);
console.log('Message: ', message);
console.log('Signature:', signature);
console.log('Valid: ', valid);
})();
// Canton dApp SDK demo — prepare a USDCx transfer via window.canton
//
// Run from the raw gist URL in the browser console:
// fetch('https://gist.githubusercontent.com/0xBigBoss/8888ca2f0ce137efea4ccded601ae6fa/raw/canton-usdcx-transfer-demo.js').then(r=>r.text()).then(eval)
//
// Or paste the script directly on any page where the Send extension is installed.
//
// What this does:
// 1. Connect to the wallet and get the sender party
// 2. Query the sender's USDCx holdings through window.canton.ledgerApi
// 3. Resolve the public Token Standard transfer-factory endpoint
// 4. Call prepareExecuteAndWait so the extension opens the approval popup
//
// Notes:
// - You will be prompted for the receiver party ID
// - If transferKind === "offer", the receiver does not have a matching TransferPreapproval
// - This uses all discovered USDCx holdings for simplicity; production code should minimize inputs
// - The active-contracts body intentionally follows the DA transfer how-to shape:
// top-level `filter`, string `activeAtOffset`, and string interfaceId
// - choiceArgument must use protobuf Value JSON encoding (record/fields/label/value),
// not plain JSON — the API gateway passes commands through to Canton ISS via
// fromJson(PrepareSubmissionRequestSchema) which expects the wire format.
//
// Docs:
// - https://docs.digitalasset.com/utilities/testnet/how-tos/registry/transfer/transfer.html
// - https://docs.digitalasset.com/utilities/testnet/how-tos/registry/transfer-preapproval/transfer-preapproval.html
(async () => {
const DEFAULT_RECEIVER =
'cantonwallet-allen-treasure-vault::12204290436defd80f672814046e8770d6583847f6cc608b772bd760c2f6892fa2f7';
const RECEIVER_PARTY_ID = window.prompt('Receiver party ID:', DEFAULT_RECEIVER);
if (!RECEIVER_PARTY_ID) {
console.log('Transfer cancelled.');
return;
}
const AMOUNT = '1.00';
const USDCX = {
admin:
'decentralized-usdc-interchain-rep::122049e2af8a725bd19759320fc83c638e7718973eac189d8f201309c512d1ffec61',
id: 'USDCx',
};
const HOLDING_INTERFACE =
'#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding';
const TRANSFER_FACTORY_URL =
'https://api.utilities.digitalasset-staging.com/api/token-standard/v0/registrars/' +
encodeURIComponent(USDCX.admin) +
'/registry/transfer-instruction/v1/transfer-factory';
if (!window.canton) {
console.error('window.canton not found — is the Send extension installed?');
return;
}
if (!RECEIVER_PARTY_ID.includes('::')) {
console.error('Receiver must be a full Canton party ID (contains "::").');
return;
}
// --- Protobuf Value JSON helpers ---
// Canton ISS expects choiceArgument in protobuf Value wire format:
// { record: { fields: [{ label: 'fieldName', value: { text: '...' } }] } }
// These helpers convert plain JS values into that encoding.
function vParty(partyId) {
return { party: partyId };
}
function vText(str) {
return { text: str };
}
function vNumeric(n) {
return { numeric: String(n) };
}
function vContractId(cid) {
return { contractId: cid };
}
function vTimestamp(isoString) {
// Canton timestamps are microseconds since epoch
const micros = String(Math.floor(new Date(isoString).getTime() * 1000));
return { timestamp: micros };
}
function vOptional(value) {
return value != null ? { optional: { value } } : { optional: {} };
}
function vRecord(fields) {
return {
record: {
fields: fields.map(([label, value]) => ({ label, value })),
},
};
}
function vList(items) {
return { list: { elements: items } };
}
function vTextMap(entries) {
return { textMap: { entries } };
}
// --- End helpers ---
function findCreatedEvents(node, out) {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (const item of node) {
findCreatedEvents(item, out);
}
return;
}
if (node.createdEvent && typeof node.createdEvent === 'object') {
out.push(node.createdEvent);
}
for (const value of Object.values(node)) {
findCreatedEvents(value, out);
}
}
function getField(recordLike, label) {
if (!recordLike) return undefined;
if (Array.isArray(recordLike.fields)) {
const field = recordLike.fields.find((entry) => entry.label === label);
return field ? field.value : undefined;
}
if (recordLike.record && Array.isArray(recordLike.record.fields)) {
const field = recordLike.record.fields.find((entry) => entry.label === label);
return field ? field.value : undefined;
}
if (Object.prototype.hasOwnProperty.call(recordLike, label)) {
return recordLike[label];
}
return undefined;
}
function scalar(value) {
if (value == null) return value;
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value;
}
for (const key of ['text', 'party', 'numeric', 'contractId', 'int64']) {
if (value[key] != null) return value[key];
}
return value;
}
async function ledgerApi(requestMethod, resource, body) {
const response = await window.canton.request({
method: 'ledgerApi',
params: {
requestMethod,
resource,
...(body ? { body: JSON.stringify(body) } : {}),
},
});
return JSON.parse(response.response);
}
async function getPrimaryPartyId() {
await window.canton.request({ method: 'connect' });
try {
const account = await window.canton.request({ method: 'getPrimaryAccount' });
const partyId =
account?.partyId || account?.account?.partyId || account?.party?.partyId;
if (partyId) return partyId;
} catch (_) {
// Fall back to listAccounts for wallets that do not support getPrimaryAccount.
}
const accounts = await window.canton.request({ method: 'listAccounts' });
const account = Array.isArray(accounts) ? accounts[0] : null;
const partyId =
account?.partyId || account?.account?.partyId || account?.party?.partyId;
if (!partyId) {
throw new Error('Could not determine sender party ID from the connected wallet.');
}
return partyId;
}
async function loadUsdcxHoldings(senderPartyId) {
const ledgerEnd = await ledgerApi('GET', '/v2/state/ledger-end');
const offset = String(ledgerEnd.offset);
const payload = await ledgerApi('POST', '/v2/state/active-contracts', {
activeAtOffset: offset,
verbose: false,
filter: {
filtersByParty: {
[senderPartyId]: {
cumulative: [
{
identifierFilter: {
InterfaceFilter: {
value: {
interfaceId: HOLDING_INTERFACE,
includeInterfaceView: true,
includeCreatedEventBlob: false,
},
},
},
},
],
},
},
},
});
const createdEvents = [];
findCreatedEvents(payload, createdEvents);
const holdings = [];
for (const createdEvent of createdEvents) {
const interfaceViews = Array.isArray(createdEvent.interfaceViews)
? createdEvent.interfaceViews
: [];
for (const view of interfaceViews) {
const viewValue = view?.viewValue;
if (!viewValue) continue;
const owner = scalar(getField(viewValue, 'owner'));
const amount = scalar(getField(viewValue, 'amount'));
const instrumentValue = getField(viewValue, 'instrumentId');
const instrumentAdmin = scalar(getField(instrumentValue, 'admin'));
const instrumentId = scalar(getField(instrumentValue, 'id'));
if (
owner === senderPartyId &&
instrumentAdmin === USDCX.admin &&
instrumentId === USDCX.id
) {
// The Holding interface view returns lock in Daml JSON format:
// null = unlocked
// { holders: [...], expiresAt: ..., ... } = locked
// The transfer implementation asserts input holdings are unlocked
// ("Input holding lock must match"), so skip locked ones.
const lockField = getField(viewValue, 'lock');
const isLocked = lockField != null;
holdings.push({
contractId: createdEvent.contractId,
owner,
amount,
instrumentAdmin,
instrumentId,
locked: isLocked,
});
break;
}
}
}
return holdings;
}
function sumAmounts(holdings) {
return holdings.reduce((sum, holding) => sum + Number(holding.amount || '0'), 0);
}
const onTxChanged = (event) => console.log('[txChanged]', event);
window.canton.on('txChanged', onTxChanged);
try {
const senderPartyId = await getPrimaryPartyId();
console.log('[sender]', senderPartyId);
const allHoldings = await loadUsdcxHoldings(senderPartyId);
console.table(allHoldings);
// Filter out locked holdings — the transfer implementation asserts
// "Input holding lock must match" and rejects locked inputs.
const holdings = allHoldings.filter((h) => !h.locked);
const lockedCount = allHoldings.length - holdings.length;
if (lockedCount > 0) {
console.log(`[holdings] ${lockedCount} locked, ${holdings.length} unlocked available`);
}
if (holdings.length === 0) {
throw new Error(
allHoldings.length > 0
? `All ${allHoldings.length} USDCx holding(s) are locked. Wait for in-flight transfers to settle.`
: 'No USDCx holdings found for the connected account.',
);
}
const totalBalance = sumAmounts(holdings);
if (totalBalance < Number(AMOUNT)) {
throw new Error(`Insufficient USDCx balance. Need ${AMOUNT}, found ${totalBalance}.`);
}
// Select the minimum set of holdings that covers the transfer amount.
// Sort by amount descending so we use the fewest contracts.
const sorted = [...holdings].sort(
(a, b) => Number(b.amount) - Number(a.amount),
);
const selectedHoldings = [];
let accumulated = 0;
for (const h of sorted) {
selectedHoldings.push(h);
accumulated += Number(h.amount);
if (accumulated >= Number(AMOUNT)) break;
}
const inputHoldingCids = selectedHoldings.map((h) => h.contractId);
console.log(`[holdings] using ${inputHoldingCids.length} holding(s), total ${accumulated}`);
// Build the transfer-factory request (plain JSON — this is what the
// token-standard REST endpoint expects).
const transferFactoryBody = {
choiceArguments: {
expectedAdmin: USDCX.admin,
transfer: {
sender: senderPartyId,
receiver: RECEIVER_PARTY_ID,
amount: AMOUNT,
instrumentId: {
admin: USDCX.admin,
id: USDCX.id,
},
requestedAt: new Date(Date.now() - 1000).toISOString(),
executeBefore: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
inputHoldingCids,
meta: { values: {} },
},
extraArgs: {
context: { values: {} },
meta: { values: {} },
},
},
excludeDebugFields: true,
};
const transferFactoryResponse = await fetch(TRANSFER_FACTORY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(transferFactoryBody),
});
const transferFactory = await transferFactoryResponse.json();
if (!transferFactoryResponse.ok) {
throw new Error(
`transfer-factory failed: ${transferFactoryResponse.status} ${JSON.stringify(
transferFactory,
)}`,
);
}
if (!transferFactory.factoryId) {
throw new Error(
`transfer-factory response missing factoryId: ${JSON.stringify(transferFactory)}`,
);
}
console.log('[transferKind]', transferFactory.transferKind);
console.log('[factoryId]', transferFactory.factoryId);
// Merge choiceContext from the transfer-factory response
const choiceContext =
transferFactory.choiceContext?.choiceContextData ?? { values: {} };
// Build the choiceArgument in protobuf Value JSON encoding.
// Canton ISS deserializes this via fromJson(PrepareSubmissionRequestSchema)
// which expects { record: { fields: [...] } } — not plain JSON objects.
const instrumentIdValue = vRecord([
['admin', vParty(USDCX.admin)],
['id', vText(USDCX.id)],
]);
const requestedAt = new Date(Date.now() - 1000).toISOString();
const executeBefore = new Date(Date.now() + 30 * 60 * 1000).toISOString();
// The transfer record matches the DA transfer how-to:
// sender, receiver, amount, instrumentId, requestedAt, executeBefore,
// inputHoldingCids, meta. No lock field — that's on Holding, not here.
const transferValue = vRecord([
['sender', vParty(senderPartyId)],
['receiver', vParty(RECEIVER_PARTY_ID)],
['amount', vNumeric(AMOUNT)],
['instrumentId', instrumentIdValue],
['requestedAt', vTimestamp(requestedAt)],
['executeBefore', vTimestamp(executeBefore)],
['inputHoldingCids', vList(inputHoldingCids.map(vContractId))],
['meta', vRecord([['values', vTextMap([])]])],
]);
// The Scan API / transfer-factory returns choiceContextData with AnyValue
// tagged values: { tag: "AV_ContractId", value: "00abc..." }
// These must be converted to DAML AnyValue variant encoding for protobuf.
//
// DAML types:
// ExtraArgs = { context: ChoiceContext, meta: ChoiceContext }
// ChoiceContext = { values: TextMap AnyValue }
// AnyValue = AV_ContractId Text | AV_Text Text | AV_Bool Bool | AV_List [AnyValue]
function scanValueToAnyValue(val) {
if (typeof val === 'string') {
// Plain string — wrap as AV_Text
return { variant: { constructor: 'AV_Text', value: vText(val) } };
}
if (typeof val === 'object' && val !== null && 'tag' in val) {
const tag = val.tag;
const inner = val.value;
if (tag === 'AV_ContractId') {
return { variant: { constructor: tag, value: vContractId(String(inner)) } };
}
if (tag === 'AV_Bool') {
return { variant: { constructor: tag, value: { bool: Boolean(inner) } } };
}
if (tag === 'AV_List') {
const elements = Array.isArray(inner) ? inner.map(scanValueToAnyValue) : [];
return { variant: { constructor: tag, value: vList(elements) } };
}
// Default: AV_Text or unknown tag
return { variant: { constructor: tag, value: vText(String(inner)) } };
}
// Fallback
return { variant: { constructor: 'AV_Text', value: vText(String(val)) } };
}
const contextEntries = Object.entries(choiceContext.values || {}).map(
([key, val]) => ({ key, value: scanValueToAnyValue(val) }),
);
const choiceContextValue = vRecord([
['values', vTextMap(contextEntries)],
]);
const emptyChoiceContext = vRecord([
['values', vTextMap([])],
]);
const extraArgsValue = vRecord([
['context', choiceContextValue],
['meta', emptyChoiceContext],
]);
const choiceArgument = vRecord([
['expectedAdmin', vParty(USDCX.admin)],
['transfer', transferValue],
['extraArgs', extraArgsValue],
]);
console.log('[choiceArgument]', JSON.stringify(choiceArgument, null, 2));
// commands must be a Record<string, command> (not an array) per the
// OpenRPC JsCommands spec — sigilry validates with z.record() before
// the controller sees the request.
const result = await window.canton.request({
method: 'prepareExecuteAndWait',
params: {
commandId: crypto.randomUUID(),
commands: {
transfer: {
exercise: {
templateId:
'#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory',
contractId: transferFactory.factoryId,
choice: 'TransferFactory_Transfer',
choiceArgument,
},
},
},
// Strip templateId from disclosed contracts — the protobuf Identifier
// type rejects plain strings, and the field is optional. Pool Party web
// does the same normalization (cantonPrepareExecute.ts).
disclosedContracts: (transferFactory.choiceContext.disclosedContracts || []).map(
({ templateId, ...rest }) => rest,
),
},
});
console.log('[prepareExecuteAndWait]', result);
if (transferFactory.transferKind === 'offer') {
console.log(
'This produced a transfer offer. The receiver likely needs a matching TransferPreapproval for direct settlement.',
);
}
} catch (error) {
console.error(error);
} finally {
window.canton.removeListener('txChanged', onTxChanged);
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment