From cffa3da30150ba999ace98ffea074bc961b023df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 17:33:46 +0000 Subject: [PATCH 1/6] Read every ledger transaction shape, and add CIP-56 V1 result parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractEventsFromTransaction now orders eventsById by node id and falls back to the flat event array when a tree response carries an empty one, so a submitAndWaitForTransaction response is no longer silently dropped. Adds the transaction lookups a caller otherwise reimplements — requireExerciseResult, findExercisedEvent(s), findCreatedContractIds, getTransactionUpdateId, matchesTemplateId — and a token-standard V1 result layer next to the existing V2 utils, covering burn-mint, transfer and allocation. Co-authored-by: hardlydiff --- src/utils/parsers/event-parser.ts | 152 +++++++++- src/utils/token-standard/index.ts | 1 + src/utils/token-standard/v1/allocation.ts | 63 ++++ src/utils/token-standard/v1/burn-mint.ts | 56 ++++ src/utils/token-standard/v1/constants.ts | 64 +++++ src/utils/token-standard/v1/errors.ts | 19 ++ src/utils/token-standard/v1/index.ts | 6 + src/utils/token-standard/v1/result.ts | 59 ++++ src/utils/token-standard/v1/transfer.ts | 61 ++++ test/unit/parsers/event-parser.test.ts | 120 ++++++++ test/unit/token-standard/v1/results.test.ts | 268 ++++++++++++++++++ .../token-standard/v1/transactions-fixture.ts | 86 ++++++ 12 files changed, 951 insertions(+), 4 deletions(-) create mode 100644 src/utils/token-standard/v1/allocation.ts create mode 100644 src/utils/token-standard/v1/burn-mint.ts create mode 100644 src/utils/token-standard/v1/constants.ts create mode 100644 src/utils/token-standard/v1/errors.ts create mode 100644 src/utils/token-standard/v1/index.ts create mode 100644 src/utils/token-standard/v1/result.ts create mode 100644 src/utils/token-standard/v1/transfer.ts create mode 100644 test/unit/token-standard/v1/results.test.ts create mode 100644 test/unit/token-standard/v1/transactions-fixture.ts diff --git a/src/utils/parsers/event-parser.ts b/src/utils/parsers/event-parser.ts index 1561cd8a..e4b2dc8a 100644 --- a/src/utils/parsers/event-parser.ts +++ b/src/utils/parsers/event-parser.ts @@ -1,3 +1,4 @@ +import { CantonError, type ErrorContext } from '../../core/errors'; import { isRecord, isString } from '../../core/utils'; export interface ParsedTemplateId { @@ -131,6 +132,33 @@ export function hasTemplateName(templateId: string, expectedTemplateName: string } } +/** The `Module:Template` part of a template id, dropping the leading package id or `#package-name`. */ +export function qualifiedTemplateName(templateId: string): string { + const firstColon = templateId.indexOf(':'); + return firstColon === -1 ? templateId : templateId.slice(firstColon + 1); +} + +/** + * Match a template id from a ledger event against a filter, ignoring the package component. + * + * A create event always names the package _id_ that produced it, which a caller cannot know in advance; a filter is + * usually written with a package _name_ (`#MyPackage:Module:Template`) or without a package at all + * (`Module:Template`, or just `Template`). All three forms match here. + */ +export function matchesTemplateId(templateId: string, filter: string): boolean { + if (templateId === filter) return true; + + const idSuffix = templateId.split(':').slice(1); + if (idSuffix.length === 0) return false; + + const filterParts = filter.split(':'); + const filterSuffix = filterParts.length > idSuffix.length ? filterParts.slice(1) : filterParts; + if (filterSuffix.length === 0 || filterSuffix.length > idSuffix.length) return false; + + const offset = idSuffix.length - filterSuffix.length; + return filterSuffix.every((part, index) => part === idSuffix[offset + index]); +} + export function parseCreatedEvent(event: unknown): ParsedCreatedEvent | null { const value = unwrapVariant(event, 'created'); if (!value) return null; @@ -275,13 +303,15 @@ function getEventsById(transaction: unknown): Readonly> for (const path of paths) { const eventsById = getNestedRecord(transaction, path); - if (eventsById) return eventsById; + if (eventsById && Object.keys(eventsById).length > 0) return eventsById; } return null; } function getEventArray(transaction: unknown): readonly unknown[] | null { + if (Array.isArray(transaction)) return transaction; + const paths: ReadonlyArray = [ ['events'], ['transactionTree', 'events'], @@ -296,11 +326,30 @@ function getEventArray(transaction: unknown): readonly unknown[] | null { return null; } -export function extractEventsFromTransaction(transaction: unknown): ParsedTransactionEvents { +/** Node ids are numeric strings, but a map preserves whatever order it was built with. */ +function compareNodeIds(left: string, right: string): number { + const leftNumber = Number(left); + const rightNumber = Number(right); + if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) return leftNumber - rightNumber; + return left < right ? -1 : left > right ? 1 : 0; +} + +/** + * The raw events of a transaction in node-id order, from whichever shape the response used: a tree keyed by node id, or + * a flat event array, wrapped in `transactionTree`, in `transaction`, or on its own. + */ +export function getTransactionEvents(transaction: unknown): readonly unknown[] { const eventsById = getEventsById(transaction); - const events = eventsById ? Object.values(eventsById) : (getEventArray(transaction) ?? []); + if (eventsById) { + return Object.keys(eventsById) + .sort(compareNodeIds) + .map((key) => eventsById[key]); + } + return getEventArray(transaction) ?? []; +} - return events.reduce( +export function extractEventsFromTransaction(transaction: unknown): ParsedTransactionEvents { + return getTransactionEvents(transaction).reduce( (acc, event) => { const created = parseCreatedEvent(event); if (created) acc.created.push(created); @@ -316,3 +365,98 @@ export function extractEventsFromTransaction(transaction: unknown): ParsedTransa { created: [], archived: [], exercised: [] } ); } + +export const TransactionParseErrorCode = { + EXERCISE_RESULT_NOT_FOUND: 'TRANSACTION_EXERCISE_RESULT_NOT_FOUND', + UPDATE_ID_NOT_FOUND: 'TRANSACTION_UPDATE_ID_NOT_FOUND', +} as const; + +export type TransactionParseErrorCode = + (typeof TransactionParseErrorCode)[keyof typeof TransactionParseErrorCode]; + +/** Thrown when a transaction response does not contain something the caller asserted it would. */ +export class TransactionParseError extends CantonError { + public override readonly name: string; + + public constructor(code: TransactionParseErrorCode, message: string, context?: ErrorContext) { + super(message, code, context); + this.name = 'TransactionParseError'; + } +} + +/** The update id of a transaction response, or `undefined` when it names none. */ +export function getTransactionUpdateId(transaction: unknown): string | undefined { + const paths: ReadonlyArray = [['updateId'], ['transactionTree', 'updateId'], ['transaction', 'updateId']]; + + for (const path of paths) { + let current: unknown = transaction; + for (const segment of path) { + if (!isRecord(current)) { + current = undefined; + break; + } + current = current[segment]; + } + if (isString(current) && current.length > 0) return current; + } + + return undefined; +} + +/** The update id, for a caller that needs to correlate a submission with what the ledger did. */ +export function requireTransactionUpdateId(transaction: unknown): string { + const updateId = getTransactionUpdateId(transaction); + if (updateId === undefined) { + throw new TransactionParseError( + TransactionParseErrorCode.UPDATE_ID_NOT_FOUND, + 'The transaction names no update id.' + ); + } + return updateId; +} + +/** Every exercise of `choice` in the transaction, in node-id order. */ +export function findExercisedEvents(transaction: unknown, choice: string): ParsedExercisedEvent[] { + return extractEventsFromTransaction(transaction).exercised.filter((event) => event.choice === choice); +} + +/** The first exercise of any of `choices`, in node-id order — the order the transaction produced them. */ +export function findExercisedEvent( + transaction: unknown, + choices: string | readonly string[] +): ParsedExercisedEvent | undefined { + const wanted = typeof choices === 'string' ? [choices] : choices; + return extractEventsFromTransaction(transaction).exercised.find((event) => wanted.includes(event.choice)); +} + +/** The return value of the first exercise of `choice`, or `undefined` when the transaction contains none. */ +export function findExerciseResult(transaction: unknown, choice: string | readonly string[]): unknown { + return findExercisedEvent(transaction, choice)?.exerciseResult; +} + +/** + * The return value of the named choice. A caller asking for it is asserting the transaction contains it, so a + * transaction without that exercise is reported rather than returned as `undefined`. + */ +export function requireExerciseResult(transaction: unknown, choice: string | readonly string[]): unknown { + const exercised = findExercisedEvent(transaction, choice); + if (!exercised) { + const wanted = typeof choice === 'string' ? choice : choice.join(', '); + throw new TransactionParseError( + TransactionParseErrorCode.EXERCISE_RESULT_NOT_FOUND, + `The transaction contains no ${wanted} exercise.`, + { choice: wanted, updateId: getTransactionUpdateId(transaction) } + ); + } + return exercised.exerciseResult; +} + +/** + * Contract ids created by the transaction, in node-id order, optionally narrowed to one template. The filter is matched + * package-agnostically by {@link matchesTemplateId}. + */ +export function findCreatedContractIds(transaction: unknown, templateFilter?: string): string[] { + return extractEventsFromTransaction(transaction) + .created.filter((created) => templateFilter === undefined || matchesTemplateId(created.templateId, templateFilter)) + .map((created) => created.contractId); +} diff --git a/src/utils/token-standard/index.ts b/src/utils/token-standard/index.ts index cb50b322..6fa48202 100644 --- a/src/utils/token-standard/index.ts +++ b/src/utils/token-standard/index.ts @@ -1 +1,2 @@ +export * from './v1'; export * from './v2'; diff --git a/src/utils/token-standard/v1/allocation.ts b/src/utils/token-standard/v1/allocation.ts new file mode 100644 index 00000000..895e3ac7 --- /dev/null +++ b/src/utils/token-standard/v1/allocation.ts @@ -0,0 +1,63 @@ +/** + * Reserving units under an allocation, and the three ways an allocation ends. + * + * `AllocationFactory_Allocate` returns an `AllocationInstructionResult`, which names the allocation when the registry + * allocates in one step. `Allocation_ExecuteTransfer`, `_Cancel` and `_Withdraw` all return holdings; the difference + * between them is who gets them. + */ + +import { requireTransactionUpdateId } from '../../parsers/event-parser'; +import { TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES, TokenStandardV1Choice } from './constants'; +import { readContractIds, readVariant, requireContractIds, requireResultRecord, requireResultRecordOfAny } from './result'; +import type { TokenStandardV1TransferStatus } from './transfer'; + +export interface TokenStandardV1AllocationResult { + readonly updateId: string; + /** A registry that allocates in one step always reports `completed`. */ + readonly status: TokenStandardV1TransferStatus; + readonly senderChangeCids: string[]; + /** The allocation the units are now reserved under. */ + readonly allocationCid: string | undefined; +} + +/** The `AllocationInstructionResult` of `AllocationFactory_Allocate`. */ +export function parseAllocationResult(transaction: unknown): TokenStandardV1AllocationResult { + const result = requireResultRecord(transaction, TokenStandardV1Choice.allocate); + const updateId = requireTransactionUpdateId(transaction); + const senderChangeCids = requireContractIds(result['senderChangeCids'], 'senderChangeCids'); + const output = readVariant(result['output']); + + if (output?.tag === 'AllocationInstructionResult_Completed') { + const allocationCid = output.value['allocationCid']; + return { + updateId, + status: 'completed', + senderChangeCids, + allocationCid: typeof allocationCid === 'string' ? allocationCid : undefined, + }; + } + + return { + updateId, + status: output?.tag === 'AllocationInstructionResult_Pending' ? 'pending' : 'failed', + senderChangeCids, + allocationCid: undefined, + }; +} + +export interface TokenStandardV1AllocationTransferResult { + readonly updateId: string; + /** Units returned to the sender: everything on a cancel or a withdraw, nothing on a delivery. */ + readonly senderHoldingCids: string[]; + readonly receiverHoldingCids: string[]; +} + +/** The result of the allocation exit in this transaction — `Allocation_ExecuteTransfer`, `_Cancel` or `_Withdraw`. */ +export function parseAllocationTransferResult(transaction: unknown): TokenStandardV1AllocationTransferResult { + const result = requireResultRecordOfAny(transaction, TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES); + return { + updateId: requireTransactionUpdateId(transaction), + senderHoldingCids: readContractIds(result['senderHoldingCids']), + receiverHoldingCids: readContractIds(result['receiverHoldingCids']), + }; +} diff --git a/src/utils/token-standard/v1/burn-mint.ts b/src/utils/token-standard/v1/burn-mint.ts new file mode 100644 index 00000000..5391322e --- /dev/null +++ b/src/utils/token-standard/v1/burn-mint.ts @@ -0,0 +1,56 @@ +/** + * Reading a mint or a burn back out of the transaction that performed it. + * + * `BurnMintFactory_BurnMint` returns a record rather than contract ids, and the interesting half of a mint is in that + * record: `outputCids` names the holdings it created. The create events alone cannot say it — a mint of two holdings + * creates two contracts with nothing to distinguish which output each was — so the exercise result is read instead. + */ + +import { isRecord } from '../../../core/utils'; +import { + findExercisedEvent, + findCreatedContractIds, + requireTransactionUpdateId, +} from '../../parsers/event-parser'; +import { TokenStandardV1Choice } from './constants'; +import { requireContractIds, requireResultRecord } from './result'; + +export interface TokenStandardV1BurnMintResult { + readonly updateId: string; + /** `Holding` interface contract ids, one per output, in the order the outputs were given. Empty for a pure burn. */ + readonly outputHoldingCids: string[]; +} + +/** + * The result of the burn-mint in this transaction: a mint's new holdings, or an empty list for a burn. Also finds a + * burn-mint that was exercised as a child of another choice, which is how a redeem's burn is confirmed. + */ +export function parseBurnMintResult(transaction: unknown): TokenStandardV1BurnMintResult { + const result = requireResultRecord(transaction, TokenStandardV1Choice.burnMint); + return { + updateId: requireTransactionUpdateId(transaction), + outputHoldingCids: requireContractIds(result['outputCids'], 'outputCids'), + }; +} + +export interface ParseMintedHoldingsOptions { + /** + * Template of the concrete holding contract, used only for the fallback path. Matched package-agnostically, so + * `Module:Template`, `Template`, or a fully qualified id all work. When omitted the fallback returns every contract + * the transaction created. + */ + readonly holdingTemplate?: string; +} + +/** + * The holdings a mint created. Falls back to the transaction's create events when it contains no burn-mint exercise — + * an admin recovery may mint through a registry-specific enforcement choice, which returns contract ids of the concrete + * template rather than a burn-mint result. + */ +export function parseMintedHoldings(transaction: unknown, options: ParseMintedHoldingsOptions = {}): string[] { + const burnMint = findExercisedEvent(transaction, TokenStandardV1Choice.burnMint); + if (burnMint && isRecord(burnMint.exerciseResult)) { + return requireContractIds(burnMint.exerciseResult['outputCids'], 'outputCids'); + } + return findCreatedContractIds(transaction, options.holdingTemplate); +} diff --git a/src/utils/token-standard/v1/constants.ts b/src/utils/token-standard/v1/constants.ts new file mode 100644 index 00000000..c44504b2 --- /dev/null +++ b/src/utils/token-standard/v1/constants.ts @@ -0,0 +1,64 @@ +/** + * Interface ids and choice names of the CIP-56 token standard V1 APIs. + * + * A standard choice is exercised against the _interface_ id rather than the template id, because that is the contract + * the choice is defined on. The interface ids are package-name scoped (`#package-name:Module:Interface`), which is what + * keeps them stable across DAR upgrades. + */ + +export const TOKEN_STANDARD_V1_HOLDING_INTERFACE_ID = '#splice-api-token-holding-v1:Splice.Api.Token.HoldingV1:Holding'; + +/** Exercise target for `BurnMintFactory_BurnMint`, which every mint, burn and redeem goes through. */ +export const TOKEN_STANDARD_V1_BURN_MINT_FACTORY_INTERFACE_ID = + '#splice-api-token-burn-mint-v1:Splice.Api.Token.BurnMintV1:BurnMintFactory'; + +/** Exercise target for `TransferFactory_Transfer`. */ +export const TOKEN_STANDARD_V1_TRANSFER_FACTORY_INTERFACE_ID = + '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferFactory'; + +/** Exercise target for the four choices on a pending transfer offer. */ +export const TOKEN_STANDARD_V1_TRANSFER_INSTRUCTION_INTERFACE_ID = + '#splice-api-token-transfer-instruction-v1:Splice.Api.Token.TransferInstructionV1:TransferInstruction'; + +/** Exercise target for `AllocationFactory_Allocate`. */ +export const TOKEN_STANDARD_V1_ALLOCATION_FACTORY_INTERFACE_ID = + '#splice-api-token-allocation-instruction-v1:Splice.Api.Token.AllocationInstructionV1:AllocationFactory'; + +/** Exercise target for the three choices on a settled or unwinding allocation. */ +export const TOKEN_STANDARD_V1_ALLOCATION_INTERFACE_ID = + '#splice-api-token-allocation-v1:Splice.Api.Token.AllocationV1:Allocation'; + +/** Choice names as the Ledger JSON API spells them in an `ExerciseCommand`. */ +export const TokenStandardV1Choice = { + burnMint: 'BurnMintFactory_BurnMint', + transfer: 'TransferFactory_Transfer', + transferInstructionAccept: 'TransferInstruction_Accept', + transferInstructionReject: 'TransferInstruction_Reject', + transferInstructionWithdraw: 'TransferInstruction_Withdraw', + transferInstructionUpdate: 'TransferInstruction_Update', + allocate: 'AllocationFactory_Allocate', + allocationExecuteTransfer: 'Allocation_ExecuteTransfer', + allocationCancel: 'Allocation_Cancel', + allocationWithdraw: 'Allocation_Withdraw', +} as const; + +export type TokenStandardV1ChoiceName = (typeof TokenStandardV1Choice)[keyof typeof TokenStandardV1Choice]; + +/** + * The five choices that return a `TransferInstructionResult`. One reader covers instructing, accepting, rejecting, + * withdrawing and expiring, because all five return the same record. + */ +export const TOKEN_STANDARD_V1_TRANSFER_RESULT_CHOICES: readonly string[] = [ + TokenStandardV1Choice.transfer, + TokenStandardV1Choice.transferInstructionAccept, + TokenStandardV1Choice.transferInstructionReject, + TokenStandardV1Choice.transferInstructionWithdraw, + TokenStandardV1Choice.transferInstructionUpdate, +]; + +/** The three exits an allocation has, all returning holdings; the difference is who gets them. */ +export const TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES: readonly string[] = [ + TokenStandardV1Choice.allocationExecuteTransfer, + TokenStandardV1Choice.allocationCancel, + TokenStandardV1Choice.allocationWithdraw, +]; diff --git a/src/utils/token-standard/v1/errors.ts b/src/utils/token-standard/v1/errors.ts new file mode 100644 index 00000000..5ec80beb --- /dev/null +++ b/src/utils/token-standard/v1/errors.ts @@ -0,0 +1,19 @@ +import { CantonError, type ErrorContext } from '../../../core/errors'; + +export const TokenStandardV1ResultErrorCode = { + RESULT_NOT_FOUND: 'TOKEN_STANDARD_V1_RESULT_NOT_FOUND', + RESULT_INVALID: 'TOKEN_STANDARD_V1_RESULT_INVALID', +} as const; + +export type TokenStandardV1ResultErrorCode = + (typeof TokenStandardV1ResultErrorCode)[keyof typeof TokenStandardV1ResultErrorCode]; + +/** Thrown when a transaction does not contain the token standard V1 result a reader was asked for, or it is malformed. */ +export class TokenStandardV1ResultError extends CantonError { + public override readonly name: string; + + public constructor(code: TokenStandardV1ResultErrorCode, message: string, context?: ErrorContext) { + super(message, code, context); + this.name = 'TokenStandardV1ResultError'; + } +} diff --git a/src/utils/token-standard/v1/index.ts b/src/utils/token-standard/v1/index.ts new file mode 100644 index 00000000..000ab750 --- /dev/null +++ b/src/utils/token-standard/v1/index.ts @@ -0,0 +1,6 @@ +export * from './allocation'; +export * from './burn-mint'; +export * from './constants'; +export * from './errors'; +export * from './result'; +export * from './transfer'; diff --git a/src/utils/token-standard/v1/result.ts b/src/utils/token-standard/v1/result.ts new file mode 100644 index 00000000..df13b73d --- /dev/null +++ b/src/utils/token-standard/v1/result.ts @@ -0,0 +1,59 @@ +import { isRecord } from '../../../core/utils'; +import { findExercisedEvent, getTransactionUpdateId, requireExerciseResult } from '../../parsers/event-parser'; +import { TokenStandardV1ResultError, TokenStandardV1ResultErrorCode } from './errors'; + +/** The record a standard choice returned, or a failure: a caller asking for it asserts the transaction contains it. */ +export function requireResultRecord(transaction: unknown, choice: string): Record { + const result = requireExerciseResult(transaction, choice); + if (!isRecord(result)) { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_INVALID, + `The ${choice} exercise returned no result record.`, + { choice, updateId: getTransactionUpdateId(transaction) } + ); + } + return result; +} + +/** The record returned by the first of `choices` this transaction exercised. */ +export function requireResultRecordOfAny(transaction: unknown, choices: readonly string[]): Record { + const exercised = findExercisedEvent(transaction, choices); + if (!exercised) { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_NOT_FOUND, + `The transaction contains none of these exercises: ${choices.join(', ')}.`, + { choices: [...choices], updateId: getTransactionUpdateId(transaction) } + ); + } + if (!isRecord(exercised.exerciseResult)) { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_INVALID, + `The ${exercised.choice} exercise returned no result record.`, + { choice: exercised.choice, updateId: getTransactionUpdateId(transaction) } + ); + } + return exercised.exerciseResult; +} + +/** A required list of contract ids from a result record. */ +export function requireContractIds(value: unknown, field: string): string[] { + if (!Array.isArray(value)) { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_INVALID, + `The exercise result has no ${field}.`, + { field } + ); + } + return value.filter((entry): entry is string => typeof entry === 'string'); +} + +/** An optional list of contract ids, which the token standard omits rather than sending empty on some paths. */ +export function readContractIds(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []; +} + +/** `{ tag, value }`, the Daml JSON encoding of a variant. */ +export function readVariant(value: unknown): { readonly tag: string; readonly value: Record } | undefined { + if (!isRecord(value) || typeof value['tag'] !== 'string') return undefined; + return { tag: value['tag'], value: isRecord(value['value']) ? value['value'] : {} }; +} diff --git a/src/utils/token-standard/v1/transfer.ts b/src/utils/token-standard/v1/transfer.ts new file mode 100644 index 00000000..f51eb9ae --- /dev/null +++ b/src/utils/token-standard/v1/transfer.ts @@ -0,0 +1,61 @@ +/** + * What became of a transfer: delivered, waiting on the receiver, or returned to the sender. + * + * `TransferInstructionResult.output` is the only thing that says which, and all five transfer choices return it, so one + * reader covers instructing, accepting, rejecting, withdrawing and expiring. + */ + +import { requireTransactionUpdateId } from '../../parsers/event-parser'; +import { TOKEN_STANDARD_V1_TRANSFER_RESULT_CHOICES } from './constants'; +import { readVariant, requireContractIds, requireResultRecordOfAny } from './result'; + +/** What became of the units: delivered, waiting on the receiver, or returned to the sender. */ +export type TokenStandardV1TransferStatus = 'completed' | 'pending' | 'failed'; + +export interface TokenStandardV1TransferResult { + readonly updateId: string; + readonly status: TokenStandardV1TransferStatus; + /** Change back to the sender, and on a failed path the returned units themselves. */ + readonly senderChangeCids: string[]; + /** The receiver's new holdings, on a completed transfer. */ + readonly receiverHoldingCids: string[]; + /** The pending offer, when the transfer did not complete in this transaction. */ + readonly transferInstructionCid: string | undefined; +} + +/** The `TransferInstructionResult` of whichever of the five transfer choices this transaction exercised. */ +export function parseTransferResult(transaction: unknown): TokenStandardV1TransferResult { + const result = requireResultRecordOfAny(transaction, TOKEN_STANDARD_V1_TRANSFER_RESULT_CHOICES); + const updateId = requireTransactionUpdateId(transaction); + const senderChangeCids = requireContractIds(result['senderChangeCids'], 'senderChangeCids'); + const output = readVariant(result['output']); + + if (output?.tag === 'TransferInstructionResult_Completed') { + return { + updateId, + status: 'completed', + senderChangeCids, + receiverHoldingCids: requireContractIds(output.value['receiverHoldingCids'], 'receiverHoldingCids'), + transferInstructionCid: undefined, + }; + } + + if (output?.tag === 'TransferInstructionResult_Pending') { + const transferInstructionCid = output.value['transferInstructionCid']; + return { + updateId, + status: 'pending', + senderChangeCids, + receiverHoldingCids: [], + transferInstructionCid: typeof transferInstructionCid === 'string' ? transferInstructionCid : undefined, + }; + } + + return { + updateId, + status: 'failed', + senderChangeCids, + receiverHoldingCids: [], + transferInstructionCid: undefined, + }; +} diff --git a/test/unit/parsers/event-parser.test.ts b/test/unit/parsers/event-parser.test.ts index 9d6b669f..291ce7fe 100644 --- a/test/unit/parsers/event-parser.test.ts +++ b/test/unit/parsers/event-parser.test.ts @@ -1,10 +1,19 @@ import { extractEventsFromTransaction, + findCreatedContractIds, + findExerciseResult, + findExercisedEvents, + getTransactionUpdateId, hasTemplateName, + matchesTemplateId, parseArchivedEvent, parseCreatedEvent, parseExercisedEvent, parseTemplateId, + qualifiedTemplateName, + requireExerciseResult, + requireTransactionUpdateId, + TransactionParseErrorCode, } from '../../../src/utils/parsers/event-parser'; describe('event-parser', () => { @@ -331,5 +340,116 @@ describe('event-parser', () => { exercised: [], }); }); + + it('orders eventsById by node id rather than by insertion order', () => { + const result = extractEventsFromTransaction({ + transactionTree: { + eventsById: { + '10': createdTreeEvent('created-10'), + '2': createdTreeEvent('created-2'), + '1': createdTreeEvent('created-1'), + }, + }, + }); + + expect(result.created.map((created) => created.contractId)).toEqual(['created-1', 'created-2', 'created-10']); + }); + + it('falls back to the flat event array when eventsById is present but empty', () => { + const result = extractEventsFromTransaction({ + transaction: { + eventsById: {}, + events: [{ CreatedEvent: { contractId: 'created-1', templateId: 'pkg:Module:Created' } }], + }, + }); + + expect(result.created[0]?.contractId).toBe('created-1'); + }); + + it('reads a bare event array', () => { + const result = extractEventsFromTransaction([ + { CreatedEvent: { contractId: 'created-1', templateId: 'pkg:Module:Created' } }, + ]); + + expect(result.created[0]?.contractId).toBe('created-1'); + }); + }); + + describe('matchesTemplateId', () => { + it('matches a package id against a package-name filter, and a bare qualified name', () => { + expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', '#WrappedAssets-v01:WrappedAssets.Holding:WrappedAsset')).toBe(true); + expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', 'WrappedAssets.Holding:WrappedAsset')).toBe(true); + expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', 'WrappedAsset')).toBe(true); + }); + + it('does not match a different module or template', () => { + expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', 'WrappedAssets.Locked:WrappedAsset')).toBe(false); + expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', 'FrozenWrappedAsset')).toBe(false); + expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', '')).toBe(false); + }); + + it('keeps nested module delimiters as part of the module', () => { + expect(matchesTemplateId('pkg:Module:Nested:Template', 'Module:Nested:Template')).toBe(true); + expect(matchesTemplateId('pkg:Module:Nested:Template', '#name:Module:Nested:Template')).toBe(true); + expect(qualifiedTemplateName('pkg:Module:Nested:Template')).toBe('Module:Nested:Template'); + }); + }); + + describe('transaction lookups', () => { + const transaction = { + transaction: { + updateId: 'update-1', + events: [ + { ExercisedEvent: { contractId: 'cid-1', templateId: 'pkg:Module:Factory', choice: 'Freeze', exerciseResult: 'cid-frozen' } }, + { CreatedEvent: { contractId: 'created-1', templateId: 'pkg:Module:Holding' } }, + { CreatedEvent: { contractId: 'created-2', templateId: 'pkg:Module:Other' } }, + { ExercisedEvent: { contractId: 'cid-2', templateId: 'pkg:Module:Factory', choice: 'Unfreeze', exerciseResult: null } }, + ], + }, + }; + + it('reads the update id from every wrapper shape', () => { + expect(getTransactionUpdateId(transaction)).toBe('update-1'); + expect(getTransactionUpdateId({ transactionTree: { updateId: 'update-2' } })).toBe('update-2'); + expect(getTransactionUpdateId({ updateId: 'update-3' })).toBe('update-3'); + expect(getTransactionUpdateId({ transactionTree: { eventsById: {} } })).toBeUndefined(); + expect(() => requireTransactionUpdateId('not a transaction')).toThrow('The transaction names no update id.'); + }); + + it('returns the raw result of a named choice, including a falsy one', () => { + expect(requireExerciseResult(transaction, 'Freeze')).toBe('cid-frozen'); + expect(requireExerciseResult(transaction, ['Unfreeze'])).toBeNull(); + expect(findExerciseResult(transaction, 'Missing')).toBeUndefined(); + }); + + it('takes the earliest matching exercise when given several choices, not the first choice listed', () => { + expect(requireExerciseResult(transaction, ['Unfreeze', 'Freeze'])).toBe('cid-frozen'); + }); + + it('reports a choice the transaction did not exercise', () => { + let thrown: unknown; + try { + requireExerciseResult(transaction, 'Missing'); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + name: 'TransactionParseError', + code: TransactionParseErrorCode.EXERCISE_RESULT_NOT_FOUND, + context: { choice: 'Missing', updateId: 'update-1' }, + }); + }); + + it('finds every exercise of one choice, and narrows created contract ids by template', () => { + expect(findExercisedEvents(transaction, 'Freeze').map((event) => event.contractId)).toEqual(['cid-1']); + expect(findCreatedContractIds(transaction)).toEqual(['created-1', 'created-2']); + expect(findCreatedContractIds(transaction, 'Module:Holding')).toEqual(['created-1']); + expect(findCreatedContractIds(transaction, 'Missing')).toEqual([]); + }); }); }); + +function createdTreeEvent(contractId: string): unknown { + return { CreatedTreeEvent: { value: { contractId, templateId: 'pkg:Module:Created' } } }; +} diff --git a/test/unit/token-standard/v1/results.test.ts b/test/unit/token-standard/v1/results.test.ts new file mode 100644 index 00000000..108ec1de --- /dev/null +++ b/test/unit/token-standard/v1/results.test.ts @@ -0,0 +1,268 @@ +import { + parseAllocationResult, + parseAllocationTransferResult, + parseBurnMintResult, + parseMintedHoldings, + parseTransferResult, + TokenStandardV1Choice, + TokenStandardV1ResultErrorCode, +} from '../../../../src/utils/token-standard/v1'; +import { + BURN_OFFER_TEMPLATE, + created, + exercised, + flatTransaction, + flattenedTransaction, + HOLDING_TEMPLATE, + transactionTree, + UPDATE_ID, +} from './transactions-fixture'; + +describe('token standard v1 burn-mint results', () => { + it('names the minted holdings in output order, from the exercise result rather than the creates', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.burnMint, + exerciseResult: { outputCids: ['cid-out-1', 'cid-out-2'] }, + }), + }, + { created: created(HOLDING_TEMPLATE, 'cid-out-1') }, + { created: created(HOLDING_TEMPLATE, 'cid-out-2') }, + ]); + + expect(parseBurnMintResult(response)).toEqual({ + updateId: UPDATE_ID, + outputHoldingCids: ['cid-out-1', 'cid-out-2'], + }); + expect(parseMintedHoldings(response)).toEqual(['cid-out-1', 'cid-out-2']); + }); + + it('reads a burn as an empty output list, not as a missing result', () => { + const response = transactionTree([ + { exercised: exercised({ choice: 'Accept' }) }, + { exercised: exercised({ choice: TokenStandardV1Choice.burnMint, exerciseResult: { outputCids: [] } }) }, + ]); + + expect(parseBurnMintResult(response).outputHoldingCids).toEqual([]); + }); + + it('falls back to the created holdings when the mint went through an enforcement action', () => { + const response = transactionTree([ + { exercised: exercised({ choice: 'BurnHoldingEnforcement', exerciseResult: {} }) }, + { created: created(BURN_OFFER_TEMPLATE, 'cid-offer') }, + { created: created(HOLDING_TEMPLATE, 'cid-reissued') }, + ]); + + expect(parseMintedHoldings(response, { holdingTemplate: HOLDING_TEMPLATE })).toEqual(['cid-reissued']); + expect(parseMintedHoldings(response)).toEqual(['cid-offer', 'cid-reissued']); + }); + + it('says so when the transaction contains no burn-mint at all', () => { + expect(() => parseBurnMintResult(transactionTree([{ created: created(HOLDING_TEMPLATE, 'cid-1') }]))).toThrow( + /no BurnMintFactory_BurnMint exercise/ + ); + }); + + it('reports a burn-mint result that names no outputCids', () => { + const response = transactionTree([ + { exercised: exercised({ choice: TokenStandardV1Choice.burnMint, exerciseResult: { meta: { values: {} } } }) }, + ]); + + expect(() => parseBurnMintResult(response)).toThrow(/no outputCids/); + }); +}); + +describe('token standard v1 transfer results', () => { + it('reads a completed transfer, from any of the five choices that return one', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.transferInstructionAccept, + exerciseResult: { + senderChangeCids: [], + output: { tag: 'TransferInstructionResult_Completed', value: { receiverHoldingCids: ['cid-receiver'] } }, + meta: { values: {} }, + }, + }), + }, + ]); + + expect(parseTransferResult(response)).toEqual({ + updateId: UPDATE_ID, + status: 'completed', + senderChangeCids: [], + receiverHoldingCids: ['cid-receiver'], + transferInstructionCid: undefined, + }); + }); + + it('names the pending offer, and the change that went back to the sender with it', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.transfer, + exerciseResult: { + senderChangeCids: ['cid-change'], + output: { tag: 'TransferInstructionResult_Pending', value: { transferInstructionCid: 'cid-instruction' } }, + meta: { values: {} }, + }, + }), + }, + ]); + + expect(parseTransferResult(response)).toEqual({ + updateId: UPDATE_ID, + status: 'pending', + senderChangeCids: ['cid-change'], + receiverHoldingCids: [], + transferInstructionCid: 'cid-instruction', + }); + }); + + it('reads a return path as failed, with the returned units as sender change', () => { + const response = flatTransaction([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.transferInstructionReject, + exerciseResult: { + senderChangeCids: ['cid-returned'], + output: { tag: 'TransferInstructionResult_Failed', value: {} }, + meta: { values: {} }, + }, + }), + }, + ]); + const result = parseTransferResult(response); + + expect(result.status).toBe('failed'); + expect(result.senderChangeCids).toEqual(['cid-returned']); + expect(result.receiverHoldingCids).toEqual([]); + }); + + it('reads the same result out of a flattened submit-and-wait response', () => { + const response = flattenedTransaction([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.transferInstructionWithdraw, + exerciseResult: { + senderChangeCids: ['cid-returned'], + output: { tag: 'TransferInstructionResult_Failed', value: {} }, + meta: { values: {} }, + }, + }), + }, + ]); + + expect(parseTransferResult(response).senderChangeCids).toEqual(['cid-returned']); + }); + + it('says so when the transaction contains no transfer result', () => { + const response = transactionTree([{ created: created(HOLDING_TEMPLATE, 'cid-1') }]); + + expect(() => parseTransferResult(response)).toThrow(/contains none of these exercises/); + }); +}); + +describe('token standard v1 allocation results', () => { + it('names the allocation the units are reserved under', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocate, + exerciseResult: { + senderChangeCids: ['cid-change'], + output: { tag: 'AllocationInstructionResult_Completed', value: { allocationCid: 'cid-allocation' } }, + meta: { values: {} }, + }, + }), + }, + ]); + + expect(parseAllocationResult(response)).toEqual({ + updateId: UPDATE_ID, + status: 'completed', + senderChangeCids: ['cid-change'], + allocationCid: 'cid-allocation', + }); + }); + + it('reads a pending allocation instruction without an allocation', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocate, + exerciseResult: { + senderChangeCids: [], + output: { + tag: 'AllocationInstructionResult_Pending', + value: { allocationInstructionCid: 'cid-instruction' }, + }, + }, + }), + }, + ]); + + expect(parseAllocationResult(response)).toEqual({ + updateId: UPDATE_ID, + status: 'pending', + senderChangeCids: [], + allocationCid: undefined, + }); + }); + + it('reads delivery and unwinding through the same result, which differ only in who gets the holdings', () => { + const executed = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocationExecuteTransfer, + exerciseResult: { senderHoldingCids: [], receiverHoldingCids: ['cid-receiver'], meta: { values: {} } }, + }), + }, + ]); + const cancelled = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocationCancel, + exerciseResult: { senderHoldingCids: ['cid-returned'], meta: { values: {} } }, + }), + }, + ]); + + expect(parseAllocationTransferResult(executed)).toEqual({ + updateId: UPDATE_ID, + senderHoldingCids: [], + receiverHoldingCids: ['cid-receiver'], + }); + expect(parseAllocationTransferResult(cancelled)).toEqual({ + updateId: UPDATE_ID, + senderHoldingCids: ['cid-returned'], + receiverHoldingCids: [], + }); + }); + + it('reports a result that is not a record rather than reading fields off it', () => { + const response = transactionTree([ + { exercised: exercised({ choice: TokenStandardV1Choice.allocationWithdraw, exerciseResult: 'nonsense' }) }, + ]); + + expect(() => parseAllocationTransferResult(response)).toThrow(/returned no result record/); + }); + + it('carries the token standard v1 error code on a missing result', () => { + const response = transactionTree([{ created: created(HOLDING_TEMPLATE, 'cid-1') }]); + + let thrown: unknown; + try { + parseAllocationTransferResult(response); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + name: 'TokenStandardV1ResultError', + code: TokenStandardV1ResultErrorCode.RESULT_NOT_FOUND, + context: { updateId: UPDATE_ID }, + }); + }); +}); diff --git a/test/unit/token-standard/v1/transactions-fixture.ts b/test/unit/token-standard/v1/transactions-fixture.ts new file mode 100644 index 00000000..49aa6d95 --- /dev/null +++ b/test/unit/token-standard/v1/transactions-fixture.ts @@ -0,0 +1,86 @@ +/** + * Submitted transactions as the Ledger JSON API returns them, so the result readers can be asserted without a + * participant node. + * + * Every response shape is built here on purpose: the submit-and-wait tree endpoints key events by node id and name the + * variants `*TreeEvent`, the flat ones return an array and name them `*Event`, and either spelling arrives with its + * payload nested under `value` or flattened onto the wrapper. A reader that only handled one would pass a fixture that + * only built one. + */ + +export const UPDATE_ID = 'update-1220abcd'; + +export const PACKAGE_ID = '0e5b1f4f1a2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6'; + +export const HOLDING_TEMPLATE = 'WrappedAssets.Holding:WrappedAsset'; + +export const BURN_OFFER_TEMPLATE = 'WrappedAssets.BurnOffer:BurnOffer'; + +export interface ExercisedFixture { + readonly choice: string; + readonly exerciseResult?: unknown; + readonly contractId?: string; + readonly templateId?: string; + readonly interfaceId?: string; +} + +export function exercised(fixture: ExercisedFixture): Record { + return { + choice: fixture.choice, + contractId: fixture.contractId ?? 'cid-exercised', + templateId: fixture.templateId ?? `${PACKAGE_ID}:WrappedAssets.BurnMint:WrappedAssetsBurnMintFactory`, + interfaceId: fixture.interfaceId ?? null, + exerciseResult: fixture.exerciseResult ?? {}, + }; +} + +export function created(template: string, contractId: string): Record { + return { + contractId, + templateId: `${PACKAGE_ID}:${template}`, + createArgument: {}, + }; +} + +export interface EventFixture { + readonly exercised?: Record; + readonly created?: Record; +} + +/** The tree shape: events keyed by node id, variants named `*TreeEvent`, payload nested under `value`. */ +export function transactionTree(events: readonly EventFixture[], updateId: string = UPDATE_ID): unknown { + const eventsById: Record = {}; + events.forEach((event, index) => { + eventsById[String(index)] = + event.exercised === undefined + ? { CreatedTreeEvent: { value: event.created } } + : { ExercisedTreeEvent: { value: event.exercised } }; + }); + return { transactionTree: { updateId, eventsById } }; +} + +/** The flat shape: an event array, variants named `*Event`, payload nested under `value`. */ +export function flatTransaction(events: readonly EventFixture[], updateId: string = UPDATE_ID): unknown { + return { + transaction: { + updateId, + events: events.map((event) => + event.exercised === undefined + ? { CreatedEvent: { value: event.created } } + : { ExercisedEvent: { value: event.exercised } } + ), + }, + }; +} + +/** The flat shape with the payload flattened onto the wrapper rather than nested under `value`. */ +export function flattenedTransaction(events: readonly EventFixture[], updateId: string = UPDATE_ID): unknown { + return { + transaction: { + updateId, + events: events.map((event) => + event.exercised === undefined ? { CreatedEvent: event.created } : { ExercisedEvent: event.exercised } + ), + }, + }; +} From d110ea8af6698f78005d30c16cdd5f446fd81de3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 17:41:43 +0000 Subject: [PATCH 2/6] Satisfy prefer-destructuring and prettier in the new parsers Co-authored-by: hardlydiff --- src/utils/parsers/event-parser.ts | 13 +++++---- src/utils/token-standard/v1/allocation.ts | 10 +++++-- src/utils/token-standard/v1/burn-mint.ts | 6 +---- src/utils/token-standard/v1/result.ts | 4 ++- src/utils/token-standard/v1/transfer.ts | 2 +- test/unit/parsers/event-parser.test.ts | 33 +++++++++++++++++++---- 6 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/utils/parsers/event-parser.ts b/src/utils/parsers/event-parser.ts index e4b2dc8a..2dc9de82 100644 --- a/src/utils/parsers/event-parser.ts +++ b/src/utils/parsers/event-parser.ts @@ -142,8 +142,8 @@ export function qualifiedTemplateName(templateId: string): string { * Match a template id from a ledger event against a filter, ignoring the package component. * * A create event always names the package _id_ that produced it, which a caller cannot know in advance; a filter is - * usually written with a package _name_ (`#MyPackage:Module:Template`) or without a package at all - * (`Module:Template`, or just `Template`). All three forms match here. + * usually written with a package _name_ (`#MyPackage:Module:Template`) or without a package at all (`Module:Template`, + * or just `Template`). All three forms match here. */ export function matchesTemplateId(templateId: string, filter: string): boolean { if (templateId === filter) return true; @@ -371,8 +371,7 @@ export const TransactionParseErrorCode = { UPDATE_ID_NOT_FOUND: 'TRANSACTION_UPDATE_ID_NOT_FOUND', } as const; -export type TransactionParseErrorCode = - (typeof TransactionParseErrorCode)[keyof typeof TransactionParseErrorCode]; +export type TransactionParseErrorCode = (typeof TransactionParseErrorCode)[keyof typeof TransactionParseErrorCode]; /** Thrown when a transaction response does not contain something the caller asserted it would. */ export class TransactionParseError extends CantonError { @@ -386,7 +385,11 @@ export class TransactionParseError extends CantonError { /** The update id of a transaction response, or `undefined` when it names none. */ export function getTransactionUpdateId(transaction: unknown): string | undefined { - const paths: ReadonlyArray = [['updateId'], ['transactionTree', 'updateId'], ['transaction', 'updateId']]; + const paths: ReadonlyArray = [ + ['updateId'], + ['transactionTree', 'updateId'], + ['transaction', 'updateId'], + ]; for (const path of paths) { let current: unknown = transaction; diff --git a/src/utils/token-standard/v1/allocation.ts b/src/utils/token-standard/v1/allocation.ts index 895e3ac7..31eb9b99 100644 --- a/src/utils/token-standard/v1/allocation.ts +++ b/src/utils/token-standard/v1/allocation.ts @@ -8,7 +8,13 @@ import { requireTransactionUpdateId } from '../../parsers/event-parser'; import { TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES, TokenStandardV1Choice } from './constants'; -import { readContractIds, readVariant, requireContractIds, requireResultRecord, requireResultRecordOfAny } from './result'; +import { + readContractIds, + readVariant, + requireContractIds, + requireResultRecord, + requireResultRecordOfAny, +} from './result'; import type { TokenStandardV1TransferStatus } from './transfer'; export interface TokenStandardV1AllocationResult { @@ -28,7 +34,7 @@ export function parseAllocationResult(transaction: unknown): TokenStandardV1Allo const output = readVariant(result['output']); if (output?.tag === 'AllocationInstructionResult_Completed') { - const allocationCid = output.value['allocationCid']; + const { allocationCid } = output.value; return { updateId, status: 'completed', diff --git a/src/utils/token-standard/v1/burn-mint.ts b/src/utils/token-standard/v1/burn-mint.ts index 5391322e..c20f992d 100644 --- a/src/utils/token-standard/v1/burn-mint.ts +++ b/src/utils/token-standard/v1/burn-mint.ts @@ -7,11 +7,7 @@ */ import { isRecord } from '../../../core/utils'; -import { - findExercisedEvent, - findCreatedContractIds, - requireTransactionUpdateId, -} from '../../parsers/event-parser'; +import { findExercisedEvent, findCreatedContractIds, requireTransactionUpdateId } from '../../parsers/event-parser'; import { TokenStandardV1Choice } from './constants'; import { requireContractIds, requireResultRecord } from './result'; diff --git a/src/utils/token-standard/v1/result.ts b/src/utils/token-standard/v1/result.ts index df13b73d..675f44c2 100644 --- a/src/utils/token-standard/v1/result.ts +++ b/src/utils/token-standard/v1/result.ts @@ -53,7 +53,9 @@ export function readContractIds(value: unknown): string[] { } /** `{ tag, value }`, the Daml JSON encoding of a variant. */ -export function readVariant(value: unknown): { readonly tag: string; readonly value: Record } | undefined { +export function readVariant( + value: unknown +): { readonly tag: string; readonly value: Record } | undefined { if (!isRecord(value) || typeof value['tag'] !== 'string') return undefined; return { tag: value['tag'], value: isRecord(value['value']) ? value['value'] : {} }; } diff --git a/src/utils/token-standard/v1/transfer.ts b/src/utils/token-standard/v1/transfer.ts index f51eb9ae..c8cdc93b 100644 --- a/src/utils/token-standard/v1/transfer.ts +++ b/src/utils/token-standard/v1/transfer.ts @@ -41,7 +41,7 @@ export function parseTransferResult(transaction: unknown): TokenStandardV1Transf } if (output?.tag === 'TransferInstructionResult_Pending') { - const transferInstructionCid = output.value['transferInstructionCid']; + const { transferInstructionCid } = output.value; return { updateId, status: 'pending', diff --git a/test/unit/parsers/event-parser.test.ts b/test/unit/parsers/event-parser.test.ts index 291ce7fe..d322d533 100644 --- a/test/unit/parsers/event-parser.test.ts +++ b/test/unit/parsers/event-parser.test.ts @@ -377,13 +377,22 @@ describe('event-parser', () => { describe('matchesTemplateId', () => { it('matches a package id against a package-name filter, and a bare qualified name', () => { - expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', '#WrappedAssets-v01:WrappedAssets.Holding:WrappedAsset')).toBe(true); - expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', 'WrappedAssets.Holding:WrappedAsset')).toBe(true); + expect( + matchesTemplateId( + 'abc123:WrappedAssets.Holding:WrappedAsset', + '#WrappedAssets-v01:WrappedAssets.Holding:WrappedAsset' + ) + ).toBe(true); + expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', 'WrappedAssets.Holding:WrappedAsset')).toBe( + true + ); expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', 'WrappedAsset')).toBe(true); }); it('does not match a different module or template', () => { - expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', 'WrappedAssets.Locked:WrappedAsset')).toBe(false); + expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', 'WrappedAssets.Locked:WrappedAsset')).toBe( + false + ); expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', 'FrozenWrappedAsset')).toBe(false); expect(matchesTemplateId('abc123:WrappedAssets.Holding:WrappedAsset', '')).toBe(false); }); @@ -400,10 +409,24 @@ describe('event-parser', () => { transaction: { updateId: 'update-1', events: [ - { ExercisedEvent: { contractId: 'cid-1', templateId: 'pkg:Module:Factory', choice: 'Freeze', exerciseResult: 'cid-frozen' } }, + { + ExercisedEvent: { + contractId: 'cid-1', + templateId: 'pkg:Module:Factory', + choice: 'Freeze', + exerciseResult: 'cid-frozen', + }, + }, { CreatedEvent: { contractId: 'created-1', templateId: 'pkg:Module:Holding' } }, { CreatedEvent: { contractId: 'created-2', templateId: 'pkg:Module:Other' } }, - { ExercisedEvent: { contractId: 'cid-2', templateId: 'pkg:Module:Factory', choice: 'Unfreeze', exerciseResult: null } }, + { + ExercisedEvent: { + contractId: 'cid-2', + templateId: 'pkg:Module:Factory', + choice: 'Unfreeze', + exerciseResult: null, + }, + }, ], }, }; From 2993621b708fbe50a72656cea6ee1a379d691a90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 18:56:07 +0000 Subject: [PATCH 3/6] Fail closed in the CIP-56 V1 result parsers parseTransferResult and parseAllocationResult reported any output they did not recognize as failed, which claims the units went back to the sender when nothing knows what happened to them. Both now require one of the standard's three output variants, and report a missing or unknown tag. parseMintedHoldings read the transaction's create events whenever it found no burn-mint result, so a transfer's sender change could be reported as minted supply. The create-event fallback is now opt-in: a caller names the registry choices that mint outside the burn-mint factory (an admin recovery's BurnHoldingEnforcement, say) and/or the concrete holding template, matching the stricter wrapped-assets-sdk reader. A burn-mint whose result is not a record is reported rather than silently falling back. Co-authored-by: hardlydiff --- src/utils/token-standard/v1/allocation.ts | 22 ++- src/utils/token-standard/v1/burn-mint.ts | 58 ++++++-- src/utils/token-standard/v1/constants.ts | 22 +++ src/utils/token-standard/v1/result.ts | 31 ++++ src/utils/token-standard/v1/transfer.ts | 22 ++- test/unit/token-standard/v1/results.test.ts | 152 +++++++++++++++++++- 6 files changed, 279 insertions(+), 28 deletions(-) diff --git a/src/utils/token-standard/v1/allocation.ts b/src/utils/token-standard/v1/allocation.ts index 31eb9b99..46e0776b 100644 --- a/src/utils/token-standard/v1/allocation.ts +++ b/src/utils/token-standard/v1/allocation.ts @@ -7,11 +7,16 @@ */ import { requireTransactionUpdateId } from '../../parsers/event-parser'; -import { TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES, TokenStandardV1Choice } from './constants'; +import { + TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES, + TOKEN_STANDARD_V1_ALLOCATION_RESULT_TAGS, + TokenStandardV1AllocationResultTag, + TokenStandardV1Choice, +} from './constants'; import { readContractIds, - readVariant, requireContractIds, + requireKnownVariant, requireResultRecord, requireResultRecordOfAny, } from './result'; @@ -26,14 +31,19 @@ export interface TokenStandardV1AllocationResult { readonly allocationCid: string | undefined; } -/** The `AllocationInstructionResult` of `AllocationFactory_Allocate`. */ +/** + * The `AllocationInstructionResult` of `AllocationFactory_Allocate`. + * + * As with a transfer, `failed` is reported only for the standard's `_Failed` output; an undefined output tag, or a + * result naming no output, is reported rather than read as a rejected allocation. + */ export function parseAllocationResult(transaction: unknown): TokenStandardV1AllocationResult { const result = requireResultRecord(transaction, TokenStandardV1Choice.allocate); const updateId = requireTransactionUpdateId(transaction); const senderChangeCids = requireContractIds(result['senderChangeCids'], 'senderChangeCids'); - const output = readVariant(result['output']); + const output = requireKnownVariant(result, 'output', TOKEN_STANDARD_V1_ALLOCATION_RESULT_TAGS, 'allocation result'); - if (output?.tag === 'AllocationInstructionResult_Completed') { + if (output.tag === TokenStandardV1AllocationResultTag.completed) { const { allocationCid } = output.value; return { updateId, @@ -45,7 +55,7 @@ export function parseAllocationResult(transaction: unknown): TokenStandardV1Allo return { updateId, - status: output?.tag === 'AllocationInstructionResult_Pending' ? 'pending' : 'failed', + status: output.tag === TokenStandardV1AllocationResultTag.pending ? 'pending' : 'failed', senderChangeCids, allocationCid: undefined, }; diff --git a/src/utils/token-standard/v1/burn-mint.ts b/src/utils/token-standard/v1/burn-mint.ts index c20f992d..b0961445 100644 --- a/src/utils/token-standard/v1/burn-mint.ts +++ b/src/utils/token-standard/v1/burn-mint.ts @@ -3,12 +3,18 @@ * * `BurnMintFactory_BurnMint` returns a record rather than contract ids, and the interesting half of a mint is in that * record: `outputCids` names the holdings it created. The create events alone cannot say it — a mint of two holdings - * creates two contracts with nothing to distinguish which output each was — so the exercise result is read instead. + * creates two contracts with nothing to distinguish which output each was — so the exercise result is read instead, and + * a registry that mints outside the factory is read from the creates only when the caller asks for it. */ -import { isRecord } from '../../../core/utils'; -import { findExercisedEvent, findCreatedContractIds, requireTransactionUpdateId } from '../../parsers/event-parser'; +import { + findExercisedEvent, + findCreatedContractIds, + getTransactionUpdateId, + requireTransactionUpdateId, +} from '../../parsers/event-parser'; import { TokenStandardV1Choice } from './constants'; +import { TokenStandardV1ResultError, TokenStandardV1ResultErrorCode } from './errors'; import { requireContractIds, requireResultRecord } from './result'; export interface TokenStandardV1BurnMintResult { @@ -31,22 +37,48 @@ export function parseBurnMintResult(transaction: unknown): TokenStandardV1BurnMi export interface ParseMintedHoldingsOptions { /** - * Template of the concrete holding contract, used only for the fallback path. Matched package-agnostically, so - * `Module:Template`, `Template`, or a fully qualified id all work. When omitted the fallback returns every contract - * the transaction created. + * Registry-specific choices that mint without going through the burn-mint factory, such as an admin recovery's + * `BurnHoldingEnforcement`, which returns contract ids of the concrete template rather than a burn-mint result. The + * fallback reads create events only when the transaction exercised one of these — the stricter of the two opt-ins, + * because it names the choice that did the minting rather than trusting the transaction's creates. + */ + readonly mintingChoices?: readonly string[]; + /** + * Template of the concrete holding contract, narrowing the fallback's create events to it. Matched + * package-agnostically, so `Module:Template`, `Template`, or a fully qualified id all work. */ readonly holdingTemplate?: string; } /** - * The holdings a mint created. Falls back to the transaction's create events when it contains no burn-mint exercise — - * an admin recovery may mint through a registry-specific enforcement choice, which returns contract ids of the concrete - * template rather than a burn-mint result. + * The holdings a mint created, from `BurnMintFactory_BurnMint`'s `outputCids`. + * + * Create events are never read on their own: every contract a transaction happened to create is not the same thing as + * the outputs of a mint, and answering with the first would name a sender's change as minted supply. A caller whose + * registry mints outside the factory opts into the fallback by naming `mintingChoices`, `holdingTemplate`, or both; + * without either, a transaction carrying no burn-mint result is reported. */ export function parseMintedHoldings(transaction: unknown, options: ParseMintedHoldingsOptions = {}): string[] { - const burnMint = findExercisedEvent(transaction, TokenStandardV1Choice.burnMint); - if (burnMint && isRecord(burnMint.exerciseResult)) { - return requireContractIds(burnMint.exerciseResult['outputCids'], 'outputCids'); + if (findExercisedEvent(transaction, TokenStandardV1Choice.burnMint)) { + const result = requireResultRecord(transaction, TokenStandardV1Choice.burnMint); + return requireContractIds(result['outputCids'], 'outputCids'); } - return findCreatedContractIds(transaction, options.holdingTemplate); + + const { mintingChoices, holdingTemplate } = options; + if (mintingChoices === undefined && holdingTemplate === undefined) { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_NOT_FOUND, + `The transaction contains no ${TokenStandardV1Choice.burnMint} exercise, so it minted nothing. Name mintingChoices or holdingTemplate to read a registry-specific mint from the create events instead.`, + { choice: TokenStandardV1Choice.burnMint, updateId: getTransactionUpdateId(transaction) } + ); + } + if (mintingChoices !== undefined && !findExercisedEvent(transaction, mintingChoices)) { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_NOT_FOUND, + `The transaction contains neither a ${TokenStandardV1Choice.burnMint} exercise nor any of these minting choices: ${mintingChoices.join(', ')}.`, + { choices: [TokenStandardV1Choice.burnMint, ...mintingChoices], updateId: getTransactionUpdateId(transaction) } + ); + } + + return findCreatedContractIds(transaction, holdingTemplate); } diff --git a/src/utils/token-standard/v1/constants.ts b/src/utils/token-standard/v1/constants.ts index c44504b2..45b32b6d 100644 --- a/src/utils/token-standard/v1/constants.ts +++ b/src/utils/token-standard/v1/constants.ts @@ -56,6 +56,28 @@ export const TOKEN_STANDARD_V1_TRANSFER_RESULT_CHOICES: readonly string[] = [ TokenStandardV1Choice.transferInstructionUpdate, ]; +/** The three `TransferInstructionResult.output` variants the standard defines. */ +export const TokenStandardV1TransferResultTag = { + completed: 'TransferInstructionResult_Completed', + pending: 'TransferInstructionResult_Pending', + failed: 'TransferInstructionResult_Failed', +} as const; + +export const TOKEN_STANDARD_V1_TRANSFER_RESULT_TAGS: readonly string[] = Object.values( + TokenStandardV1TransferResultTag +); + +/** The three `AllocationInstructionResult.output` variants the standard defines. */ +export const TokenStandardV1AllocationResultTag = { + completed: 'AllocationInstructionResult_Completed', + pending: 'AllocationInstructionResult_Pending', + failed: 'AllocationInstructionResult_Failed', +} as const; + +export const TOKEN_STANDARD_V1_ALLOCATION_RESULT_TAGS: readonly string[] = Object.values( + TokenStandardV1AllocationResultTag +); + /** The three exits an allocation has, all returning holdings; the difference is who gets them. */ export const TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES: readonly string[] = [ TokenStandardV1Choice.allocationExecuteTransfer, diff --git a/src/utils/token-standard/v1/result.ts b/src/utils/token-standard/v1/result.ts index 675f44c2..64edf60c 100644 --- a/src/utils/token-standard/v1/result.ts +++ b/src/utils/token-standard/v1/result.ts @@ -59,3 +59,34 @@ export function readVariant( if (!isRecord(value) || typeof value['tag'] !== 'string') return undefined; return { tag: value['tag'], value: isRecord(value['value']) ? value['value'] : {} }; } + +/** + * A variant field of a result record, which must be one of the variants the reader knows. + * + * A missing field or an unrecognized tag is reported rather than folded into the nearest status. The standard's + * `_Failed` output means the units went back to the sender, so answering an unknown tag with it would tell a caller a + * transfer was returned when nothing here knows what happened to it. + */ +export function requireKnownVariant( + result: Record, + field: string, + known: readonly string[], + what: string +): { readonly tag: string; readonly value: Record } { + const variant = readVariant(result[field]); + if (!variant) { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_INVALID, + `The ${what} names no ${field}.`, + { field } + ); + } + if (!known.includes(variant.tag)) { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_INVALID, + `Unknown ${what} ${field}: ${variant.tag}.`, + { field, tag: variant.tag, known: [...known] } + ); + } + return variant; +} diff --git a/src/utils/token-standard/v1/transfer.ts b/src/utils/token-standard/v1/transfer.ts index c8cdc93b..b349bc56 100644 --- a/src/utils/token-standard/v1/transfer.ts +++ b/src/utils/token-standard/v1/transfer.ts @@ -6,8 +6,12 @@ */ import { requireTransactionUpdateId } from '../../parsers/event-parser'; -import { TOKEN_STANDARD_V1_TRANSFER_RESULT_CHOICES } from './constants'; -import { readVariant, requireContractIds, requireResultRecordOfAny } from './result'; +import { + TOKEN_STANDARD_V1_TRANSFER_RESULT_CHOICES, + TOKEN_STANDARD_V1_TRANSFER_RESULT_TAGS, + TokenStandardV1TransferResultTag, +} from './constants'; +import { requireContractIds, requireKnownVariant, requireResultRecordOfAny } from './result'; /** What became of the units: delivered, waiting on the receiver, or returned to the sender. */ export type TokenStandardV1TransferStatus = 'completed' | 'pending' | 'failed'; @@ -23,14 +27,20 @@ export interface TokenStandardV1TransferResult { readonly transferInstructionCid: string | undefined; } -/** The `TransferInstructionResult` of whichever of the five transfer choices this transaction exercised. */ +/** + * The `TransferInstructionResult` of whichever of the five transfer choices this transaction exercised. + * + * `failed` is reported only for the standard's `_Failed` output, which says the units went back to the sender. An + * output the standard does not define, or a result naming none at all, is a failure to read the transaction rather than + * a transfer that failed. + */ export function parseTransferResult(transaction: unknown): TokenStandardV1TransferResult { const result = requireResultRecordOfAny(transaction, TOKEN_STANDARD_V1_TRANSFER_RESULT_CHOICES); const updateId = requireTransactionUpdateId(transaction); const senderChangeCids = requireContractIds(result['senderChangeCids'], 'senderChangeCids'); - const output = readVariant(result['output']); + const output = requireKnownVariant(result, 'output', TOKEN_STANDARD_V1_TRANSFER_RESULT_TAGS, 'transfer result'); - if (output?.tag === 'TransferInstructionResult_Completed') { + if (output.tag === TokenStandardV1TransferResultTag.completed) { return { updateId, status: 'completed', @@ -40,7 +50,7 @@ export function parseTransferResult(transaction: unknown): TokenStandardV1Transf }; } - if (output?.tag === 'TransferInstructionResult_Pending') { + if (output.tag === TokenStandardV1TransferResultTag.pending) { const { transferInstructionCid } = output.value; return { updateId, diff --git a/test/unit/token-standard/v1/results.test.ts b/test/unit/token-standard/v1/results.test.ts index 108ec1de..1e8fa552 100644 --- a/test/unit/token-standard/v1/results.test.ts +++ b/test/unit/token-standard/v1/results.test.ts @@ -45,17 +45,71 @@ describe('token standard v1 burn-mint results', () => { ]); expect(parseBurnMintResult(response).outputHoldingCids).toEqual([]); + expect(parseMintedHoldings(response)).toEqual([]); }); - it('falls back to the created holdings when the mint went through an enforcement action', () => { + it('reads the created holdings when the caller names the enforcement choice that minted them', () => { const response = transactionTree([ { exercised: exercised({ choice: 'BurnHoldingEnforcement', exerciseResult: {} }) }, { created: created(BURN_OFFER_TEMPLATE, 'cid-offer') }, { created: created(HOLDING_TEMPLATE, 'cid-reissued') }, ]); - expect(parseMintedHoldings(response, { holdingTemplate: HOLDING_TEMPLATE })).toEqual(['cid-reissued']); - expect(parseMintedHoldings(response)).toEqual(['cid-offer', 'cid-reissued']); + expect( + parseMintedHoldings(response, { + mintingChoices: ['BurnHoldingEnforcement'], + holdingTemplate: HOLDING_TEMPLATE, + }) + ).toEqual(['cid-reissued']); + }); + + it('refuses to call every create a mint when the transaction carries no burn-mint result', () => { + const response = transactionTree([ + { exercised: exercised({ choice: 'BurnHoldingEnforcement', exerciseResult: {} }) }, + { created: created(BURN_OFFER_TEMPLATE, 'cid-offer') }, + { created: created(HOLDING_TEMPLATE, 'cid-reissued') }, + ]); + + expect(() => parseMintedHoldings(response)).toThrow(/minted nothing/); + }); + + it('refuses the fallback when none of the named minting choices was exercised', () => { + const response = transactionTree([ + { exercised: exercised({ choice: TokenStandardV1Choice.transfer, exerciseResult: {} }) }, + { created: created(HOLDING_TEMPLATE, 'cid-change') }, + ]); + + expect(() => + parseMintedHoldings(response, { + mintingChoices: ['BurnHoldingEnforcement'], + holdingTemplate: HOLDING_TEMPLATE, + }) + ).toThrow(/nor any of these minting choices/); + }); + + it('reports a pure confiscation as no minted holdings rather than as a failure', () => { + const response = transactionTree([ + { exercised: exercised({ choice: 'BurnHoldingEnforcement', exerciseResult: {} }) }, + { created: created(BURN_OFFER_TEMPLATE, 'cid-offer') }, + ]); + + expect( + parseMintedHoldings(response, { + mintingChoices: ['BurnHoldingEnforcement'], + holdingTemplate: HOLDING_TEMPLATE, + }) + ).toEqual([]); + }); + + it('reads a burn-mint result that is not a record as a failure, not as a reason to read the creates', () => { + const response = transactionTree([ + { exercised: exercised({ choice: TokenStandardV1Choice.burnMint, exerciseResult: 'nonsense' }) }, + { created: created(HOLDING_TEMPLATE, 'cid-created') }, + ]); + + expect(() => parseMintedHoldings(response, { holdingTemplate: HOLDING_TEMPLATE })).toThrow( + /returned no result record/ + ); }); it('says so when the transaction contains no burn-mint at all', () => { @@ -162,6 +216,46 @@ describe('token standard v1 transfer results', () => { expect(() => parseTransferResult(response)).toThrow(/contains none of these exercises/); }); + + it('reports an output tag the standard does not define rather than calling the transfer failed', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.transfer, + exerciseResult: { + senderChangeCids: ['cid-change'], + output: { tag: 'TransferInstructionResult_Quantum', value: {} }, + }, + }), + }, + ]); + + let thrown: unknown; + try { + parseTransferResult(response); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + name: 'TokenStandardV1ResultError', + code: TokenStandardV1ResultErrorCode.RESULT_INVALID, + context: { tag: 'TransferInstructionResult_Quantum' }, + }); + }); + + it('reports a transfer result that names no output at all', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.transferInstructionAccept, + exerciseResult: { senderChangeCids: [], meta: { values: {} } }, + }), + }, + ]); + + expect(() => parseTransferResult(response)).toThrow(/transfer result names no output/); + }); }); describe('token standard v1 allocation results', () => { @@ -211,6 +305,58 @@ describe('token standard v1 allocation results', () => { }); }); + it('reads a rejected allocation as failed, from the standard failed variant', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocate, + exerciseResult: { + senderChangeCids: ['cid-returned'], + output: { tag: 'AllocationInstructionResult_Failed', value: {} }, + }, + }), + }, + ]); + + expect(parseAllocationResult(response)).toEqual({ + updateId: UPDATE_ID, + status: 'failed', + senderChangeCids: ['cid-returned'], + allocationCid: undefined, + }); + }); + + it('reports an allocation output tag it does not know rather than calling the allocation failed', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocate, + exerciseResult: { + senderChangeCids: [], + output: { tag: 'AllocationInstructionResult_Deferred', value: {} }, + }, + }), + }, + ]); + + expect(() => parseAllocationResult(response)).toThrow( + /Unknown allocation result output: AllocationInstructionResult_Deferred/ + ); + }); + + it('reports an allocation result that names no output at all', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocate, + exerciseResult: { senderChangeCids: [], meta: { values: {} } }, + }), + }, + ]); + + expect(() => parseAllocationResult(response)).toThrow(/allocation result names no output/); + }); + it('reads delivery and unwinding through the same result, which differ only in who gets the holdings', () => { const executed = transactionTree([ { From 2d8fa60f3da2789643dfbb30cc4bfa1291d0c602 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 23:47:26 +0000 Subject: [PATCH 4/6] Fail closed on malformed CIP-56 V1 result fields Reject missing/invalid instruction and allocation CIDs, mixed contract-id arrays, and incomplete allocation-exit holdings instead of returning a partial success. Missing singular exercises now throw TokenStandardV1ResultError RESULT_NOT_FOUND, and the mint create-event fallback requires both mintingChoices and holdingTemplate. Co-authored-by: hardlydiff --- src/utils/token-standard/v1/allocation.ts | 35 ++++- src/utils/token-standard/v1/burn-mint.ts | 24 +-- src/utils/token-standard/v1/result.ts | 37 +++-- src/utils/token-standard/v1/transfer.ts | 5 +- test/unit/token-standard/v1/results.test.ts | 164 +++++++++++++++++++- 5 files changed, 233 insertions(+), 32 deletions(-) diff --git a/src/utils/token-standard/v1/allocation.ts b/src/utils/token-standard/v1/allocation.ts index 46e0776b..d5c2b83c 100644 --- a/src/utils/token-standard/v1/allocation.ts +++ b/src/utils/token-standard/v1/allocation.ts @@ -6,7 +6,7 @@ * between them is who gets them. */ -import { requireTransactionUpdateId } from '../../parsers/event-parser'; +import { findExercisedEvent, requireTransactionUpdateId } from '../../parsers/event-parser'; import { TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES, TOKEN_STANDARD_V1_ALLOCATION_RESULT_TAGS, @@ -17,6 +17,7 @@ import { readContractIds, requireContractIds, requireKnownVariant, + requireNonEmptyString, requireResultRecord, requireResultRecordOfAny, } from './result'; @@ -29,6 +30,8 @@ export interface TokenStandardV1AllocationResult { readonly senderChangeCids: string[]; /** The allocation the units are now reserved under. */ readonly allocationCid: string | undefined; + /** The pending instruction, when the allocation did not complete in this transaction. */ + readonly allocationInstructionCid: string | undefined; } /** @@ -44,20 +47,34 @@ export function parseAllocationResult(transaction: unknown): TokenStandardV1Allo const output = requireKnownVariant(result, 'output', TOKEN_STANDARD_V1_ALLOCATION_RESULT_TAGS, 'allocation result'); if (output.tag === TokenStandardV1AllocationResultTag.completed) { - const { allocationCid } = output.value; return { updateId, status: 'completed', senderChangeCids, - allocationCid: typeof allocationCid === 'string' ? allocationCid : undefined, + allocationCid: requireNonEmptyString(output.value['allocationCid'], 'allocationCid'), + allocationInstructionCid: undefined, + }; + } + + if (output.tag === TokenStandardV1AllocationResultTag.pending) { + return { + updateId, + status: 'pending', + senderChangeCids, + allocationCid: undefined, + allocationInstructionCid: requireNonEmptyString( + output.value['allocationInstructionCid'], + 'allocationInstructionCid' + ), }; } return { updateId, - status: output.tag === TokenStandardV1AllocationResultTag.pending ? 'pending' : 'failed', + status: 'failed', senderChangeCids, allocationCid: undefined, + allocationInstructionCid: undefined, }; } @@ -71,9 +88,15 @@ export interface TokenStandardV1AllocationTransferResult { /** The result of the allocation exit in this transaction — `Allocation_ExecuteTransfer`, `_Cancel` or `_Withdraw`. */ export function parseAllocationTransferResult(transaction: unknown): TokenStandardV1AllocationTransferResult { const result = requireResultRecordOfAny(transaction, TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES); + const exercised = findExercisedEvent(transaction, TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES); + const executedTransfer = exercised?.choice === TokenStandardV1Choice.allocationExecuteTransfer; return { updateId: requireTransactionUpdateId(transaction), - senderHoldingCids: readContractIds(result['senderHoldingCids']), - receiverHoldingCids: readContractIds(result['receiverHoldingCids']), + senderHoldingCids: executedTransfer + ? readContractIds(result['senderHoldingCids']) + : requireContractIds(result['senderHoldingCids'], 'senderHoldingCids'), + receiverHoldingCids: executedTransfer + ? requireContractIds(result['receiverHoldingCids'], 'receiverHoldingCids') + : readContractIds(result['receiverHoldingCids']), }; } diff --git a/src/utils/token-standard/v1/burn-mint.ts b/src/utils/token-standard/v1/burn-mint.ts index b0961445..d3f69e3a 100644 --- a/src/utils/token-standard/v1/burn-mint.ts +++ b/src/utils/token-standard/v1/burn-mint.ts @@ -38,14 +38,15 @@ export function parseBurnMintResult(transaction: unknown): TokenStandardV1BurnMi export interface ParseMintedHoldingsOptions { /** * Registry-specific choices that mint without going through the burn-mint factory, such as an admin recovery's - * `BurnHoldingEnforcement`, which returns contract ids of the concrete template rather than a burn-mint result. The - * fallback reads create events only when the transaction exercised one of these — the stricter of the two opt-ins, - * because it names the choice that did the minting rather than trusting the transaction's creates. + * `BurnHoldingEnforcement`, which returns contract ids of the concrete template rather than a burn-mint result. + * Required together with `holdingTemplate` to enable the create-event fallback, which then runs only when the + * transaction exercised one of these. */ readonly mintingChoices?: readonly string[]; /** * Template of the concrete holding contract, narrowing the fallback's create events to it. Matched - * package-agnostically, so `Module:Template`, `Template`, or a fully qualified id all work. + * package-agnostically, so `Module:Template`, `Template`, or a fully qualified id all work. Required together with + * `mintingChoices` to enable the create-event fallback. */ readonly holdingTemplate?: string; } @@ -55,8 +56,8 @@ export interface ParseMintedHoldingsOptions { * * Create events are never read on their own: every contract a transaction happened to create is not the same thing as * the outputs of a mint, and answering with the first would name a sender's change as minted supply. A caller whose - * registry mints outside the factory opts into the fallback by naming `mintingChoices`, `holdingTemplate`, or both; - * without either, a transaction carrying no burn-mint result is reported. + * registry mints outside the factory opts into the fallback by naming both `mintingChoices` and `holdingTemplate`; + * without both, a transaction carrying no burn-mint result is reported. */ export function parseMintedHoldings(transaction: unknown, options: ParseMintedHoldingsOptions = {}): string[] { if (findExercisedEvent(transaction, TokenStandardV1Choice.burnMint)) { @@ -65,14 +66,19 @@ export function parseMintedHoldings(transaction: unknown, options: ParseMintedHo } const { mintingChoices, holdingTemplate } = options; - if (mintingChoices === undefined && holdingTemplate === undefined) { + const canFallback = + Array.isArray(mintingChoices) && + mintingChoices.length > 0 && + typeof holdingTemplate === 'string' && + holdingTemplate.length > 0; + if (!canFallback) { throw new TokenStandardV1ResultError( TokenStandardV1ResultErrorCode.RESULT_NOT_FOUND, - `The transaction contains no ${TokenStandardV1Choice.burnMint} exercise, so it minted nothing. Name mintingChoices or holdingTemplate to read a registry-specific mint from the create events instead.`, + `The transaction contains no ${TokenStandardV1Choice.burnMint} exercise, so it minted nothing. Name mintingChoices and holdingTemplate to read a registry-specific mint from the create events instead.`, { choice: TokenStandardV1Choice.burnMint, updateId: getTransactionUpdateId(transaction) } ); } - if (mintingChoices !== undefined && !findExercisedEvent(transaction, mintingChoices)) { + if (!findExercisedEvent(transaction, mintingChoices)) { throw new TokenStandardV1ResultError( TokenStandardV1ResultErrorCode.RESULT_NOT_FOUND, `The transaction contains neither a ${TokenStandardV1Choice.burnMint} exercise nor any of these minting choices: ${mintingChoices.join(', ')}.`, diff --git a/src/utils/token-standard/v1/result.ts b/src/utils/token-standard/v1/result.ts index 64edf60c..d8e72890 100644 --- a/src/utils/token-standard/v1/result.ts +++ b/src/utils/token-standard/v1/result.ts @@ -1,18 +1,10 @@ import { isRecord } from '../../../core/utils'; -import { findExercisedEvent, getTransactionUpdateId, requireExerciseResult } from '../../parsers/event-parser'; +import { findExercisedEvent, getTransactionUpdateId } from '../../parsers/event-parser'; import { TokenStandardV1ResultError, TokenStandardV1ResultErrorCode } from './errors'; /** The record a standard choice returned, or a failure: a caller asking for it asserts the transaction contains it. */ export function requireResultRecord(transaction: unknown, choice: string): Record { - const result = requireExerciseResult(transaction, choice); - if (!isRecord(result)) { - throw new TokenStandardV1ResultError( - TokenStandardV1ResultErrorCode.RESULT_INVALID, - `The ${choice} exercise returned no result record.`, - { choice, updateId: getTransactionUpdateId(transaction) } - ); - } - return result; + return requireResultRecordOfAny(transaction, [choice]); } /** The record returned by the first of `choices` this transaction exercised. */ @@ -35,6 +27,18 @@ export function requireResultRecordOfAny(transaction: unknown, choices: readonly return exercised.exerciseResult; } +/** A required non-empty string from a result record. */ +export function requireNonEmptyString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_INVALID, + `The exercise result has no ${field}.`, + { field } + ); + } + return value; +} + /** A required list of contract ids from a result record. */ export function requireContractIds(value: unknown, field: string): string[] { if (!Array.isArray(value)) { @@ -44,7 +48,18 @@ export function requireContractIds(value: unknown, field: string): string[] { { field } ); } - return value.filter((entry): entry is string => typeof entry === 'string'); + const contractIds: string[] = []; + for (const entry of value) { + if (typeof entry !== 'string') { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_INVALID, + `The ${field} is not a list of contract ids.`, + { field } + ); + } + contractIds.push(entry); + } + return contractIds; } /** An optional list of contract ids, which the token standard omits rather than sending empty on some paths. */ diff --git a/src/utils/token-standard/v1/transfer.ts b/src/utils/token-standard/v1/transfer.ts index b349bc56..db151a16 100644 --- a/src/utils/token-standard/v1/transfer.ts +++ b/src/utils/token-standard/v1/transfer.ts @@ -11,7 +11,7 @@ import { TOKEN_STANDARD_V1_TRANSFER_RESULT_TAGS, TokenStandardV1TransferResultTag, } from './constants'; -import { requireContractIds, requireKnownVariant, requireResultRecordOfAny } from './result'; +import { requireContractIds, requireKnownVariant, requireNonEmptyString, requireResultRecordOfAny } from './result'; /** What became of the units: delivered, waiting on the receiver, or returned to the sender. */ export type TokenStandardV1TransferStatus = 'completed' | 'pending' | 'failed'; @@ -51,13 +51,12 @@ export function parseTransferResult(transaction: unknown): TokenStandardV1Transf } if (output.tag === TokenStandardV1TransferResultTag.pending) { - const { transferInstructionCid } = output.value; return { updateId, status: 'pending', senderChangeCids, receiverHoldingCids: [], - transferInstructionCid: typeof transferInstructionCid === 'string' ? transferInstructionCid : undefined, + transferInstructionCid: requireNonEmptyString(output.value['transferInstructionCid'], 'transferInstructionCid'), }; } diff --git a/test/unit/token-standard/v1/results.test.ts b/test/unit/token-standard/v1/results.test.ts index 1e8fa552..5b44657b 100644 --- a/test/unit/token-standard/v1/results.test.ts +++ b/test/unit/token-standard/v1/results.test.ts @@ -73,6 +73,28 @@ describe('token standard v1 burn-mint results', () => { expect(() => parseMintedHoldings(response)).toThrow(/minted nothing/); }); + it('refuses the create-event fallback unless both mintingChoices and holdingTemplate are named', () => { + const response = transactionTree([ + { exercised: exercised({ choice: 'BurnHoldingEnforcement', exerciseResult: {} }) }, + { created: created(BURN_OFFER_TEMPLATE, 'cid-offer') }, + { created: created(HOLDING_TEMPLATE, 'cid-reissued') }, + ]); + + expect(() => parseMintedHoldings(response, { holdingTemplate: HOLDING_TEMPLATE })).toThrow(/minted nothing/); + expect(() => parseMintedHoldings(response, { mintingChoices: ['BurnHoldingEnforcement'] })).toThrow( + /minted nothing/ + ); + expect(() => parseMintedHoldings(response, { mintingChoices: [], holdingTemplate: HOLDING_TEMPLATE })).toThrow( + /minted nothing/ + ); + expect( + parseMintedHoldings(response, { + mintingChoices: ['BurnHoldingEnforcement'], + holdingTemplate: HOLDING_TEMPLATE, + }) + ).toEqual(['cid-reissued']); + }); + it('refuses the fallback when none of the named minting choices was exercised', () => { const response = transactionTree([ { exercised: exercised({ choice: TokenStandardV1Choice.transfer, exerciseResult: {} }) }, @@ -113,9 +135,31 @@ describe('token standard v1 burn-mint results', () => { }); it('says so when the transaction contains no burn-mint at all', () => { - expect(() => parseBurnMintResult(transactionTree([{ created: created(HOLDING_TEMPLATE, 'cid-1') }]))).toThrow( - /no BurnMintFactory_BurnMint exercise/ - ); + let thrown: unknown; + try { + parseBurnMintResult(transactionTree([{ created: created(HOLDING_TEMPLATE, 'cid-1') }])); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + name: 'TokenStandardV1ResultError', + code: TokenStandardV1ResultErrorCode.RESULT_NOT_FOUND, + context: { updateId: UPDATE_ID }, + }); + }); + + it('reports mixed outputCids as invalid rather than dropping the non-strings', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.burnMint, + exerciseResult: { outputCids: ['cid-1', 42] }, + }), + }, + ]); + + expect(() => parseBurnMintResult(response)).toThrow(/not a list of contract ids/); }); it('reports a burn-mint result that names no outputCids', () => { @@ -174,6 +218,25 @@ describe('token standard v1 transfer results', () => { }); }); + it.each([undefined, 42, ''] as const)( + 'refuses a pending transfer whose instruction cid is %j', + (transferInstructionCid) => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.transfer, + exerciseResult: { + senderChangeCids: ['cid-change'], + output: { tag: 'TransferInstructionResult_Pending', value: { transferInstructionCid } }, + }, + }), + }, + ]); + + expect(() => parseTransferResult(response)).toThrow(/no transferInstructionCid/); + } + ); + it('reads a return path as failed, with the returned units as sender change', () => { const response = flatTransaction([ { @@ -278,9 +341,29 @@ describe('token standard v1 allocation results', () => { status: 'completed', senderChangeCids: ['cid-change'], allocationCid: 'cid-allocation', + allocationInstructionCid: undefined, }); }); + it.each([undefined, 42, ''] as const)( + 'refuses a completed allocation whose allocation cid is %j', + (allocationCid) => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocate, + exerciseResult: { + senderChangeCids: ['cid-change'], + output: { tag: 'AllocationInstructionResult_Completed', value: { allocationCid } }, + }, + }), + }, + ]); + + expect(() => parseAllocationResult(response)).toThrow(/no allocationCid/); + } + ); + it('reads a pending allocation instruction without an allocation', () => { const response = transactionTree([ { @@ -302,9 +385,29 @@ describe('token standard v1 allocation results', () => { status: 'pending', senderChangeCids: [], allocationCid: undefined, + allocationInstructionCid: 'cid-instruction', }); }); + it.each([undefined, 42, ''] as const)( + 'refuses a pending allocation whose instruction cid is %j', + (allocationInstructionCid) => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocate, + exerciseResult: { + senderChangeCids: [], + output: { tag: 'AllocationInstructionResult_Pending', value: { allocationInstructionCid } }, + }, + }), + }, + ]); + + expect(() => parseAllocationResult(response)).toThrow(/no allocationInstructionCid/); + } + ); + it('reads a rejected allocation as failed, from the standard failed variant', () => { const response = transactionTree([ { @@ -323,6 +426,22 @@ describe('token standard v1 allocation results', () => { status: 'failed', senderChangeCids: ['cid-returned'], allocationCid: undefined, + allocationInstructionCid: undefined, + }); + }); + + it('reports a missing allocate exercise as a token standard result error, not a parse error', () => { + let thrown: unknown; + try { + parseAllocationResult(transactionTree([{ created: created(HOLDING_TEMPLATE, 'cid-1') }])); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + name: 'TokenStandardV1ResultError', + code: TokenStandardV1ResultErrorCode.RESULT_NOT_FOUND, + context: { updateId: UPDATE_ID }, }); }); @@ -387,6 +506,45 @@ describe('token standard v1 allocation results', () => { }); }); + it('reports execute without receiver holdings as invalid, not as an empty delivery', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocationExecuteTransfer, + exerciseResult: { senderHoldingCids: [], meta: { values: {} } }, + }), + }, + ]); + + expect(() => parseAllocationTransferResult(response)).toThrow(/no receiverHoldingCids/); + }); + + it('reports cancel without sender holdings as invalid, not as an empty unwind', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocationCancel, + exerciseResult: { receiverHoldingCids: [], meta: { values: {} } }, + }), + }, + ]); + + expect(() => parseAllocationTransferResult(response)).toThrow(/no senderHoldingCids/); + }); + + it('reports withdraw without sender holdings as invalid, not as an empty unwind', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocationWithdraw, + exerciseResult: { meta: { values: {} } }, + }), + }, + ]); + + expect(() => parseAllocationTransferResult(response)).toThrow(/no senderHoldingCids/); + }); + it('reports a result that is not a record rather than reading fields off it', () => { const response = transactionTree([ { exercised: exercised({ choice: TokenStandardV1Choice.allocationWithdraw, exerciseResult: 'nonsense' }) }, From 88c0ea07a6fb541d218e094d1b4b3d2fc2906d8b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 23:54:21 +0000 Subject: [PATCH 5/6] Reject mixed optional CIP-56 V1 holding cid lists readContractIds still dropped non-strings on the optional allocation-exit side, so execute/cancel could return a partial success. Validate present arrays the same way as required cid lists. Co-authored-by: hardlydiff --- src/utils/token-standard/v1/allocation.ts | 4 ++-- src/utils/token-standard/v1/result.ts | 7 ++++-- test/unit/token-standard/v1/results.test.ts | 26 +++++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/utils/token-standard/v1/allocation.ts b/src/utils/token-standard/v1/allocation.ts index d5c2b83c..6f9c09b8 100644 --- a/src/utils/token-standard/v1/allocation.ts +++ b/src/utils/token-standard/v1/allocation.ts @@ -93,10 +93,10 @@ export function parseAllocationTransferResult(transaction: unknown): TokenStanda return { updateId: requireTransactionUpdateId(transaction), senderHoldingCids: executedTransfer - ? readContractIds(result['senderHoldingCids']) + ? readContractIds(result['senderHoldingCids'], 'senderHoldingCids') : requireContractIds(result['senderHoldingCids'], 'senderHoldingCids'), receiverHoldingCids: executedTransfer ? requireContractIds(result['receiverHoldingCids'], 'receiverHoldingCids') - : readContractIds(result['receiverHoldingCids']), + : readContractIds(result['receiverHoldingCids'], 'receiverHoldingCids'), }; } diff --git a/src/utils/token-standard/v1/result.ts b/src/utils/token-standard/v1/result.ts index d8e72890..66c96e64 100644 --- a/src/utils/token-standard/v1/result.ts +++ b/src/utils/token-standard/v1/result.ts @@ -63,8 +63,11 @@ export function requireContractIds(value: unknown, field: string): string[] { } /** An optional list of contract ids, which the token standard omits rather than sending empty on some paths. */ -export function readContractIds(value: unknown): string[] { - return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []; +export function readContractIds(value: unknown, field: string): string[] { + if (!Array.isArray(value)) { + return []; + } + return requireContractIds(value, field); } /** `{ tag, value }`, the Daml JSON encoding of a variant. */ diff --git a/test/unit/token-standard/v1/results.test.ts b/test/unit/token-standard/v1/results.test.ts index 5b44657b..eb1aed4b 100644 --- a/test/unit/token-standard/v1/results.test.ts +++ b/test/unit/token-standard/v1/results.test.ts @@ -506,6 +506,32 @@ describe('token standard v1 allocation results', () => { }); }); + it('reports mixed optional holding cids as invalid rather than dropping the non-strings', () => { + const executed = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocationExecuteTransfer, + exerciseResult: { + senderHoldingCids: ['cid-change', 42], + receiverHoldingCids: ['cid-receiver'], + meta: { values: {} }, + }, + }), + }, + ]); + const cancelled = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.allocationCancel, + exerciseResult: { senderHoldingCids: ['cid-returned'], receiverHoldingCids: ['cid-extra', 42] }, + }), + }, + ]); + + expect(() => parseAllocationTransferResult(executed)).toThrow(/not a list of contract ids/); + expect(() => parseAllocationTransferResult(cancelled)).toThrow(/not a list of contract ids/); + }); + it('reports execute without receiver holdings as invalid, not as an empty delivery', () => { const response = transactionTree([ { From 31cae46ed429d385e9009dbeae0558c9aafb1dbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 19 Aug 2026 00:00:00 +0000 Subject: [PATCH 6/6] Reject empty-string CIP-56 V1 contract id lists requireContractIds treated [''] as valid because it only checked typeof string. Each entry must now be a non-empty string; mixed or empty-string arrays fail closed as RESULT_INVALID. Co-authored-by: hardlydiff --- src/utils/token-standard/v1/result.ts | 2 +- test/unit/token-standard/v1/results.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/utils/token-standard/v1/result.ts b/src/utils/token-standard/v1/result.ts index 66c96e64..e08bdc83 100644 --- a/src/utils/token-standard/v1/result.ts +++ b/src/utils/token-standard/v1/result.ts @@ -50,7 +50,7 @@ export function requireContractIds(value: unknown, field: string): string[] { } const contractIds: string[] = []; for (const entry of value) { - if (typeof entry !== 'string') { + if (typeof entry !== 'string' || entry.length === 0) { throw new TokenStandardV1ResultError( TokenStandardV1ResultErrorCode.RESULT_INVALID, `The ${field} is not a list of contract ids.`, diff --git a/test/unit/token-standard/v1/results.test.ts b/test/unit/token-standard/v1/results.test.ts index eb1aed4b..28001c1a 100644 --- a/test/unit/token-standard/v1/results.test.ts +++ b/test/unit/token-standard/v1/results.test.ts @@ -162,6 +162,19 @@ describe('token standard v1 burn-mint results', () => { expect(() => parseBurnMintResult(response)).toThrow(/not a list of contract ids/); }); + it('reports empty-string outputCids as invalid rather than accepting them', () => { + const response = transactionTree([ + { + exercised: exercised({ + choice: TokenStandardV1Choice.burnMint, + exerciseResult: { outputCids: ['cid-1', ''] }, + }), + }, + ]); + + expect(() => parseBurnMintResult(response)).toThrow(/not a list of contract ids/); + }); + it('reports a burn-mint result that names no outputCids', () => { const response = transactionTree([ { exercised: exercised({ choice: TokenStandardV1Choice.burnMint, exerciseResult: { meta: { values: {} } } }) },