diff --git a/src/utils/parsers/event-parser.ts b/src/utils/parsers/event-parser.ts index 1561cd8a..2dc9de82 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,101 @@ 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..6f9c09b8 --- /dev/null +++ b/src/utils/token-standard/v1/allocation.ts @@ -0,0 +1,102 @@ +/** + * 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 { findExercisedEvent, requireTransactionUpdateId } from '../../parsers/event-parser'; +import { + TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES, + TOKEN_STANDARD_V1_ALLOCATION_RESULT_TAGS, + TokenStandardV1AllocationResultTag, + TokenStandardV1Choice, +} from './constants'; +import { + readContractIds, + requireContractIds, + requireKnownVariant, + requireNonEmptyString, + 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 pending instruction, when the allocation did not complete in this transaction. */ + readonly allocationInstructionCid: string | undefined; +} + +/** + * 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 = requireKnownVariant(result, 'output', TOKEN_STANDARD_V1_ALLOCATION_RESULT_TAGS, 'allocation result'); + + if (output.tag === TokenStandardV1AllocationResultTag.completed) { + return { + updateId, + status: 'completed', + senderChangeCids, + 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: 'failed', + senderChangeCids, + allocationCid: undefined, + allocationInstructionCid: 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); + const exercised = findExercisedEvent(transaction, TOKEN_STANDARD_V1_ALLOCATION_EXIT_CHOICES); + const executedTransfer = exercised?.choice === TokenStandardV1Choice.allocationExecuteTransfer; + return { + updateId: requireTransactionUpdateId(transaction), + senderHoldingCids: executedTransfer + ? readContractIds(result['senderHoldingCids'], 'senderHoldingCids') + : requireContractIds(result['senderHoldingCids'], 'senderHoldingCids'), + receiverHoldingCids: executedTransfer + ? requireContractIds(result['receiverHoldingCids'], 'receiverHoldingCids') + : readContractIds(result['receiverHoldingCids'], '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..d3f69e3a --- /dev/null +++ b/src/utils/token-standard/v1/burn-mint.ts @@ -0,0 +1,90 @@ +/** + * 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, and + * a registry that mints outside the factory is read from the creates only when the caller asks for it. + */ + +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 { + 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 { + /** + * 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. + * 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. Required together with + * `mintingChoices` to enable the create-event fallback. + */ + readonly holdingTemplate?: string; +} + +/** + * 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 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)) { + const result = requireResultRecord(transaction, TokenStandardV1Choice.burnMint); + return requireContractIds(result['outputCids'], 'outputCids'); + } + + const { mintingChoices, holdingTemplate } = options; + 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 and holdingTemplate to read a registry-specific mint from the create events instead.`, + { choice: TokenStandardV1Choice.burnMint, updateId: getTransactionUpdateId(transaction) } + ); + } + 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(', ')}.`, + { 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 new file mode 100644 index 00000000..45b32b6d --- /dev/null +++ b/src/utils/token-standard/v1/constants.ts @@ -0,0 +1,86 @@ +/** + * 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 `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, + 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..e08bdc83 --- /dev/null +++ b/src/utils/token-standard/v1/result.ts @@ -0,0 +1,110 @@ +import { isRecord } from '../../../core/utils'; +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 { + return requireResultRecordOfAny(transaction, [choice]); +} + +/** 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 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)) { + throw new TokenStandardV1ResultError( + TokenStandardV1ResultErrorCode.RESULT_INVALID, + `The exercise result has no ${field}.`, + { field } + ); + } + const contractIds: string[] = []; + for (const entry of value) { + if (typeof entry !== 'string' || entry.length === 0) { + 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. */ +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. */ +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'] : {} }; +} + +/** + * 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 new file mode 100644 index 00000000..db151a16 --- /dev/null +++ b/src/utils/token-standard/v1/transfer.ts @@ -0,0 +1,70 @@ +/** + * 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, + TOKEN_STANDARD_V1_TRANSFER_RESULT_TAGS, + TokenStandardV1TransferResultTag, +} from './constants'; +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'; + +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. + * + * `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 = requireKnownVariant(result, 'output', TOKEN_STANDARD_V1_TRANSFER_RESULT_TAGS, 'transfer result'); + + if (output.tag === TokenStandardV1TransferResultTag.completed) { + return { + updateId, + status: 'completed', + senderChangeCids, + receiverHoldingCids: requireContractIds(output.value['receiverHoldingCids'], 'receiverHoldingCids'), + transferInstructionCid: undefined, + }; + } + + if (output.tag === TokenStandardV1TransferResultTag.pending) { + return { + updateId, + status: 'pending', + senderChangeCids, + receiverHoldingCids: [], + transferInstructionCid: requireNonEmptyString(output.value['transferInstructionCid'], 'transferInstructionCid'), + }; + } + + 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..d322d533 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,139 @@ 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..28001c1a --- /dev/null +++ b/test/unit/token-standard/v1/results.test.ts @@ -0,0 +1,611 @@ +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([]); + expect(parseMintedHoldings(response)).toEqual([]); + }); + + 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, { + 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 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: {} }) }, + { 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', () => { + 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 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: {} } } }) }, + ]); + + 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.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([ + { + 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/); + }); + + 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', () => { + 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', + 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([ + { + 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, + 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([ + { + 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, + 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 }, + }); + }); + + 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([ + { + 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 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([ + { + 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' }) }, + ]); + + 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 } + ), + }, + }; +}