diff --git a/src/utils/token-standard/v1/holdings.ts b/src/utils/token-standard/v1/holdings.ts new file mode 100644 index 00000000..87558497 --- /dev/null +++ b/src/utils/token-standard/v1/holdings.ts @@ -0,0 +1,561 @@ +import type { GetActiveContractsParams } from '../../../clients/ledger-json-api/operations/v2/state/get-active-contracts'; +import type { JsGetActiveContractsResponse } from '../../../clients/ledger-json-api/schemas'; +import { CantonError, type ErrorContext } from '../../../core/errors'; +import { isNonEmptyString, isRecord } from '../../../core/utils'; +import { isJsActiveContractItem, type JsActiveContractItem } from '../../contracts'; +import { TOKEN_STANDARD_V1_HOLDING_INTERFACE_ID } from './constants'; +import type { TokenStandardV1InstrumentId, TokenStandardV1Metadata } from './transfer-factory'; + +export const TOKEN_STANDARD_V1_AMOUNT_DECIMALS = 10; + +const ISO_8601_TIMESTAMP_PATTERN = + /^(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; +const SIGNED_INTEGER_PATTERN = /^-?\d+$/; +const DECIMAL_AMOUNT_PATTERN = /^(\d+)(?:\.(\d+))?$/; +const TRAILING_ZEROS_PATTERN = /0+$/; +const NON_NEGATIVE_INTEGER_PATTERN = /^\d+$/; + +export const TokenStandardV1HoldingErrorCode = { + INPUT_INVALID: 'TOKEN_STANDARD_V1_HOLDING_INPUT_INVALID', + INTERFACE_VIEW_INVALID: 'TOKEN_STANDARD_V1_HOLDING_INTERFACE_VIEW_INVALID', + BALANCE_INSUFFICIENT: 'TOKEN_STANDARD_V1_HOLDING_BALANCE_INSUFFICIENT', +} as const; + +export type TokenStandardV1HoldingErrorCode = + (typeof TokenStandardV1HoldingErrorCode)[keyof typeof TokenStandardV1HoldingErrorCode]; + +export class TokenStandardV1HoldingError extends CantonError { + public override readonly name: string; + + public constructor(code: TokenStandardV1HoldingErrorCode, message: string, context?: ErrorContext) { + super(message, code, context); + this.name = 'TokenStandardV1HoldingError'; + } +} + +export interface TokenStandardV1HoldingActiveContractsClient { + getActiveContracts(params: GetActiveContractsParams): Promise; +} + +export interface TokenStandardV1Lock { + readonly holders: readonly string[]; + readonly expiresAt: string | null; + /** Duration from the holding's createdAt ledger time after which the lock expires. */ + readonly expiresAfter: { readonly microseconds: string } | null; + readonly context: string | null; +} + +export interface TokenStandardV1Holding { + readonly contractId: string; + readonly templateId: string; + readonly synchronizerId: string; + /** ISO 8601 ledger effective time at which the active holding was created. */ + readonly createdAt: string; + readonly owner: string; + readonly instrumentId: TokenStandardV1InstrumentId; + readonly amount: string; + readonly amountBaseUnits: string; + readonly lock: TokenStandardV1Lock | null; + readonly meta: TokenStandardV1Metadata; +} + +export interface ListTokenStandardV1HoldingsParams { + readonly ledger: TokenStandardV1HoldingActiveContractsClient; + readonly parties: readonly string[]; + readonly owner: string; + readonly instrumentId: TokenStandardV1InstrumentId; + readonly instrumentDecimals: number; + readonly synchronizerId?: string; + readonly activeAtOffset?: number; + readonly holdingInterfaceId?: string; +} + +export interface SelectTokenStandardV1HoldingsParams extends Omit< + ListTokenStandardV1HoldingsParams, + 'synchronizerId' | 'activeAtOffset' +> { + readonly synchronizerId: string; + /** ACS snapshot offset. Requiring it keeps selection to one Canton network read. */ + readonly activeAtOffset: number; + readonly amountBaseUnits: string; + /** + * Token-specific spendability policy. Use holding.createdAt with lock.expiresAfter to derive a relative lock's + * absolute expiry. Defaults to holdings whose HoldingV1 lock is None. + */ + readonly isSpendable?: (holding: TokenStandardV1Holding) => boolean; +} + +export interface SelectedTokenStandardV1Holdings { + readonly holdings: readonly TokenStandardV1Holding[]; + readonly contractIds: readonly string[]; + readonly totalBaseUnits: string; +} + +function inputInvalid(message: string, context: ErrorContext): never { + throw new TokenStandardV1HoldingError(TokenStandardV1HoldingErrorCode.INPUT_INVALID, message, context); +} + +function interfaceViewInvalid(message: string, context: ErrorContext): never { + throw new TokenStandardV1HoldingError(TokenStandardV1HoldingErrorCode.INTERFACE_VIEW_INVALID, message, context); +} + +function normalizeRequiredString(value: string, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + inputInvalid(`${field} must be a non-empty string.`, { field }); + } + return value.trim(); +} + +function normalizeParties(parties: readonly string[]): string[] { + if (!Array.isArray(parties)) { + inputInvalid('parties must be an array of explicit read parties.', { + field: 'parties', + }); + } + const normalized = new Set(); + for (const [index, party] of parties.entries()) { + if (typeof party !== 'string') { + inputInvalid('parties must contain only strings.', { + field: 'parties', + index, + }); + } + if (party.trim().length > 0) normalized.add(party.trim()); + } + if (normalized.size === 0) { + inputInvalid('parties must contain at least one read party.', { + field: 'parties', + }); + } + return [...normalized]; +} + +function normalizeInstrumentDecimals(value: number): number { + if (!Number.isSafeInteger(value) || value < 0 || value > TOKEN_STANDARD_V1_AMOUNT_DECIMALS) { + inputInvalid(`instrumentDecimals must be a safe integer between 0 and ${TOKEN_STANDARD_V1_AMOUNT_DECIMALS}.`, { + field: 'instrumentDecimals', + instrumentDecimals: value, + }); + } + return value; +} + +function normalizeActiveAtOffset(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + inputInvalid('activeAtOffset must be a non-negative safe integer.', { + field: 'activeAtOffset', + activeAtOffset: value, + }); + } + return value; +} + +function normalizeRequiredActiveAtOffset(value: unknown): number { + if (value === undefined) { + inputInvalid('activeAtOffset is required for Token Standard V1 holding selection.', { + field: 'activeAtOffset', + }); + } + if (typeof value !== 'number') { + inputInvalid('activeAtOffset must be a non-negative safe integer.', { + field: 'activeAtOffset', + activeAtOffset: value, + }); + } + return normalizeActiveAtOffset(value); +} + +function validateOwner(owner: string): void { + if (!isNonEmptyString(owner)) { + inputInvalid('owner must be a non-empty party.', { field: 'owner' }); + } +} + +function validateInstrumentId(instrumentId: TokenStandardV1InstrumentId): void { + if (!isRecord(instrumentId) || !isNonEmptyString(instrumentId.admin) || !isNonEmptyString(instrumentId.id)) { + inputInvalid('instrumentId must be a valid Token Standard V1 InstrumentId.', { field: 'instrumentId' }); + } +} + +function readNullableString(value: unknown): string | null | undefined { + if (value === null) return null; + return isNonEmptyString(value) ? value : undefined; +} + +function readCreatedAt(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const match = ISO_8601_TIMESTAMP_PATTERN.exec(value); + if (!match || Number.isNaN(Date.parse(value))) return undefined; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const isLeapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = month === 2 ? (isLeapYear ? 29 : 28) : [4, 6, 9, 11].includes(month) ? 30 : 31; + return day <= daysInMonth ? value : undefined; +} + +function requireCreatedAt(value: unknown, context: ErrorContext): string { + const createdAt = readCreatedAt(value); + if (createdAt === undefined) { + interfaceViewInvalid('Active Holding contract is missing a valid createdAt ledger timestamp.', { + ...context, + createdAt: value, + }); + } + return createdAt; +} + +function readRawActiveContract(item: unknown): Readonly> | undefined { + if (!isRecord(item)) return undefined; + const { contractEntry } = item; + if (!isRecord(contractEntry)) return undefined; + const activeContract = contractEntry['JsActiveContract']; + return isRecord(activeContract) ? activeContract : undefined; +} + +function readInstrumentId(value: unknown): TokenStandardV1InstrumentId | undefined { + if (!isRecord(value)) return undefined; + const admin = isNonEmptyString(value['admin']) ? value['admin'] : undefined; + const id = isNonEmptyString(value['id']) ? value['id'] : undefined; + return admin && id ? { admin, id } : undefined; +} + +function readMetadata(value: unknown): TokenStandardV1Metadata | undefined { + if (!isRecord(value) || !isRecord(value['values'])) return undefined; + const values = Object.create(null) as Record; + for (const [key, metadataValue] of Object.entries(value['values'])) { + if (typeof metadataValue !== 'string') return undefined; + Object.defineProperty(values, key, { + value: metadataValue, + enumerable: true, + configurable: true, + writable: false, + }); + } + return { values }; +} + +function readLock(value: unknown): TokenStandardV1Lock | null | undefined { + if (value === null) return null; + if (!isRecord(value) || !Array.isArray(value['holders'])) return undefined; + const { holders } = value; + if (!holders.every(isNonEmptyString)) return undefined; + + const expiresAt = readNullableString(value['expiresAt']); + const context = readNullableString(value['context']); + const rawExpiresAfter = value['expiresAfter']; + let expiresAfter: TokenStandardV1Lock['expiresAfter']; + if (rawExpiresAfter === null) { + expiresAfter = null; + } else if ( + isRecord(rawExpiresAfter) && + typeof rawExpiresAfter['microseconds'] === 'string' && + SIGNED_INTEGER_PATTERN.test(rawExpiresAfter['microseconds']) + ) { + expiresAfter = { microseconds: rawExpiresAfter['microseconds'] }; + } else { + return undefined; + } + if (expiresAt === undefined || context === undefined) return undefined; + return { holders, expiresAt, expiresAfter, context }; +} + +function instrumentIdsEqual(left: TokenStandardV1InstrumentId, right: TokenStandardV1InstrumentId): boolean { + return left.admin === right.admin && left.id === right.id; +} + +function locksEqual(left: TokenStandardV1Lock | null, right: TokenStandardV1Lock | null): boolean { + if (left === null || right === null) return left === right; + return ( + left.expiresAt === right.expiresAt && + left.expiresAfter?.microseconds === right.expiresAfter?.microseconds && + left.context === right.context && + left.holders.length === right.holders.length && + left.holders.every((holder, index) => holder === right.holders[index]) + ); +} + +function metadataEqual(left: TokenStandardV1Metadata, right: TokenStandardV1Metadata): boolean { + const leftEntries = Object.entries(left.values); + return ( + leftEntries.length === Object.keys(right.values).length && + leftEntries.every(([key, value]) => right.values[key] === value) + ); +} + +function identifierModuleEntitySuffix(identifier: string): string | undefined { + const separator = identifier.indexOf(':'); + return separator === -1 ? undefined : identifier.slice(separator + 1); +} + +function interfaceIdsMatch(left: string, right: string): boolean { + return identifierModuleEntitySuffix(left) === identifierModuleEntitySuffix(right); +} + +function failedInterfaceView(params: { + readonly itemIndex: number; + readonly contractId: unknown; + readonly interfaceId: unknown; + readonly statusCode: number; + readonly statusMessage: unknown; +}): never { + interfaceViewInvalid('HoldingV1 interface view request failed.', { + itemIndex: params.itemIndex, + contractId: params.contractId, + interfaceId: params.interfaceId, + viewStatusCode: params.statusCode, + viewStatusMessage: params.statusMessage, + }); +} + +function decimalAmountToBaseUnits(amount: string, decimals: number): string { + const match = DECIMAL_AMOUNT_PATTERN.exec(amount.trim()); + if (!match) throw new Error('amount must be a non-negative decimal'); + + const whole = match[1] ?? '0'; + const fractional = (match[2] ?? '').replace(TRAILING_ZEROS_PATTERN, ''); + if (fractional.length > decimals) { + throw new Error(`amount exceeds ${decimals} decimal places`); + } + const scale = 10n ** BigInt(decimals); + return (BigInt(whole) * scale + BigInt(fractional.padEnd(decimals, '0') || '0')).toString(); +} + +function normalizeBaseUnitAmount(value: string): string { + if (typeof value !== 'string' || !NON_NEGATIVE_INTEGER_PATTERN.test(value.trim())) { + inputInvalid('amountBaseUnits must be a non-negative integer string.', { + field: 'amountBaseUnits', + }); + } + return BigInt(value.trim()).toString(); +} + +function readHolding(params: { + readonly item: JsActiveContractItem; + readonly itemIndex: number; + readonly holdingInterfaceId: string; + readonly owner: string; + readonly instrumentId: TokenStandardV1InstrumentId; + readonly instrumentDecimals: number; + readonly synchronizerId?: string; +}): TokenStandardV1Holding | undefined { + const activeContract = params.item.contractEntry.JsActiveContract; + if (params.synchronizerId !== undefined && activeContract.synchronizerId !== params.synchronizerId) { + return undefined; + } + + const { createdEvent } = activeContract; + const matchingViews = createdEvent.interfaceViews.filter((view) => + interfaceIdsMatch(view.interfaceId, params.holdingInterfaceId) + ); + if (matchingViews.length !== 1) { + interfaceViewInvalid('Active Holding contract must contain exactly one HoldingV1 interface view.', { + itemIndex: params.itemIndex, + contractId: createdEvent.contractId, + matchingViewCount: matchingViews.length, + }); + } + + const interfaceView = matchingViews[0]; + if (!interfaceView) { + interfaceViewInvalid('HoldingV1 interface view is missing.', { + itemIndex: params.itemIndex, + contractId: createdEvent.contractId, + }); + } + const viewValue = isRecord(interfaceView.viewValue) ? interfaceView.viewValue : undefined; + const owner = viewValue && isNonEmptyString(viewValue['owner']) ? viewValue['owner'] : undefined; + const instrumentId = viewValue ? readInstrumentId(viewValue['instrumentId']) : undefined; + if ((owner && owner !== params.owner) || (instrumentId && !instrumentIdsEqual(instrumentId, params.instrumentId))) { + return undefined; + } + + if (interfaceView.viewStatus.code !== 0) { + failedInterfaceView({ + itemIndex: params.itemIndex, + contractId: createdEvent.contractId, + interfaceId: interfaceView.interfaceId, + statusCode: interfaceView.viewStatus.code, + statusMessage: interfaceView.viewStatus.message, + }); + } + if (!viewValue) { + interfaceViewInvalid('HoldingV1 interface view is missing viewValue.', { + itemIndex: params.itemIndex, + contractId: createdEvent.contractId, + }); + } + + if (!owner || !instrumentId) { + interfaceViewInvalid('HoldingV1 interface view is missing a valid owner or instrumentId.', { + itemIndex: params.itemIndex, + contractId: createdEvent.contractId, + }); + } + const createdAt = requireCreatedAt(createdEvent.createdAt, { + itemIndex: params.itemIndex, + contractId: createdEvent.contractId, + }); + + const lock = readLock(viewValue['lock']); + const meta = readMetadata(viewValue['meta']); + if (lock === undefined || !meta) { + interfaceViewInvalid('HoldingV1 interface view has an invalid lock or metadata value.', { + itemIndex: params.itemIndex, + contractId: createdEvent.contractId, + }); + } + + const { amount } = viewValue; + if (!isNonEmptyString(amount)) { + interfaceViewInvalid('HoldingV1 interface view is missing amount.', { + itemIndex: params.itemIndex, + contractId: createdEvent.contractId, + }); + } + let amountBaseUnits: string; + try { + amountBaseUnits = decimalAmountToBaseUnits(amount, params.instrumentDecimals); + } catch { + interfaceViewInvalid('HoldingV1 amount is invalid for the configured instrument decimals.', { + itemIndex: params.itemIndex, + contractId: createdEvent.contractId, + amount, + instrumentDecimals: params.instrumentDecimals, + }); + } + + return { + contractId: createdEvent.contractId, + templateId: createdEvent.templateId, + synchronizerId: activeContract.synchronizerId, + createdAt, + owner, + instrumentId, + amount, + amountBaseUnits, + lock, + meta, + }; +} + +export async function listTokenStandardV1Holdings( + params: ListTokenStandardV1HoldingsParams +): Promise { + if (!isRecord(params.ledger) || typeof params.ledger.getActiveContracts !== 'function') { + inputInvalid('ledger must provide getActiveContracts.', { field: 'ledger' }); + } + validateOwner(params.owner); + validateInstrumentId(params.instrumentId); + const parties = normalizeParties(params.parties); + const instrumentDecimals = normalizeInstrumentDecimals(params.instrumentDecimals); + const holdingInterfaceId = normalizeRequiredString( + params.holdingInterfaceId ?? TOKEN_STANDARD_V1_HOLDING_INTERFACE_ID, + 'holdingInterfaceId' + ); + const synchronizerId = + params.synchronizerId === undefined ? undefined : normalizeRequiredString(params.synchronizerId, 'synchronizerId'); + const activeAtOffset = + params.activeAtOffset === undefined ? undefined : normalizeActiveAtOffset(params.activeAtOffset); + const response = await params.ledger.getActiveContracts({ + parties, + interfaceIds: [holdingInterfaceId], + includeInterfaceView: true, + includeCreatedEventBlob: false, + ...(activeAtOffset === undefined ? {} : { activeAtOffset }), + }); + + const holdings = new Map(); + for (const [itemIndex, item] of response.entries()) { + if (!isJsActiveContractItem(item)) { + const activeContract = readRawActiveContract(item); + if (activeContract && (synchronizerId === undefined || activeContract['synchronizerId'] === synchronizerId)) { + const { createdEvent } = activeContract; + requireCreatedAt(isRecord(createdEvent) ? createdEvent['createdAt'] : undefined, { + itemIndex, + contractId: isRecord(createdEvent) ? createdEvent['contractId'] : undefined, + }); + } + continue; + } + const holding = readHolding({ + item, + itemIndex, + holdingInterfaceId, + owner: params.owner, + instrumentId: params.instrumentId, + instrumentDecimals, + ...(synchronizerId === undefined ? {} : { synchronizerId }), + }); + if (!holding) continue; + + const existing = holdings.get(holding.contractId); + if ( + existing && + (existing.templateId !== holding.templateId || + existing.synchronizerId !== holding.synchronizerId || + existing.createdAt !== holding.createdAt || + existing.owner !== holding.owner || + existing.amount !== holding.amount || + existing.amountBaseUnits !== holding.amountBaseUnits || + !locksEqual(existing.lock, holding.lock) || + !metadataEqual(existing.meta, holding.meta)) + ) { + interfaceViewInvalid('Duplicate HoldingV1 contract rows contain inconsistent data.', { + contractId: holding.contractId, + }); + } + if (!existing) holdings.set(holding.contractId, holding); + } + return [...holdings.values()]; +} + +export async function selectTokenStandardV1Holdings( + params: SelectTokenStandardV1HoldingsParams +): Promise { + const activeAtOffset = normalizeRequiredActiveAtOffset(params.activeAtOffset); + const required = BigInt(normalizeBaseUnitAmount(params.amountBaseUnits)); + if (required <= 0n) { + inputInvalid('amountBaseUnits must be positive.', { + field: 'amountBaseUnits', + amountBaseUnits: params.amountBaseUnits, + }); + } + + const isSpendable = params.isSpendable ?? ((holding: TokenStandardV1Holding) => holding.lock === null); + const holdings = [...(await listTokenStandardV1Holdings({ ...params, activeAtOffset }))] + .filter((holding) => isSpendable(holding) && BigInt(holding.amountBaseUnits) > 0n) + .sort((left, right) => { + const difference = BigInt(right.amountBaseUnits) - BigInt(left.amountBaseUnits); + if (difference !== 0n) return difference > 0n ? 1 : -1; + return left.contractId.localeCompare(right.contractId); + }); + + const selected: TokenStandardV1Holding[] = []; + let total = 0n; + for (const holding of holdings) { + selected.push(holding); + total += BigInt(holding.amountBaseUnits); + if (total >= required) break; + } + + if (total < required) { + throw new TokenStandardV1HoldingError( + TokenStandardV1HoldingErrorCode.BALANCE_INSUFFICIENT, + 'Available spendable Token Standard V1 holdings do not cover the requested amount.', + { + requiredBaseUnits: required.toString(), + availableBaseUnits: total.toString(), + owner: params.owner, + instrumentId: params.instrumentId, + } + ); + } + + return { + holdings: selected, + contractIds: selected.map((holding) => holding.contractId), + totalBaseUnits: total.toString(), + }; +} diff --git a/src/utils/token-standard/v1/index.ts b/src/utils/token-standard/v1/index.ts index 000ab750..ba10390d 100644 --- a/src/utils/token-standard/v1/index.ts +++ b/src/utils/token-standard/v1/index.ts @@ -2,5 +2,7 @@ export * from './allocation'; export * from './burn-mint'; export * from './constants'; export * from './errors'; +export * from './holdings'; export * from './result'; export * from './transfer'; +export * from './transfer-factory'; diff --git a/src/utils/token-standard/v1/transfer-factory.ts b/src/utils/token-standard/v1/transfer-factory.ts new file mode 100644 index 00000000..17f81a47 --- /dev/null +++ b/src/utils/token-standard/v1/transfer-factory.ts @@ -0,0 +1,232 @@ +import type { Command, ExerciseCommand } from '../../../clients/ledger-json-api/schemas'; +import { CantonError, type ErrorContext } from '../../../core/errors'; +import { isRecord } from '../../../core/utils'; +import { TOKEN_STANDARD_V1_TRANSFER_FACTORY_INTERFACE_ID, TokenStandardV1Choice } from './constants'; + +const DAML_DECIMAL_PATTERN = /^(-?)(\d{1,28})(?:\.(\d{1,10}))?$/; +const ZERO_DIGITS_PATTERN = /^0+$/; + +export const TokenStandardV1TransferFactoryErrorCode = { + INPUT_INVALID: 'TOKEN_STANDARD_V1_TRANSFER_FACTORY_INPUT_INVALID', +} as const; + +export type TokenStandardV1TransferFactoryErrorCode = + (typeof TokenStandardV1TransferFactoryErrorCode)[keyof typeof TokenStandardV1TransferFactoryErrorCode]; + +export class TokenStandardV1TransferFactoryError extends CantonError { + public override readonly name: string; + + public constructor(code: TokenStandardV1TransferFactoryErrorCode, message: string, context?: ErrorContext) { + super(message, code, context); + this.name = 'TokenStandardV1TransferFactoryError'; + } +} + +export interface TokenStandardV1InstrumentId { + readonly admin: string; + readonly id: string; +} + +export interface TokenStandardV1Metadata { + readonly values: Readonly>; +} + +export interface TokenStandardV1ChoiceContext { + readonly values: Readonly>; +} + +export interface TokenStandardV1ExtraArgs { + readonly context: TokenStandardV1ChoiceContext; + readonly meta: TokenStandardV1Metadata; +} + +export interface TokenStandardV1Transfer { + readonly sender: string; + readonly receiver: string; + readonly amount: string; + readonly instrumentId: TokenStandardV1InstrumentId; + readonly requestedAt: string; + readonly executeBefore: string; + readonly inputHoldingCids: readonly string[]; + readonly meta?: TokenStandardV1Metadata; +} + +export interface TokenStandardV1TransferFactoryTransferArgument { + readonly expectedAdmin: string; + readonly transfer: Omit & { readonly meta: TokenStandardV1Metadata }; + readonly extraArgs: TokenStandardV1ExtraArgs; +} + +export interface BuildTokenStandardV1TransferChoiceArgumentParams { + readonly expectedAdmin: string; + readonly transfer: TokenStandardV1Transfer; + readonly extraArgs?: TokenStandardV1ExtraArgs; +} + +export interface BuildTokenStandardV1TransferCommandParams extends BuildTokenStandardV1TransferChoiceArgumentParams { + readonly transferFactoryContractId: string; +} + +function inputInvalid(message: string, context: ErrorContext): never { + throw new TokenStandardV1TransferFactoryError( + TokenStandardV1TransferFactoryErrorCode.INPUT_INVALID, + message, + context + ); +} + +function requireInputRecord(value: unknown, field: string): asserts value is Record { + if (!isRecord(value)) { + inputInvalid(`${field} must be an object.`, { field, value }); + } +} + +function requireNonEmpty(value: unknown, field: string): string { + if (typeof value !== 'string') { + inputInvalid(`${field} must be a string.`, { field, value }); + } + const normalized = value.trim(); + if (normalized.length === 0) { + inputInvalid(`${field} must be non-empty.`, { field, value }); + } + return normalized; +} + +function requireText(value: unknown, field: string): string { + if (typeof value !== 'string') { + inputInvalid(`${field} must be text.`, { field, value }); + } + return value; +} + +function copyStringRecord(value: unknown, field: string): Readonly> { + if (!isRecord(value)) { + inputInvalid(`${field} must be a string map.`, { field, value }); + } + const result = Object.create(null) as Record; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== 'string') { + inputInvalid(`${field}.${key} must be a string.`, { field: `${field}.${key}`, value: entry }); + } + Object.defineProperty(result, key, { + value: entry, + enumerable: true, + configurable: true, + writable: false, + }); + } + return result; +} + +function normalizeMetadata(value: unknown, field: string): TokenStandardV1Metadata { + if (!isRecord(value)) { + inputInvalid(`${field} must be Token Standard metadata.`, { field, value }); + } + return { values: copyStringRecord(value['values'], `${field}.values`) }; +} + +function normalizeMetadataOrDefault(value: unknown, field: string): TokenStandardV1Metadata { + return value === undefined ? { values: copyStringRecord({}, `${field}.values`) } : normalizeMetadata(value, field); +} + +function normalizeChoiceContext(value: unknown, field: string): TokenStandardV1ChoiceContext { + if (!isRecord(value) || !isRecord(value['values'])) { + inputInvalid(`${field} must be a Token Standard choice context.`, { field, value }); + } + const values = Object.create(null) as Record; + for (const [key, entry] of Object.entries(value['values'])) { + Object.defineProperty(values, key, { + value: entry, + enumerable: true, + configurable: true, + writable: false, + }); + } + return { values }; +} + +function parseDecimalText(value: unknown, field: string): { readonly text: string; readonly sign: -1 | 0 | 1 } { + const text = requireNonEmpty(value, field); + const match = DAML_DECIMAL_PATTERN.exec(text); + if (!match) { + inputInvalid(`${field} must be a valid Daml Decimal string.`, { field, value }); + } + const digits = `${match[2]}${match[3] ?? ''}`; + const sign = ZERO_DIGITS_PATTERN.test(digits) ? 0 : match[1] === '-' ? -1 : 1; + return { text, sign }; +} + +function normalizePositiveDecimal(value: unknown, field: string): string { + const decimal = parseDecimalText(value, field); + if (decimal.sign !== 1) { + inputInvalid(`${field} must be positive.`, { field, value }); + } + return decimal.text; +} + +function normalizeInstrumentId(value: unknown, field: string): TokenStandardV1InstrumentId { + requireInputRecord(value, field); + return { + admin: requireNonEmpty(value['admin'], `${field}.admin`), + id: requireText(value['id'], `${field}.id`), + }; +} + +function normalizeHoldingCids(value: unknown, field: string): string[] { + if (!Array.isArray(value)) { + inputInvalid(`${field} must be an array.`, { field, value }); + } + return value.map((entry, index) => requireNonEmpty(entry, `${field}[${index}]`)); +} + +function emptyTokenStandardV1ExtraArgs(): TokenStandardV1ExtraArgs { + return { + context: { values: Object.create(null) as Record }, + meta: { values: Object.create(null) as Record }, + }; +} + +function normalizeExtraArgs(value: unknown): TokenStandardV1ExtraArgs { + if (value === undefined) return emptyTokenStandardV1ExtraArgs(); + requireInputRecord(value, 'extraArgs'); + return { + context: normalizeChoiceContext(value['context'], 'extraArgs.context'), + meta: normalizeMetadata(value['meta'], 'extraArgs.meta'), + }; +} + +export function buildTokenStandardV1TransferChoiceArgument( + params: BuildTokenStandardV1TransferChoiceArgumentParams +): TokenStandardV1TransferFactoryTransferArgument { + requireInputRecord(params, 'params'); + requireInputRecord(params.transfer, 'transfer'); + if (params.extraArgs !== undefined) requireInputRecord(params.extraArgs, 'extraArgs'); + if (params.transfer.meta !== undefined) requireInputRecord(params.transfer.meta, 'transfer.meta'); + return { + expectedAdmin: requireNonEmpty(params.expectedAdmin, 'expectedAdmin'), + transfer: { + sender: requireNonEmpty(params.transfer.sender, 'transfer.sender'), + receiver: requireNonEmpty(params.transfer.receiver, 'transfer.receiver'), + amount: normalizePositiveDecimal(params.transfer.amount, 'transfer.amount'), + instrumentId: normalizeInstrumentId(params.transfer.instrumentId, 'transfer.instrumentId'), + requestedAt: requireNonEmpty(params.transfer.requestedAt, 'transfer.requestedAt'), + executeBefore: requireNonEmpty(params.transfer.executeBefore, 'transfer.executeBefore'), + inputHoldingCids: normalizeHoldingCids(params.transfer.inputHoldingCids, 'transfer.inputHoldingCids'), + meta: normalizeMetadataOrDefault(params.transfer.meta, 'transfer.meta'), + }, + extraArgs: normalizeExtraArgs(params.extraArgs), + }; +} + +export function buildTokenStandardV1TransferCommand(params: BuildTokenStandardV1TransferCommandParams): Command { + requireInputRecord(params, 'params'); + const choiceArgument = buildTokenStandardV1TransferChoiceArgument(params); + return { + ExerciseCommand: { + templateId: TOKEN_STANDARD_V1_TRANSFER_FACTORY_INTERFACE_ID, + contractId: requireNonEmpty(params.transferFactoryContractId, 'transferFactoryContractId'), + choice: TokenStandardV1Choice.transfer, + choiceArgument: choiceArgument as unknown as ExerciseCommand['ExerciseCommand']['choiceArgument'], + }, + }; +} diff --git a/test/unit/token-standard/v1/holdings.test.ts b/test/unit/token-standard/v1/holdings.test.ts new file mode 100644 index 00000000..9523b9ee --- /dev/null +++ b/test/unit/token-standard/v1/holdings.test.ts @@ -0,0 +1,520 @@ +import type { JsGetActiveContractsResponseItem } from '../../../../src/clients/ledger-json-api/schemas'; +import { + listTokenStandardV1Holdings, + selectTokenStandardV1Holdings, + TOKEN_STANDARD_V1_HOLDING_INTERFACE_ID, + TokenStandardV1HoldingError, + type TokenStandardV1HoldingActiveContractsClient, + type TokenStandardV1InstrumentId, +} from '../../../../src/utils/token-standard'; + +const owner = 'Buyer::alice'; +const instrumentId: TokenStandardV1InstrumentId = { + admin: 'CashAdmin::issuer', + id: 'USD', +}; +const synchronizerId = 'global-domain::1220primary'; + +function activeHolding(params: { + readonly contractId: string; + readonly amount: string; + readonly owner?: unknown; + readonly instrumentId?: unknown; + readonly lock?: unknown; + readonly createdAt?: string; + readonly viewStatusCode?: number; + readonly viewStatusMessage?: string; + readonly synchronizerId?: string; + readonly interfaceId?: string; + readonly omitViewValue?: boolean; + readonly omitLock?: boolean; + readonly meta?: unknown; +}): JsGetActiveContractsResponseItem { + const viewValue = { + owner: params.owner ?? owner, + instrumentId: params.instrumentId ?? instrumentId, + amount: params.amount, + ...(!params.omitLock ? { lock: params.lock ?? null } : {}), + meta: params.meta ?? { values: {} }, + }; + return { + contractEntry: { + JsActiveContract: { + synchronizerId: params.synchronizerId ?? synchronizerId, + reassignmentCounter: 0, + createdEvent: { + offset: 1, + nodeId: 1, + contractId: params.contractId, + templateId: '#cash-token:Cash:Holding', + contractKey: null, + createArgument: {}, + createdEventBlob: '', + interfaceViews: [ + { + interfaceId: params.interfaceId ?? '#holding-package-id:Splice.Api.Token.HoldingV1:Holding', + viewStatus: { + code: params.viewStatusCode ?? 0, + message: params.viewStatusMessage ?? '', + }, + ...(!params.omitViewValue ? { viewValue } : {}), + }, + ], + witnessParties: ['Buyer::alice'], + signatories: ['CashAdmin::issuer'], + observers: ['Buyer::alice'], + createdAt: params.createdAt ?? '2026-07-10T01:00:00.000Z', + packageName: 'cash-token', + implementedInterfaces: ['#holding-package-id:Splice.Api.Token.HoldingV1:Holding'], + }, + }, + }, + }; +} + +function withoutCreatedAt(item: JsGetActiveContractsResponseItem): JsGetActiveContractsResponseItem { + if (!('JsActiveContract' in item.contractEntry)) throw new Error('Expected an active contract fixture'); + delete (item.contractEntry.JsActiveContract.createdEvent as { createdAt?: unknown }).createdAt; + return item; +} + +function createLedger( + holdings: readonly JsGetActiveContractsResponseItem[] +): TokenStandardV1HoldingActiveContractsClient & { + readonly getActiveContracts: jest.Mock; +} { + return { + getActiveContracts: jest.fn(async () => [...holdings]), + }; +} + +describe('Token Standard V1 holdings', () => { + test('queries HoldingV1 once and deterministically selects the fewest contracts', async () => { + const seven = activeHolding({ contractId: '#seven', amount: '7.0000000000' }); + const ledger = createLedger([ + activeHolding({ contractId: '#four', amount: '4.0000000000' }), + seven, + activeHolding({ contractId: '#seven', amount: '7.0000000000' }), + activeHolding({ + contractId: '#other-synchronizer', + amount: '1000.0', + synchronizerId: 'other-domain::1220secondary', + viewStatusCode: 3, + }), + activeHolding({ + contractId: '#locked', + amount: '100.0', + lock: { + holders: ['CashAdmin::issuer'], + expiresAt: null, + expiresAfter: null, + context: null, + }, + }), + activeHolding({ + contractId: '#other-owner', + amount: '100.0', + owner: 'Other::owner', + }), + ]); + + await expect( + selectTokenStandardV1Holdings({ + ledger, + parties: [' Buyer::alice ', 'Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + synchronizerId, + activeAtOffset: 42, + amountBaseUnits: '8000000', + }) + ).resolves.toEqual({ + holdings: [ + expect.objectContaining({ + contractId: '#seven', + amountBaseUnits: '7000000', + }), + expect.objectContaining({ + contractId: '#four', + amountBaseUnits: '4000000', + }), + ], + contractIds: ['#seven', '#four'], + totalBaseUnits: '11000000', + }); + expect(ledger.getActiveContracts).toHaveBeenCalledTimes(1); + expect(ledger.getActiveContracts).toHaveBeenCalledWith({ + parties: ['Buyer::alice'], + interfaceIds: [TOKEN_STANDARD_V1_HOLDING_INTERFACE_ID], + includeInterfaceView: true, + includeCreatedEventBlob: false, + activeAtOffset: 42, + }); + }); + + test('filters by owner and InstrumentId before validating unrelated values', async () => { + const ledger = createLedger([ + activeHolding({ contractId: '#match', amount: '1.0' }), + activeHolding({ + contractId: '#other-owner', + amount: '2.0000001', + owner: 'Other::owner', + }), + activeHolding({ + contractId: '#other-instrument', + amount: '3.0000001', + instrumentId: { ...instrumentId, id: 'EUR' }, + }), + ]); + + await expect( + listTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + }) + ).resolves.toEqual([ + expect.objectContaining({ + contractId: '#match', + amountBaseUnits: '1000000', + }), + ]); + }); + + test('skips failed or malformed views when either filter proves the row is out of scope', async () => { + const ledger = createLedger([ + activeHolding({ + contractId: '#failed-other-owner', + amount: 'not-a-decimal', + owner: 'Other::owner', + viewStatusCode: 3, + viewStatusMessage: 'HoldingV1 view computation failed', + }), + activeHolding({ + contractId: '#malformed-other-instrument', + amount: 'not-a-decimal', + owner: 42, + instrumentId: { ...instrumentId, id: 'EUR' }, + omitLock: true, + }), + activeHolding({ contractId: '#match', amount: '1.0' }), + ]); + + await expect( + selectTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + synchronizerId, + activeAtOffset: 42, + amountBaseUnits: '1000000', + }) + ).resolves.toMatchObject({ + contractIds: ['#match'], + totalBaseUnits: '1000000', + }); + }); + + test('throws the typed interface error when the requested view fails on the selected synchronizer', async () => { + const ledger = createLedger([ + activeHolding({ + contractId: '#failed-view', + amount: '1.0', + viewStatusCode: 3, + viewStatusMessage: 'HoldingV1 view computation failed', + omitViewValue: true, + }), + ]); + + await expect( + selectTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + synchronizerId, + activeAtOffset: 42, + amountBaseUnits: '1000000', + }) + ).rejects.toMatchObject({ + code: 'TOKEN_STANDARD_V1_HOLDING_INTERFACE_VIEW_INVALID', + context: { + contractId: '#failed-view', + viewStatusCode: 3, + viewStatusMessage: 'HoldingV1 view computation failed', + }, + }); + }); + + test('exposes createdAt for a token-specific relative-lock spendability policy', async () => { + const ledger = createLedger([ + activeHolding({ + contractId: '#relative-expired-lock', + amount: '5.0', + lock: { + holders: ['CashAdmin::issuer'], + expiresAt: null, + expiresAfter: { microseconds: '3600000000' }, + context: 'cash-lock', + }, + meta: { values: { source: 'cash-token' } }, + }), + ]); + + await expect( + listTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + synchronizerId, + }) + ).resolves.toEqual([ + expect.objectContaining({ + contractId: '#relative-expired-lock', + createdAt: '2026-07-10T01:00:00.000Z', + lock: expect.objectContaining({ context: 'cash-lock' }), + meta: { values: { source: 'cash-token' } }, + }), + ]); + + await expect( + selectTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + synchronizerId, + activeAtOffset: 42, + amountBaseUnits: '5000000', + isSpendable: (holding) => { + if (!holding.lock?.expiresAfter) return false; + const createdAtMicroseconds = BigInt(Date.parse(holding.createdAt)) * 1000n; + const absoluteExpiryMicroseconds = createdAtMicroseconds + BigInt(holding.lock.expiresAfter.microseconds); + const evaluationTimeMicroseconds = BigInt(Date.parse('2026-07-10T02:00:00.000Z')) * 1000n; + return absoluteExpiryMicroseconds <= evaluationTimeMicroseconds; + }, + }) + ).resolves.toMatchObject({ + contractIds: ['#relative-expired-lock'], + totalBaseUnits: '5000000', + }); + }); + + test('preserves special metadata keys without inheriting an attacker-controlled prototype', async () => { + const values = JSON.parse('{"__proto__":"ledger-value","constructor":"ledger-constructor"}') as Record< + string, + string + >; + const ledger = createLedger([ + activeHolding({ + contractId: '#special-metadata', + amount: '1.0', + meta: { values }, + }), + ]); + + const [holding] = await listTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + }); + + expect(Object.getPrototypeOf(holding?.meta.values)).toBeNull(); + expect(holding?.meta.values).toEqual(values); + expect(Object.prototype.hasOwnProperty.call(holding?.meta.values, '__proto__')).toBe(true); + }); + + test.each([ + [ + 'missing', + '#missing-created-at', + withoutCreatedAt(activeHolding({ contractId: '#missing-created-at', amount: '1.0' })), + ], + [ + 'malformed', + '#malformed-created-at', + activeHolding({ + contractId: '#malformed-created-at', + amount: '1.0', + createdAt: '2026-02-31T00:00:00.000Z', + }), + ], + ] as const)('fails closed when a matching active holding has a %s createdAt', async (_case, contractId, holding) => { + const ledger = createLedger([holding]); + + await expect( + listTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + synchronizerId, + }) + ).rejects.toMatchObject({ + code: 'TOKEN_STANDARD_V1_HOLDING_INTERFACE_VIEW_INVALID', + context: { contractId }, + }); + }); + + test('skips non-active ACS variants and rejects a missing lock field', async () => { + const ledger = createLedger([ + { contractEntry: { JsEmpty: {} } }, + activeHolding({ contractId: '#match', amount: '1.0' }), + ]); + await expect( + listTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + }) + ).resolves.toEqual([expect.objectContaining({ contractId: '#match', lock: null, owner })]); + + const malformedLedger = createLedger([ + activeHolding({ + contractId: '#missing-lock', + amount: '1.0', + omitLock: true, + }), + ]); + await expect( + listTokenStandardV1Holdings({ + ledger: malformedLedger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + }) + ).rejects.toBeInstanceOf(TokenStandardV1HoldingError); + }); + + test.each([ + [ + 'lock', + activeHolding({ contractId: '#duplicate', amount: '1.0' }), + activeHolding({ + contractId: '#duplicate', + amount: '1.0', + lock: { + holders: ['CashAdmin::issuer'], + expiresAt: null, + expiresAfter: null, + context: 'settlement', + }, + }), + ], + [ + 'metadata', + activeHolding({ contractId: '#duplicate', amount: '1.0', meta: { values: { source: 'first' } } }), + activeHolding({ contractId: '#duplicate', amount: '1.0', meta: { values: { source: 'second' } } }), + ], + ])('rejects duplicate contract rows with inconsistent %s values', async (_field, first, second) => { + const ledger = createLedger([first, second]); + + await expect( + listTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + }) + ).rejects.toMatchObject({ + code: 'TOKEN_STANDARD_V1_HOLDING_INTERFACE_VIEW_INVALID', + context: { contractId: '#duplicate' }, + }); + }); + + test('reports insufficient balances in base units', async () => { + const ledger = createLedger([activeHolding({ contractId: '#one', amount: '1.0' })]); + + await expect( + selectTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + synchronizerId, + activeAtOffset: 42, + amountBaseUnits: '2000000', + }) + ).rejects.toMatchObject({ + code: 'TOKEN_STANDARD_V1_HOLDING_BALANCE_INSUFFICIENT', + context: { + requiredBaseUnits: '2000000', + availableBaseUnits: '1000000', + }, + }); + }); + + test('rejects non-zero precision beyond the instrument decimals', async () => { + const ledger = createLedger([activeHolding({ contractId: '#over-precise', amount: '1.0000001' })]); + + await expect( + listTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + }) + ).rejects.toMatchObject({ + code: 'TOKEN_STANDARD_V1_HOLDING_INTERFACE_VIEW_INVALID', + }); + }); + + test('validates positive base-unit requests before querying Canton', async () => { + const ledger = createLedger([]); + + await expect( + selectTokenStandardV1Holdings({ + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + synchronizerId, + activeAtOffset: 42, + amountBaseUnits: '0', + }) + ).rejects.toMatchObject({ + code: 'TOKEN_STANDARD_V1_HOLDING_INPUT_INVALID', + }); + expect(ledger.getActiveContracts).not.toHaveBeenCalled(); + }); + + test('requires activeAtOffset at compile time and rejects runtime omission before querying Canton', async () => { + const ledger = createLedger([]); + const requestWithoutOffset = { + ledger, + parties: ['Buyer::alice'], + owner, + instrumentId, + instrumentDecimals: 6, + synchronizerId, + amountBaseUnits: '1000000', + }; + + // @ts-expect-error activeAtOffset is required by the high-level selection request. + const selection = selectTokenStandardV1Holdings(requestWithoutOffset); + await expect(selection).rejects.toMatchObject({ + code: 'TOKEN_STANDARD_V1_HOLDING_INPUT_INVALID', + context: { field: 'activeAtOffset' }, + }); + expect(ledger.getActiveContracts).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/token-standard/v1/transfer-factory.test.ts b/test/unit/token-standard/v1/transfer-factory.test.ts new file mode 100644 index 00000000..71e0c8c4 --- /dev/null +++ b/test/unit/token-standard/v1/transfer-factory.test.ts @@ -0,0 +1,187 @@ +import { + buildTokenStandardV1TransferChoiceArgument, + buildTokenStandardV1TransferCommand, + TOKEN_STANDARD_V1_TRANSFER_FACTORY_INTERFACE_ID, + TokenStandardV1Choice, + TokenStandardV1TransferFactoryError, + type BuildTokenStandardV1TransferChoiceArgumentParams, +} from '../../../../src/utils/token-standard'; + +const transferParams: BuildTokenStandardV1TransferChoiceArgumentParams = { + expectedAdmin: 'CashAdmin::issuer', + transfer: { + sender: 'Buyer::alice', + receiver: 'Seller::bob', + amount: '25.50', + instrumentId: { + admin: 'CashAdmin::issuer', + id: 'USD', + }, + requestedAt: '2026-07-10T01:00:00.000Z', + executeBefore: '2026-07-10T02:00:00.000Z', + inputHoldingCids: ['#cash-holding'], + }, +}; + +describe('Token Standard V1 transfer-factory helpers', () => { + test('builds the exact TransferFactory_Transfer choice argument and command', () => { + const choiceArgument = buildTokenStandardV1TransferChoiceArgument(transferParams); + + expect(choiceArgument).toEqual({ + expectedAdmin: 'CashAdmin::issuer', + transfer: { + sender: 'Buyer::alice', + receiver: 'Seller::bob', + amount: '25.50', + instrumentId: { + admin: 'CashAdmin::issuer', + id: 'USD', + }, + requestedAt: '2026-07-10T01:00:00.000Z', + executeBefore: '2026-07-10T02:00:00.000Z', + inputHoldingCids: ['#cash-holding'], + meta: { values: {} }, + }, + extraArgs: { + context: { values: {} }, + meta: { values: {} }, + }, + }); + + expect( + buildTokenStandardV1TransferCommand({ + ...transferParams, + transferFactoryContractId: '#transfer-factory', + }) + ).toEqual({ + ExerciseCommand: { + templateId: TOKEN_STANDARD_V1_TRANSFER_FACTORY_INTERFACE_ID, + contractId: '#transfer-factory', + choice: TokenStandardV1Choice.transfer, + choiceArgument, + }, + }); + expect(TokenStandardV1Choice.transfer).toBe('TransferFactory_Transfer'); + }); + + test('allows empty input holdings and preserves extraArgs plus transfer metadata', () => { + const choiceArgument = buildTokenStandardV1TransferChoiceArgument({ + expectedAdmin: 'CashAdmin::issuer', + transfer: { + ...transferParams.transfer, + inputHoldingCids: [], + meta: { values: { reason: 'self-merge' } }, + }, + extraArgs: { + context: { values: { factoryHint: 'registry' } }, + meta: { values: { source: 'wallet' } }, + }, + }); + + expect(choiceArgument.transfer.inputHoldingCids).toEqual([]); + expect(choiceArgument.transfer.meta).toEqual({ values: { reason: 'self-merge' } }); + expect(choiceArgument.extraArgs).toEqual({ + context: { values: { factoryHint: 'registry' } }, + meta: { values: { source: 'wallet' } }, + }); + expect(Object.getPrototypeOf(choiceArgument.extraArgs.context.values)).toBeNull(); + expect(Object.getPrototypeOf(choiceArgument.extraArgs.meta.values)).toBeNull(); + }); + + test('trims parties and contract ids while preserving opaque instrument identifiers', () => { + const choiceArgument = buildTokenStandardV1TransferChoiceArgument({ + expectedAdmin: ' CashAdmin::issuer ', + transfer: { + sender: ' Buyer::alice ', + receiver: ' Seller::bob ', + amount: ' 10.0 ', + instrumentId: { + admin: ' CashAdmin::issuer ', + id: ' opaque instrument id ', + }, + requestedAt: ' 2026-07-10T01:00:00.000Z ', + executeBefore: ' 2026-07-10T02:00:00.000Z ', + inputHoldingCids: [' #cash-holding '], + }, + }); + + expect(choiceArgument.expectedAdmin).toBe('CashAdmin::issuer'); + expect(choiceArgument.transfer.sender).toBe('Buyer::alice'); + expect(choiceArgument.transfer.receiver).toBe('Seller::bob'); + expect(choiceArgument.transfer.amount).toBe('10.0'); + expect(choiceArgument.transfer.instrumentId).toEqual({ + admin: 'CashAdmin::issuer', + id: ' opaque instrument id ', + }); + expect(choiceArgument.transfer.requestedAt).toBe('2026-07-10T01:00:00.000Z'); + expect(choiceArgument.transfer.executeBefore).toBe('2026-07-10T02:00:00.000Z'); + expect(choiceArgument.transfer.inputHoldingCids).toEqual(['#cash-holding']); + }); + + test('omits unknown caller properties from the ledger choice argument', () => { + const choiceArgument = buildTokenStandardV1TransferChoiceArgument({ + ...transferParams, + callerOnlyField: 'must-not-reach-canton', + transfer: { + ...transferParams.transfer, + callerOnlyTransferField: 'must-not-reach-canton', + }, + } as unknown as BuildTokenStandardV1TransferChoiceArgumentParams); + + expect(choiceArgument).not.toHaveProperty('callerOnlyField'); + expect(choiceArgument.transfer).not.toHaveProperty('callerOnlyTransferField'); + }); + + test('rejects malformed runtime objects with typed input errors', () => { + const malformedParams: readonly unknown[] = [ + null, + undefined, + { ...transferParams, expectedAdmin: '' }, + { ...transferParams, expectedAdmin: ' ' }, + { ...transferParams, transfer: null }, + { ...transferParams, extraArgs: null }, + { ...transferParams, transfer: { ...transferParams.transfer, sender: '' } }, + { ...transferParams, transfer: { ...transferParams.transfer, receiver: 42 } }, + { ...transferParams, transfer: { ...transferParams.transfer, amount: '0' } }, + { ...transferParams, transfer: { ...transferParams.transfer, amount: '-1.0' } }, + { ...transferParams, transfer: { ...transferParams.transfer, amount: 'not-a-decimal' } }, + { ...transferParams, transfer: { ...transferParams.transfer, instrumentId: null } }, + { + ...transferParams, + transfer: { ...transferParams.transfer, instrumentId: { admin: '', id: 'USD' } }, + }, + { ...transferParams, transfer: { ...transferParams.transfer, requestedAt: '' } }, + { ...transferParams, transfer: { ...transferParams.transfer, executeBefore: '' } }, + { ...transferParams, transfer: { ...transferParams.transfer, inputHoldingCids: 'cid' } }, + { ...transferParams, transfer: { ...transferParams.transfer, inputHoldingCids: [''] } }, + { ...transferParams, transfer: { ...transferParams.transfer, meta: null } }, + { + ...transferParams, + extraArgs: { context: { values: {} } }, + }, + ]; + + for (const value of malformedParams) { + let error: unknown; + try { + buildTokenStandardV1TransferChoiceArgument(value as BuildTokenStandardV1TransferChoiceArgumentParams); + } catch (caught) { + error = caught; + } + expect(error).toMatchObject({ + name: 'TokenStandardV1TransferFactoryError', + code: 'TOKEN_STANDARD_V1_TRANSFER_FACTORY_INPUT_INVALID', + }); + expect(error).toBeInstanceOf(TokenStandardV1TransferFactoryError); + } + }); + + test('rejects a missing transfer factory contract id before building the command', () => { + expect(() => + buildTokenStandardV1TransferCommand({ + ...transferParams, + transferFactoryContractId: ' ', + }) + ).toThrow(TokenStandardV1TransferFactoryError); + }); +});