Last active
March 27, 2026 22:36
-
-
Save 0xBigBoss/8888ca2f0ce137efea4ccded601ae6fa to your computer and use it in GitHub Desktop.
Canton dApp SDK demo — query active contracts via window.canton (Send extension)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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); | |
| })(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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