diff --git a/src/functions/OpenCapTable/capTable/cancellationContractData.ts b/src/functions/OpenCapTable/capTable/cancellationContractData.ts new file mode 100644 index 00000000..889ec590 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/cancellationContractData.ts @@ -0,0 +1,149 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; +import { assertSafeGeneratedDamlJson } from '../../../utils/generatedDamlValidation'; +import { validatePartyId } from '../../../utils/validation'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType } from './batchTypes'; +import { assertLosslessGeneratedDamlRoundTrip } from './damlCodecLosslessness'; + +export type CancellationEntityType = Extract< + OcfEntityType, + 'convertibleCancellation' | 'equityCompensationCancellation' | 'stockCancellation' | 'warrantCancellation' +>; + +export function isCancellationEntityType(entityType: OcfEntityType): entityType is CancellationEntityType { + return ( + entityType === 'convertibleCancellation' || + entityType === 'equityCompensationCancellation' || + entityType === 'stockCancellation' || + entityType === 'warrantCancellation' + ); +} + +interface CancellationCreateArgumentMap { + convertibleCancellation: Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation; + equityCompensationCancellation: Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellation; + stockCancellation: Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation; + warrantCancellation: Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellation; +} + +interface DecoderError { + readonly at: string; + readonly message: string; +} + +interface CancellationCreateArgumentCodec { + readonly decoder: { + run( + input: unknown + ): { readonly ok: true; readonly result: T } | { readonly ok: false; readonly error: DecoderError }; + }; + encode(value: T): unknown; +} + +type CancellationDataFor = + CancellationCreateArgumentMap[EntityType]['cancellation_data']; + +type CancellationCreateArgumentDecoderMap = { + readonly [EntityType in CancellationEntityType]: (createArgument: unknown) => CancellationDataFor; +}; + +function cancellationCreateArgumentError( + entityType: CancellationEntityType, + rootPath: string, + message: string, + context: Readonly> +): OcpParseError { + return new OcpParseError(message, { + source: rootPath, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + ...context, + }, + }); +} + +/** Build one exact, lossless generated-template decoder while retaining its cancellation-family correlation. */ +function createCancellationCreateArgumentDecoder( + entityType: EntityType, + codec: CancellationCreateArgumentCodec +): (createArgument: unknown) => CancellationDataFor { + return (createArgument) => { + const rootPath = `damlToOcf.${entityType}.createArgument`; + const diagnosticContext = { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + } as const; + + // The structural preflight is descriptor-only, bounded, and rejects every + // non-JSON value before generated code can read properties or invoke traps. + assertSafeGeneratedDamlJson(createArgument, rootPath); + + const decoded = codec.decoder.run(createArgument); + if (!decoded.ok) { + const { at: decoderPath, message: decoderMessage } = decoded.error; + throw cancellationCreateArgumentError( + entityType, + rootPath, + `Invalid generated DAML create argument for ${entityType} at ${decoderPath}: ${decoderMessage}`, + { decoderPath, decoderMessage } + ); + } + + let encoded: unknown; + try { + encoded = codec.encode(decoded.result); + } catch (error) { + throw cancellationCreateArgumentError( + entityType, + rootPath, + `Unable to encode generated DAML create argument for ${entityType}: ${toSafeDiagnosticText(error)}`, + { phase: 'encode' } + ); + } + + assertSafeGeneratedDamlJson(encoded, `${rootPath}.__encoded`); + assertLosslessGeneratedDamlRoundTrip(createArgument, encoded, { + rootPath, + description: `${entityType} create argument`, + decodeSource: rootPath, + context: diagnosticContext, + }); + + validatePartyId(decoded.result.context.issuer, `${rootPath}.context.issuer`); + validatePartyId(decoded.result.context.system_operator, `${rootPath}.context.system_operator`); + + return decoded.result.cancellation_data; + }; +} + +/** Generated full-template codecs correlated with each supported cancellation family. */ +const CANCELLATION_CREATE_ARGUMENT_DECODER_MAP = { + convertibleCancellation: createCancellationCreateArgumentDecoder( + 'convertibleCancellation', + Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation + ), + equityCompensationCancellation: createCancellationCreateArgumentDecoder( + 'equityCompensationCancellation', + Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellation + ), + stockCancellation: createCancellationCreateArgumentDecoder( + 'stockCancellation', + Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation + ), + warrantCancellation: createCancellationCreateArgumentDecoder( + 'warrantCancellation', + Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellation + ), +} as const satisfies CancellationCreateArgumentDecoderMap; + +/** Decode the exact generated contract wrapper and return its correlated cancellation payload. */ +export function extractAndDecodeCancellationData( + entityType: EntityType, + createArgument: unknown +): CancellationDataFor { + return CANCELLATION_CREATE_ARGUMENT_DECODER_MAP[entityType](createArgument); +} diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index f0e9ff61..f20ddc75 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -14,6 +14,7 @@ import { type DamlDataTypeFor, type OcfEntityType, } from './batchTypes'; +import { extractAndDecodeCancellationData, isCancellationEntityType } from './cancellationContractData'; import { decodeLosslessGeneratedDamlValue } from './damlCodecLosslessness'; interface EntityDataCodec { @@ -319,5 +320,9 @@ export function extractAndDecodeDamlEntityData( return extractAndDecodeAcceptanceData(entityType, createArgument); } + if (isCancellationEntityType(entityType)) { + return extractAndDecodeCancellationData(entityType, createArgument); + } + return decodeDamlEntityData(entityType, extractEntityData(entityType, createArgument)); } diff --git a/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts b/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts index 506e4bba..94e24dfd 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts @@ -1,14 +1,7 @@ import type { OcfConvertibleCancellation } from '../../../types'; -import { cleanComments, dateStringToDAMLTime, monetaryToDaml, optionalString } from '../../../utils/typeConversions'; +import type { PkgConvertibleCancellationOcfData } from '../../../types/daml'; +import { convertibleCancellationValuesToDaml } from '../shared/cancellationValues'; -export function convertibleCancellationDataToDaml(d: OcfConvertibleCancellation): Record { - return { - id: d.id, - security_id: d.security_id, - amount: monetaryToDaml(d.amount), - reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date, 'convertibleCancellation.date'), - balance_security_id: optionalString(d.balance_security_id), - comments: cleanComments(d.comments), - }; +export function convertibleCancellationDataToDaml(d: OcfConvertibleCancellation): PkgConvertibleCancellationOcfData { + return convertibleCancellationValuesToDaml(d); } diff --git a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts index bc8fc0e7..e28f742c 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts @@ -2,30 +2,12 @@ * DAML to OCF converters for ConvertibleCancellation entities. */ -import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfConvertibleCancellation } from '../../../types'; -import { damlMonetaryToNative, damlTimeToDateString } from '../../../utils/typeConversions'; +import type { PkgConvertibleCancellationOcfData } from '../../../types/daml'; +import { convertibleCancellationValuesFromDaml } from '../shared/cancellationValues'; -/** - * DAML Monetary data structure. - */ -interface DamlMonetary { - amount: string; - currency: string; -} -/** - * DAML ConvertibleCancellation data structure. - * This matches the shape of data returned from DAML contracts. - */ -export interface DamlConvertibleCancellationData { - id: string; - date: string; - security_id: string; - amount?: DamlMonetary; - reason_text: string; - balance_security_id?: string; - comments?: string[]; -} +/** Exact generated DAML input accepted by the convertible-cancellation converter. */ +export type DamlConvertibleCancellationData = PkgConvertibleCancellationOcfData; /** * Convert DAML ConvertibleCancellation data to native OCF format. @@ -34,20 +16,8 @@ export interface DamlConvertibleCancellationData { * @returns The native OCF ConvertibleCancellation object */ export function damlConvertibleCancellationToNative(d: DamlConvertibleCancellationData): OcfConvertibleCancellation { - if (!d.amount) { - throw new OcpValidationError('convertibleCancellation.amount', 'amount is required for ConvertibleCancellation', { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - }); - } - return { + ...convertibleCancellationValuesFromDaml(d), object_type: 'TX_CONVERTIBLE_CANCELLATION', - id: d.id, - date: damlTimeToDateString(d.date, 'convertibleCancellation.date'), - security_id: d.security_id, - amount: damlMonetaryToNative(d.amount), - reason_text: d.reason_text, - ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), }; } diff --git a/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts index 4ddf18b6..86427a46 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts @@ -1,7 +1,8 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfConvertibleCancellation } from '../../../types/native'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlConvertibleCancellationToNative } from './damlToOcf'; @@ -17,13 +18,10 @@ export type OcfConvertibleCancellationEvent = OcfConvertibleCancellation; export type GetConvertibleCancellationAsOcfParams = GetByContractIdParams; export interface GetConvertibleCancellationAsOcfResult { - event: OcfConvertibleCancellationEvent; - contractId: string; + readonly event: OcfConvertibleCancellationEvent; + readonly contractId: string; } -/** Type alias for DAML ConvertibleCancellation contract createArgument */ -type ConvertibleCancellationCreateArgument = Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation; - /** * Get a convertible cancellation contract and convert it to OCF format. * @@ -35,20 +33,11 @@ export async function getConvertibleCancellationAsOcf( client: LedgerJsonApiClient, params: GetConvertibleCancellationAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getConvertibleCancellationAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.convertibleCancellation, }); - const contract = createArgument as ConvertibleCancellationCreateArgument; - const data = contract.cancellation_data; - - const event = damlConvertibleCancellationToNative({ - id: data.id, - date: data.date, - security_id: data.security_id, - amount: data.amount, - ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), - reason_text: data.reason_text, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }); - return { event, contractId: params.contractId }; + const data = extractAndDecodeDamlEntityData('convertibleCancellation', createArgument); + const event = damlConvertibleCancellationToNative(data); + return { event, contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts b/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts index cd4e7da7..c2f50470 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts @@ -1,21 +1,9 @@ import type { OcfEquityCompensationCancellation } from '../../../types'; -import { - cleanComments, - dateStringToDAMLTime, - normalizeNumericString, - optionalString, -} from '../../../utils/typeConversions'; +import type { PkgEquityCompensationCancellationOcfData } from '../../../types/daml'; +import { quantityCancellationValuesToDaml } from '../shared/cancellationValues'; export function equityCompensationCancellationDataToDaml( d: OcfEquityCompensationCancellation -): Record { - return { - id: d.id, - security_id: d.security_id, - reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date, 'equityCompensationCancellation.date'), - quantity: normalizeNumericString(d.quantity), - balance_security_id: optionalString(d.balance_security_id), - comments: cleanComments(d.comments), - }; +): PkgEquityCompensationCancellationOcfData { + return quantityCancellationValuesToDaml(d, 'equityCompensationCancellation', 'TX_EQUITY_COMPENSATION_CANCELLATION'); } diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts index ac047fc0..ef7cf7a1 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts @@ -3,13 +3,14 @@ */ import type { OcfEquityCompensationCancellation } from '../../../types'; -import { type DamlQuantityCancellationData, quantityCancellationToNative } from '../../../utils/typeConversions'; +import type { PkgEquityCompensationCancellationOcfData } from '../../../types/daml'; +import { quantityCancellationValuesFromDaml } from '../shared/cancellationValues'; /** * DAML EquityCompensationCancellation data structure. * This matches the shape of data returned from DAML contracts. */ -export type DamlEquityCompensationCancellationData = DamlQuantityCancellationData; +export type DamlEquityCompensationCancellationData = PkgEquityCompensationCancellationOcfData; /** * Convert DAML EquityCompensationCancellation data to native OCF format. @@ -21,7 +22,7 @@ export function damlEquityCompensationCancellationToNative( d: DamlEquityCompensationCancellationData ): OcfEquityCompensationCancellation { return { - ...quantityCancellationToNative(d, 'equityCompensationCancellation.date'), + ...quantityCancellationValuesFromDaml(d, 'equityCompensationCancellation'), object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', }; } diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts index d07cedb8..66d83b77 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts @@ -1,35 +1,24 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import type { OcfEquityCompensationCancellation } from '../../../types/native'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlEquityCompensationCancellationToNative } from './damlToOcf'; /** * OCF Equity Compensation Cancellation Event with object_type discriminator OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/cancellation/EquityCompensationCancellation.schema.json */ -export interface OcfEquityCompensationCancellationEvent { - object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION'; - id: string; - date: string; - security_id: string; - quantity: string; - balance_security_id?: string; - reason_text: string; - comments?: string[]; -} +export type OcfEquityCompensationCancellationEvent = OcfEquityCompensationCancellation; export type GetEquityCompensationCancellationAsOcfParams = GetByContractIdParams; export interface GetEquityCompensationCancellationAsOcfResult { - event: OcfEquityCompensationCancellationEvent; - contractId: string; + readonly event: OcfEquityCompensationCancellationEvent; + readonly contractId: string; } -/** Type alias for DAML EquityCompensationCancellation contract createArgument */ -type EquityCompensationCancellationCreateArgument = - Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellation; - /** * Get an equity compensation cancellation contract and convert it to OCF format. * @@ -41,25 +30,11 @@ export async function getEquityCompensationCancellationAsOcf( client: LedgerJsonApiClient, params: GetEquityCompensationCancellationAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getEquityCompensationCancellationAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.equityCompensationCancellation, }); - const contract = createArgument as EquityCompensationCancellationCreateArgument; - const data = contract.cancellation_data; - - // Convert quantity to string for normalization (DAML Numeric may come as number at runtime) - const quantity = data.quantity as string | number; - const quantityStr = typeof quantity === 'number' ? quantity.toString() : quantity; - - const event: OcfEquityCompensationCancellationEvent = { - object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', - id: data.id, - date: damlTimeToDateString(data.date, 'equityCompensationCancellation.date'), - security_id: data.security_id, - quantity: normalizeNumericString(quantityStr), - ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), - reason_text: data.reason_text, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; - return { event, contractId: params.contractId }; + const data = extractAndDecodeDamlEntityData('equityCompensationCancellation', createArgument); + const event = damlEquityCompensationCancellationToNative(data); + return { event, contractId }; } diff --git a/src/functions/OpenCapTable/shared/cancellationValues.ts b/src/functions/OpenCapTable/shared/cancellationValues.ts new file mode 100644 index 00000000..98e0619b --- /dev/null +++ b/src/functions/OpenCapTable/shared/cancellationValues.ts @@ -0,0 +1,318 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { Monetary } from '../../../types/native'; +import { damlTimeToDateString, dateStringToDAMLTime, isRecord } from '../../../utils/typeConversions'; +import { + assertCanonicalJsonGraph, + assertExactObjectFields, + requireCurrencyCode, + requireDenseArray, + requirePositiveDecimal, +} from './ocfValues'; + +export type QuantityCancellationEntityType = + | 'stockCancellation' + | 'equityCompensationCancellation' + | 'warrantCancellation'; + +export type QuantityCancellationObjectType = + | 'TX_STOCK_CANCELLATION' + | 'TX_EQUITY_COMPENSATION_CANCELLATION' + | 'TX_WARRANT_CANCELLATION'; + +export interface DamlQuantityCancellationValues { + readonly id: string; + readonly date: string; + readonly security_id: string; + readonly quantity: string; + readonly reason_text: string; + readonly comments: string[]; + readonly balance_security_id: string | null; +} + +export interface NativeQuantityCancellationValues { + readonly id: string; + readonly date: string; + readonly security_id: string; + readonly quantity: string; + readonly reason_text: string; + readonly comments?: string[]; + readonly balance_security_id?: string; +} + +export interface DamlConvertibleCancellationValues { + readonly id: string; + readonly date: string; + readonly security_id: string; + readonly amount: Monetary; + readonly reason_text: string; + readonly comments: string[]; + readonly balance_security_id: string | null; +} + +export interface NativeConvertibleCancellationValues { + readonly id: string; + readonly date: string; + readonly security_id: string; + readonly amount: Monetary; + readonly reason_text: string; + readonly comments?: string[]; + readonly balance_security_id?: string; +} + +const OCF_QUANTITY_CANCELLATION_FIELDS = [ + 'object_type', + 'id', + 'date', + 'security_id', + 'quantity', + 'reason_text', + 'comments', + 'balance_security_id', +] as const; + +const DAML_QUANTITY_CANCELLATION_FIELDS = [ + 'id', + 'date', + 'security_id', + 'quantity', + 'reason_text', + 'comments', + 'balance_security_id', +] as const; + +const OCF_CONVERTIBLE_CANCELLATION_FIELDS = [ + 'object_type', + 'id', + 'date', + 'security_id', + 'amount', + 'reason_text', + 'comments', + 'balance_security_id', +] as const; + +const DAML_CONVERTIBLE_CANCELLATION_FIELDS = [ + 'id', + 'date', + 'security_id', + 'amount', + 'reason_text', + 'comments', + 'balance_security_id', +] as const; + +const MONETARY_FIELDS = ['amount', 'currency'] as const; + +function requiredMissing(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +function invalidFormat(fieldPath: string, expectedType: string, receivedValue: unknown): OcpValidationError { + return new OcpValidationError(fieldPath, `${fieldPath} has an invalid format`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType, + receivedValue, + }); +} + +function requireExactRecord( + value: unknown, + allowedFields: readonly string[], + fieldPath: string, + expectedType: string +): Record { + assertCanonicalJsonGraph(value, fieldPath, { rejectUndefined: true }); + if (!isRecord(value)) throw invalidType(fieldPath, expectedType, value); + assertExactObjectFields(value, allowedFields, fieldPath); + return value; +} + +function requireNonEmptyString(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'non-empty string', value); + if (typeof value !== 'string') throw invalidType(fieldPath, 'non-empty string', value); + if (value.length === 0) throw invalidFormat(fieldPath, 'non-empty string', value); + return value; +} + +function requireDiscriminator(value: unknown, fieldPath: string, expectedValue: string): void { + if (value === null || value === undefined) throw requiredMissing(fieldPath, expectedValue, value); + if (typeof value !== 'string') throw invalidType(fieldPath, expectedValue, value); + if (value !== expectedValue) throw invalidFormat(fieldPath, expectedValue, value); +} + +function requireOcfDateToDaml(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'OCF date string', value); + return dateStringToDAMLTime(value, fieldPath); +} + +function requireDamlTimeToOcfDate(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) throw requiredMissing(fieldPath, 'DAML Time string', value); + return damlTimeToDateString(value, fieldPath); +} + +function requireComments(value: unknown, fieldPath: string, optional: boolean): string[] { + if (value === undefined && optional) return []; + if (value === undefined) throw requiredMissing(fieldPath, 'array of non-empty strings', value); + if (value === null) throw invalidType(fieldPath, 'array of non-empty strings', value); + + return requireDenseArray(value, fieldPath).map((comment, index) => + requireNonEmptyString(comment, `${fieldPath}.${index}`) + ); +} + +function requireOptionalBalanceSecurityId( + value: unknown, + fieldPath: string, + options: { readonly nullIsAbsent: boolean } +): string | undefined { + if (value === undefined) return undefined; + if (value === null && options.nullIsAbsent) return undefined; + if (typeof value !== 'string') { + throw invalidType( + fieldPath, + options.nullIsAbsent ? 'null or non-empty string' : 'non-empty string or omitted', + value + ); + } + if (value.length === 0) { + throw invalidFormat( + fieldPath, + options.nullIsAbsent ? 'null or non-empty string' : 'non-empty string or omitted', + value + ); + } + return value; +} + +function requirePositiveMonetary(value: unknown, fieldPath: string): Monetary { + const monetary = requireExactRecord(value, MONETARY_FIELDS, fieldPath, 'Monetary object'); + return { + amount: requirePositiveDecimal(monetary.amount, `${fieldPath}.amount`), + currency: requireCurrencyCode(monetary.currency, `${fieldPath}.currency`), + }; +} + +/** Validate and encode one exact quantity-based OCF cancellation for DAML. */ +export function quantityCancellationValuesToDaml( + input: unknown, + entityType: QuantityCancellationEntityType, + objectType: QuantityCancellationObjectType +): DamlQuantityCancellationValues { + const data = requireExactRecord( + input, + OCF_QUANTITY_CANCELLATION_FIELDS, + entityType, + `${objectType} cancellation object` + ); + requireDiscriminator(data.object_type, `${entityType}.object_type`, objectType); + const balanceSecurityId = requireOptionalBalanceSecurityId( + data.balance_security_id, + `${entityType}.balance_security_id`, + { nullIsAbsent: false } + ); + + return { + id: requireNonEmptyString(data.id, `${entityType}.id`), + date: requireOcfDateToDaml(data.date, `${entityType}.date`), + security_id: requireNonEmptyString(data.security_id, `${entityType}.security_id`), + quantity: requirePositiveDecimal(data.quantity, `${entityType}.quantity`), + reason_text: requireNonEmptyString(data.reason_text, `${entityType}.reason_text`), + comments: requireComments(data.comments, `${entityType}.comments`, true), + balance_security_id: balanceSecurityId ?? null, + }; +} + +/** Validate and decode one exact generated quantity-based DAML cancellation. */ +export function quantityCancellationValuesFromDaml( + input: unknown, + entityType: QuantityCancellationEntityType +): NativeQuantityCancellationValues { + const data = requireExactRecord( + input, + DAML_QUANTITY_CANCELLATION_FIELDS, + entityType, + 'generated quantity cancellation data' + ); + const comments = requireComments(data.comments, `${entityType}.comments`, false); + const balanceSecurityId = requireOptionalBalanceSecurityId( + data.balance_security_id, + `${entityType}.balance_security_id`, + { nullIsAbsent: true } + ); + + return { + id: requireNonEmptyString(data.id, `${entityType}.id`), + date: requireDamlTimeToOcfDate(data.date, `${entityType}.date`), + security_id: requireNonEmptyString(data.security_id, `${entityType}.security_id`), + quantity: requirePositiveDecimal(data.quantity, `${entityType}.quantity`), + reason_text: requireNonEmptyString(data.reason_text, `${entityType}.reason_text`), + ...(comments.length > 0 ? { comments } : {}), + ...(balanceSecurityId !== undefined ? { balance_security_id: balanceSecurityId } : {}), + }; +} + +/** Validate and encode one exact OCF convertible cancellation for DAML. */ +export function convertibleCancellationValuesToDaml(input: unknown): DamlConvertibleCancellationValues { + const entityType = 'convertibleCancellation'; + const data = requireExactRecord( + input, + OCF_CONVERTIBLE_CANCELLATION_FIELDS, + entityType, + 'TX_CONVERTIBLE_CANCELLATION cancellation object' + ); + requireDiscriminator(data.object_type, `${entityType}.object_type`, 'TX_CONVERTIBLE_CANCELLATION'); + const balanceSecurityId = requireOptionalBalanceSecurityId( + data.balance_security_id, + `${entityType}.balance_security_id`, + { nullIsAbsent: false } + ); + + return { + id: requireNonEmptyString(data.id, `${entityType}.id`), + date: requireOcfDateToDaml(data.date, `${entityType}.date`), + security_id: requireNonEmptyString(data.security_id, `${entityType}.security_id`), + amount: requirePositiveMonetary(data.amount, `${entityType}.amount`), + reason_text: requireNonEmptyString(data.reason_text, `${entityType}.reason_text`), + comments: requireComments(data.comments, `${entityType}.comments`, true), + balance_security_id: balanceSecurityId ?? null, + }; +} + +/** Validate and decode one exact generated DAML convertible cancellation. */ +export function convertibleCancellationValuesFromDaml(input: unknown): NativeConvertibleCancellationValues { + const entityType = 'convertibleCancellation'; + const data = requireExactRecord( + input, + DAML_CONVERTIBLE_CANCELLATION_FIELDS, + entityType, + 'generated convertible cancellation data' + ); + const comments = requireComments(data.comments, `${entityType}.comments`, false); + const balanceSecurityId = requireOptionalBalanceSecurityId( + data.balance_security_id, + `${entityType}.balance_security_id`, + { nullIsAbsent: true } + ); + + return { + id: requireNonEmptyString(data.id, `${entityType}.id`), + date: requireDamlTimeToOcfDate(data.date, `${entityType}.date`), + security_id: requireNonEmptyString(data.security_id, `${entityType}.security_id`), + amount: requirePositiveMonetary(data.amount, `${entityType}.amount`), + reason_text: requireNonEmptyString(data.reason_text, `${entityType}.reason_text`), + ...(comments.length > 0 ? { comments } : {}), + ...(balanceSecurityId !== undefined ? { balance_security_id: balanceSecurityId } : {}), + }; +} diff --git a/src/functions/OpenCapTable/shared/singleContractRead.ts b/src/functions/OpenCapTable/shared/singleContractRead.ts index 1fc00501..fb68649e 100644 --- a/src/functions/OpenCapTable/shared/singleContractRead.ts +++ b/src/functions/OpenCapTable/shared/singleContractRead.ts @@ -8,7 +8,7 @@ import { findUnsafeJsonIssue } from '../../../utils/safeJson'; import { assertTemplateIdentity, type ParsedTemplateIdentity } from '../../../utils/templateIdentity'; export interface LedgerCreatedEvent { - contractId?: string; + contractId?: unknown; templateId?: unknown; packageName?: unknown; createArgument?: unknown; diff --git a/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts b/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts index f9e18928..b4d65e4a 100644 --- a/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts +++ b/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts @@ -1,19 +1,7 @@ import type { OcfStockCancellation } from '../../../types'; -import { - cleanComments, - dateStringToDAMLTime, - normalizeNumericString, - optionalString, -} from '../../../utils/typeConversions'; +import type { PkgStockCancellationOcfData } from '../../../types/daml'; +import { quantityCancellationValuesToDaml } from '../shared/cancellationValues'; -export function stockCancellationDataToDaml(d: OcfStockCancellation): Record { - return { - id: d.id, - security_id: d.security_id, - reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date, 'stockCancellation.date'), - quantity: normalizeNumericString(d.quantity), - balance_security_id: optionalString(d.balance_security_id), - comments: cleanComments(d.comments), - }; +export function stockCancellationDataToDaml(d: OcfStockCancellation): PkgStockCancellationOcfData { + return quantityCancellationValuesToDaml(d, 'stockCancellation', 'TX_STOCK_CANCELLATION'); } diff --git a/src/functions/OpenCapTable/stockCancellation/damlToOcf.ts b/src/functions/OpenCapTable/stockCancellation/damlToOcf.ts index 71cce568..e8aa1112 100644 --- a/src/functions/OpenCapTable/stockCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockCancellation/damlToOcf.ts @@ -3,13 +3,14 @@ */ import type { OcfStockCancellation } from '../../../types'; -import { type DamlQuantityCancellationData, quantityCancellationToNative } from '../../../utils/typeConversions'; +import type { PkgStockCancellationOcfData } from '../../../types/daml'; +import { quantityCancellationValuesFromDaml } from '../shared/cancellationValues'; /** * DAML StockCancellation data structure. * This matches the shape of data returned from DAML contracts. */ -export type DamlStockCancellationData = DamlQuantityCancellationData; +export type DamlStockCancellationData = PkgStockCancellationOcfData; /** * Convert DAML StockCancellation data to native OCF format. @@ -19,7 +20,7 @@ export type DamlStockCancellationData = DamlQuantityCancellationData; */ export function damlStockCancellationToNative(d: DamlStockCancellationData): OcfStockCancellation { return { - ...quantityCancellationToNative(d, 'stockCancellation.date'), + ...quantityCancellationValuesFromDaml(d, 'stockCancellation'), object_type: 'TX_STOCK_CANCELLATION', }; } diff --git a/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts b/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts index 60f8b933..81ddf71b 100644 --- a/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts @@ -1,52 +1,28 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import type { OcfStockCancellation } from '../../../types/native'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockCancellationToNative } from './damlToOcf'; -export interface OcfStockCancellationEvent { - object_type: 'TX_STOCK_CANCELLATION'; - id: string; - date: string; - security_id: string; - quantity: string; - balance_security_id?: string; - reason_text: string; - comments?: string[]; -} +export type OcfStockCancellationEvent = OcfStockCancellation; export type GetStockCancellationAsOcfParams = GetByContractIdParams; export interface GetStockCancellationAsOcfResult { - event: OcfStockCancellationEvent; - contractId: string; + readonly event: OcfStockCancellationEvent; + readonly contractId: string; } -/** Type alias for DAML StockCancellation contract createArgument */ -type StockCancellationCreateArgument = Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation; - export async function getStockCancellationAsOcf( client: LedgerJsonApiClient, params: GetStockCancellationAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockCancellationAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockCancellation, }); - const contract = createArgument as StockCancellationCreateArgument; - const data = contract.cancellation_data; - - // Convert quantity to string for normalization (DAML Numeric may come as number at runtime) - const quantity = data.quantity as string | number; - const quantityStr = typeof quantity === 'number' ? quantity.toString() : quantity; - - const event: OcfStockCancellationEvent = { - object_type: 'TX_STOCK_CANCELLATION', - id: data.id, - date: damlTimeToDateString(data.date, 'stockCancellation.date'), - security_id: data.security_id, - quantity: normalizeNumericString(quantityStr), - ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), - reason_text: data.reason_text, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; - return { event, contractId: params.contractId }; + const data = extractAndDecodeDamlEntityData('stockCancellation', createArgument); + const event = damlStockCancellationToNative(data); + return { event, contractId }; } diff --git a/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts b/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts index 53ccb05d..a1c07e82 100644 --- a/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts +++ b/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts @@ -1,19 +1,7 @@ import type { OcfWarrantCancellation } from '../../../types'; -import { - cleanComments, - dateStringToDAMLTime, - normalizeNumericString, - optionalString, -} from '../../../utils/typeConversions'; +import type { PkgWarrantCancellationOcfData } from '../../../types/daml'; +import { quantityCancellationValuesToDaml } from '../shared/cancellationValues'; -export function warrantCancellationDataToDaml(d: OcfWarrantCancellation): Record { - return { - id: d.id, - security_id: d.security_id, - reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date, 'warrantCancellation.date'), - quantity: normalizeNumericString(d.quantity), - balance_security_id: optionalString(d.balance_security_id), - comments: cleanComments(d.comments), - }; +export function warrantCancellationDataToDaml(d: OcfWarrantCancellation): PkgWarrantCancellationOcfData { + return quantityCancellationValuesToDaml(d, 'warrantCancellation', 'TX_WARRANT_CANCELLATION'); } diff --git a/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts b/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts index 5f7a70a1..e4d60514 100644 --- a/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts @@ -3,13 +3,14 @@ */ import type { OcfWarrantCancellation } from '../../../types'; -import { type DamlQuantityCancellationData, quantityCancellationToNative } from '../../../utils/typeConversions'; +import type { PkgWarrantCancellationOcfData } from '../../../types/daml'; +import { quantityCancellationValuesFromDaml } from '../shared/cancellationValues'; /** * DAML WarrantCancellation data structure. * This matches the shape of data returned from DAML contracts. */ -export type DamlWarrantCancellationData = DamlQuantityCancellationData; +export type DamlWarrantCancellationData = PkgWarrantCancellationOcfData; /** * Convert DAML WarrantCancellation data to native OCF format. @@ -19,7 +20,7 @@ export type DamlWarrantCancellationData = DamlQuantityCancellationData; */ export function damlWarrantCancellationToNative(d: DamlWarrantCancellationData): OcfWarrantCancellation { return { - ...quantityCancellationToNative(d, 'warrantCancellation.date'), + ...quantityCancellationValuesFromDaml(d, 'warrantCancellation'), object_type: 'TX_WARRANT_CANCELLATION', }; } diff --git a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts index cfd9e48e..f00eb54b 100644 --- a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts @@ -1,34 +1,24 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { type Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import type { OcfWarrantCancellation } from '../../../types/native'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlWarrantCancellationToNative } from './damlToOcf'; /** * OCF Warrant Cancellation Event with object_type discriminator OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/cancellation/WarrantCancellation.schema.json */ -export interface OcfWarrantCancellationEvent { - object_type: 'TX_WARRANT_CANCELLATION'; - id: string; - date: string; - security_id: string; - quantity: string; - balance_security_id?: string; - reason_text: string; - comments?: string[]; -} +export type OcfWarrantCancellationEvent = OcfWarrantCancellation; export type GetWarrantCancellationAsOcfParams = GetByContractIdParams; export interface GetWarrantCancellationAsOcfResult { - event: OcfWarrantCancellationEvent; - contractId: string; + readonly event: OcfWarrantCancellationEvent; + readonly contractId: string; } -/** Type alias for DAML WarrantCancellation contract createArgument */ -type WarrantCancellationCreateArgument = Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellation; - /** * Get a warrant cancellation contract and convert it to OCF format. * @@ -40,25 +30,11 @@ export async function getWarrantCancellationAsOcf( client: LedgerJsonApiClient, params: GetWarrantCancellationAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getWarrantCancellationAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.warrantCancellation, }); - const contract = createArgument as WarrantCancellationCreateArgument; - const data = contract.cancellation_data; - - // Convert quantity to string for normalization (DAML Numeric may come as number at runtime) - const quantity = data.quantity as string | number; - const quantityStr = typeof quantity === 'number' ? quantity.toString() : quantity; - - const event: OcfWarrantCancellationEvent = { - object_type: 'TX_WARRANT_CANCELLATION', - id: data.id, - date: damlTimeToDateString(data.date, 'warrantCancellation.date'), - security_id: data.security_id, - quantity: normalizeNumericString(quantityStr), - ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), - reason_text: data.reason_text, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; - return { event, contractId: params.contractId }; + const data = extractAndDecodeDamlEntityData('warrantCancellation', createArgument); + const event = damlWarrantCancellationToNative(data); + return { event, contractId }; } diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index db9bc292..6813952f 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -883,54 +883,3 @@ export function quantityTransferToNative( ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), }; } - -/** - * Common DAML data structure for quantity-based cancellations (Stock, Warrant, EquityCompensation). - * All three share the same field structure for the cancellation operation. - */ -export interface DamlQuantityCancellationData { - id: string; - date: string; - security_id: string; - quantity: string; - reason_text: string; - balance_security_id?: string; - comments?: string[]; -} - -/** - * Common native output structure for quantity-based cancellations. - * The entity-specific converters will assert the correct return type. - */ -export interface NativeQuantityCancellationData { - id: string; - date: string; - security_id: string; - quantity: string; - reason_text: string; - balance_security_id?: string; - comments?: string[]; -} - -/** - * Convert DAML quantity-based cancellation data to native OCF format. - * Used by Stock, Warrant, and EquityCompensation cancellation converters. - * - * @param d - The DAML cancellation data object - * @param dateFieldPath - Contextual path for date validation failures - * @returns The native cancellation object (without object_type) - */ -export function quantityCancellationToNative( - d: DamlQuantityCancellationData, - dateFieldPath: string -): NativeQuantityCancellationData { - return { - id: d.id, - date: damlTimeToDateString(d.date, dateFieldPath), - security_id: d.security_id, - quantity: normalizeNumericString(d.quantity), - reason_text: d.reason_text, - ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), - }; -} diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index e7e2def0..8ccc1238 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -366,6 +366,41 @@ describe('damlToOcf dispatcher', () => { }); }); + it('enforces the full generated wrapper for generic cancellation reads', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: 'cancellation-cid', + templateId: Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation.templateId, + createArgument: { + cancellation_data: { + id: 'cancellation-1', + date: '2025-01-01T00:00:00Z', + quantity: '1', + reason_text: 'Cancelled', + security_id: 'security-1', + comments: [], + balance_security_id: null, + }, + }, + }, + }, + }); + const mockClient = { getEventsByContractId } as unknown as LedgerJsonApiClient; + + await expect(getEntityAsOcf(mockClient, 'stockCancellation', 'cancellation-cid')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + source: 'damlToOcf.stockCancellation.createArgument', + context: { + entityType: 'stockCancellation', + decoderPath: 'input', + decoderMessage: expect.stringContaining("key 'context' is required"), + }, + }); + }); + it('rejects a contract whose generated decoder would erase a present optional', async () => { const getEventsByContractId = jest.fn().mockResolvedValue( buildCreatedEventsResponse( diff --git a/test/converters/convertibleCancellationConverters.test.ts b/test/converters/convertibleCancellationConverters.test.ts index 2e65af52..e89692a1 100644 --- a/test/converters/convertibleCancellationConverters.test.ts +++ b/test/converters/convertibleCancellationConverters.test.ts @@ -1,5 +1,34 @@ -import { OcpValidationError } from '../../src/errors'; -import { damlConvertibleCancellationToNative } from '../../src/functions/OpenCapTable/convertibleCancellation'; +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { ENTITY_TEMPLATE_ID_MAP } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { + type DamlConvertibleCancellationData, + damlConvertibleCancellationToNative, + getConvertibleCancellationAsOcf, +} from '../../src/functions/OpenCapTable/convertibleCancellation'; + +function createMockClient( + createArgument: Record, + contractId = 'convertible-cancellation-contract-1' +): LedgerJsonApiClient { + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId, + templateId: ENTITY_TEMPLATE_ID_MAP.convertibleCancellation, + createArgument: { + context: { + issuer: 'issuer::party', + system_operator: 'system-operator::party', + }, + ...createArgument, + }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} describe('Convertible cancellation converters', () => { test('converts DAML data to a canonical cancellation event', () => { @@ -32,8 +61,69 @@ describe('Convertible cancellation converters', () => { date: '2026-07-09T00:00:00.000Z', security_id: 'convertible-security-2', reason_text: 'Missing amount', + balance_security_id: null, comments: [], - }) + } as unknown as DamlConvertibleCancellationData) ).toThrow(OcpValidationError); }); + + test('the dedicated getter returns the canonical monetary amount', async () => { + const client = createMockClient({ + cancellation_data: { + id: 'convertible-cancellation-1', + date: '2026-07-09T00:00:00.000Z', + security_id: 'convertible-security-1', + amount: { amount: '1250.5000000000', currency: 'USD' }, + balance_security_id: 'convertible-security-balance-1', + reason_text: 'Partial repayment', + comments: ['Board approved'], + }, + }); + + const result = await getConvertibleCancellationAsOcf(client, { + contractId: 'convertible-cancellation-contract-1', + }); + + expect(result).toEqual({ + contractId: 'convertible-cancellation-contract-1', + event: { + object_type: 'TX_CONVERTIBLE_CANCELLATION', + id: 'convertible-cancellation-1', + date: '2026-07-09', + security_id: 'convertible-security-1', + amount: { amount: '1250.5', currency: 'USD' }, + balance_security_id: 'convertible-security-balance-1', + reason_text: 'Partial repayment', + comments: ['Board approved'], + }, + }); + }); + + test('the dedicated getter rejects a cancellation without an amount', async () => { + const client = createMockClient( + { + cancellation_data: { + id: 'convertible-cancellation-2', + date: '2026-07-09T00:00:00.000Z', + security_id: 'convertible-security-2', + reason_text: 'Missing amount', + balance_security_id: null, + comments: [], + }, + }, + 'convertible-cancellation-contract-2' + ); + + await expect( + getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-contract-2' }) + ).rejects.toMatchObject({ + name: OcpParseError.name, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: 'convertibleCancellation', + decoderPath: expect.any(String), + decoderMessage: expect.stringContaining('amount'), + }, + }); + }); }); diff --git a/test/declarations/cancellationReaders.types.ts b/test/declarations/cancellationReaders.types.ts new file mode 100644 index 00000000..0dc08566 --- /dev/null +++ b/test/declarations/cancellationReaders.types.ts @@ -0,0 +1,104 @@ +/** Built-declaration contracts for exact cancellation-reader result families. */ + +import type { convertibleCancellationDataToDaml } from '../../dist/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation'; +import type { DamlConvertibleCancellationData } from '../../dist/functions/OpenCapTable/convertibleCancellation/damlToOcf'; +import type { GetConvertibleCancellationAsOcfResult } from '../../dist/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; +import type { equityCompensationCancellationDataToDaml } from '../../dist/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation'; +import type { DamlEquityCompensationCancellationData } from '../../dist/functions/OpenCapTable/equityCompensationCancellation/damlToOcf'; +import type { GetEquityCompensationCancellationAsOcfResult } from '../../dist/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf'; +import type { stockCancellationDataToDaml } from '../../dist/functions/OpenCapTable/stockCancellation/createStockCancellation'; +import type { DamlStockCancellationData } from '../../dist/functions/OpenCapTable/stockCancellation/damlToOcf'; +import type { GetStockCancellationAsOcfResult } from '../../dist/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf'; +import type { warrantCancellationDataToDaml } from '../../dist/functions/OpenCapTable/warrantCancellation/createWarrantCancellation'; +import type { DamlWarrantCancellationData } from '../../dist/functions/OpenCapTable/warrantCancellation/damlToOcf'; +import type { GetWarrantCancellationAsOcfResult } from '../../dist/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf'; +import type { + PkgConvertibleCancellationOcfData, + PkgEquityCompensationCancellationOcfData, + PkgStockCancellationOcfData, + PkgWarrantCancellationOcfData, +} from '../../dist/types/daml'; +import type { + OcfConvertibleCancellation, + OcfEquityCompensationCancellation, + OcfStockCancellation, + OcfWarrantCancellation, +} from '../../dist/types/native'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type StockEvent = GetStockCancellationAsOcfResult['event']; +type ConvertibleEvent = GetConvertibleCancellationAsOcfResult['event']; +type EquityCompensationEvent = GetEquityCompensationCancellationAsOcfResult['event']; +type WarrantEvent = GetWarrantCancellationAsOcfResult['event']; + +const stockIsExact: Assert> = true; +const convertibleIsExact: Assert> = true; +const equityCompensationIsExact: Assert> = true; +const warrantIsExact: Assert> = true; +const stockIsNotAny: Assert, false>> = true; +const convertibleIsNotAny: Assert, false>> = true; +const equityCompensationIsNotAny: Assert, false>> = true; +const warrantIsNotAny: Assert, false>> = true; +const stockDamlInputIsGenerated: Assert> = true; +const convertibleDamlInputIsGenerated: Assert< + IsExactly +> = true; +const equityDamlInputIsGenerated: Assert< + IsExactly +> = true; +const warrantDamlInputIsGenerated: Assert> = true; +const stockWriterIsGenerated: Assert< + IsExactly, PkgStockCancellationOcfData> +> = true; +const convertibleWriterIsGenerated: Assert< + IsExactly, PkgConvertibleCancellationOcfData> +> = true; +const equityWriterIsGenerated: Assert< + IsExactly, PkgEquityCompensationCancellationOcfData> +> = true; +const warrantWriterIsGenerated: Assert< + IsExactly, PkgWarrantCancellationOcfData> +> = true; + +declare const stockResult: GetStockCancellationAsOcfResult; +declare const convertibleResult: GetConvertibleCancellationAsOcfResult; +declare const equityCompensationResult: GetEquityCompensationCancellationAsOcfResult; +declare const warrantResult: GetWarrantCancellationAsOcfResult; + +// @ts-expect-error built reader results are immutable at their top-level boundary +stockResult.contractId = 'different-contract'; +// @ts-expect-error built reader results cannot be reassigned to a different event family +convertibleResult.event = stockResult.event; + +// @ts-expect-error built stock cancellation cannot be used as warrant cancellation +const wrongWarrant: OcfWarrantCancellation = stockResult.event; +// @ts-expect-error built convertible cancellation cannot be used as stock cancellation +const wrongStock: OcfStockCancellation = convertibleResult.event; +// @ts-expect-error built equity-compensation cancellation cannot be used as convertible cancellation +const wrongConvertible: OcfConvertibleCancellation = equityCompensationResult.event; +// @ts-expect-error built warrant cancellation cannot be used as equity-compensation cancellation +const wrongEquityCompensation: OcfEquityCompensationCancellation = warrantResult.event; + +void stockIsExact; +void convertibleIsExact; +void equityCompensationIsExact; +void warrantIsExact; +void stockIsNotAny; +void convertibleIsNotAny; +void equityCompensationIsNotAny; +void warrantIsNotAny; +void stockDamlInputIsGenerated; +void convertibleDamlInputIsGenerated; +void equityDamlInputIsGenerated; +void warrantDamlInputIsGenerated; +void stockWriterIsGenerated; +void convertibleWriterIsGenerated; +void equityWriterIsGenerated; +void warrantWriterIsGenerated; +void wrongWarrant; +void wrongStock; +void wrongConvertible; +void wrongEquityCompensation; diff --git a/test/functions/cancellationReaders.test.ts b/test/functions/cancellationReaders.test.ts new file mode 100644 index 00000000..136bd171 --- /dev/null +++ b/test/functions/cancellationReaders.test.ts @@ -0,0 +1,1224 @@ +/** Exact ledger and conversion contracts shared by all four OCF cancellation families. */ + +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpContractError, OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + type OcfEntityType, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { getEntityAsOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; +import { convertibleCancellationDataToDaml } from '../../src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation'; +import { damlConvertibleCancellationToNative } from '../../src/functions/OpenCapTable/convertibleCancellation/damlToOcf'; +import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; +import { equityCompensationCancellationDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation'; +import { damlEquityCompensationCancellationToNative } from '../../src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf'; +import { getEquityCompensationCancellationAsOcf } from '../../src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf'; +import { stockCancellationDataToDaml } from '../../src/functions/OpenCapTable/stockCancellation/createStockCancellation'; +import { damlStockCancellationToNative } from '../../src/functions/OpenCapTable/stockCancellation/damlToOcf'; +import { getStockCancellationAsOcf } from '../../src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf'; +import { warrantCancellationDataToDaml } from '../../src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation'; +import { damlWarrantCancellationToNative } from '../../src/functions/OpenCapTable/warrantCancellation/damlToOcf'; +import { getWarrantCancellationAsOcf } from '../../src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf'; +import type { + PkgConvertibleCancellationOcfData, + PkgEquityCompensationCancellationOcfData, + PkgStockCancellationOcfData, + PkgWarrantCancellationOcfData, +} from '../../src/types/daml'; +import type { + OcfConvertibleCancellation, + OcfEquityCompensationCancellation, + OcfStockCancellation, + OcfWarrantCancellation, +} from '../../src/types/native'; + +type CancellationEntityType = Extract< + OcfEntityType, + 'convertibleCancellation' | 'equityCompensationCancellation' | 'stockCancellation' | 'warrantCancellation' +>; +type CancellationEvent = + | OcfConvertibleCancellation + | OcfEquityCompensationCancellation + | OcfStockCancellation + | OcfWarrantCancellation; + +const VALID_CONTEXT = { + issuer: 'issuer::party', + system_operator: 'system-operator::party', +} as const; + +interface CancellationReaderCase { + readonly entityType: CancellationEntityType; + readonly contractId: string; + readonly numericField: 'amount' | 'quantity'; + /** Literal ledger fixture: deliberately independent from the SDK writer under test. */ + readonly literalData: () => Record; + readonly expectedEvent: CancellationEvent; + readonly writerData: () => unknown; + readonly write: (event: unknown) => unknown; + readonly convert: (data: unknown) => CancellationEvent; + readonly encodeGeneratedWrapper: (data: Record) => unknown; + readonly invoke: ( + client: LedgerJsonApiClient + ) => Promise<{ readonly event: CancellationEvent; readonly contractId: string }>; +} + +const cancellationReaderCases: readonly CancellationReaderCase[] = [ + { + entityType: 'stockCancellation', + contractId: 'stock-cancellation-cid', + numericField: 'quantity', + literalData: () => ({ + id: 'stock-cancellation-1', + date: '2026-07-10T00:00:00.000Z', + security_id: 'stock-security-1', + quantity: '12.5000000000', + reason_text: 'Cancelled', + balance_security_id: null, + comments: ['cancelled'], + }), + expectedEvent: { + object_type: 'TX_STOCK_CANCELLATION', + id: 'stock-cancellation-1', + date: '2026-07-10', + security_id: 'stock-security-1', + quantity: '12.5', + reason_text: 'Cancelled', + comments: ['cancelled'], + }, + writerData: () => + stockCancellationDataToDaml({ + object_type: 'TX_STOCK_CANCELLATION', + id: 'stock-cancellation-1', + date: '2026-07-10', + security_id: 'stock-security-1', + quantity: '12.5000000000', + reason_text: 'Cancelled', + comments: ['cancelled'], + }), + write: (event) => stockCancellationDataToDaml(event as OcfStockCancellation), + convert: (data) => damlStockCancellationToNative(data as PkgStockCancellationOcfData), + encodeGeneratedWrapper: (data) => + Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation.encode({ + context: VALID_CONTEXT, + cancellation_data: data as PkgStockCancellationOcfData, + }), + invoke: async (client) => getStockCancellationAsOcf(client, { contractId: 'stock-cancellation-cid' }), + }, + { + entityType: 'convertibleCancellation', + contractId: 'convertible-cancellation-cid', + numericField: 'amount', + literalData: () => ({ + id: 'convertible-cancellation-1', + date: '2026-07-10T00:00:00.000Z', + security_id: 'convertible-security-1', + amount: { amount: '250.0000000000', currency: 'USD' }, + reason_text: 'Cancelled', + balance_security_id: null, + comments: ['cancelled'], + }), + expectedEvent: { + object_type: 'TX_CONVERTIBLE_CANCELLATION', + id: 'convertible-cancellation-1', + date: '2026-07-10', + security_id: 'convertible-security-1', + amount: { amount: '250', currency: 'USD' }, + reason_text: 'Cancelled', + comments: ['cancelled'], + }, + writerData: () => + convertibleCancellationDataToDaml({ + object_type: 'TX_CONVERTIBLE_CANCELLATION', + id: 'convertible-cancellation-1', + date: '2026-07-10', + security_id: 'convertible-security-1', + amount: { amount: '250.0000000000', currency: 'USD' }, + reason_text: 'Cancelled', + comments: ['cancelled'], + }), + write: (event) => convertibleCancellationDataToDaml(event as OcfConvertibleCancellation), + convert: (data) => damlConvertibleCancellationToNative(data as PkgConvertibleCancellationOcfData), + encodeGeneratedWrapper: (data) => + Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation.encode({ + context: VALID_CONTEXT, + cancellation_data: data as PkgConvertibleCancellationOcfData, + }), + invoke: async (client) => getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-cid' }), + }, + { + entityType: 'equityCompensationCancellation', + contractId: 'equity-compensation-cancellation-cid', + numericField: 'quantity', + literalData: () => ({ + id: 'equity-compensation-cancellation-1', + date: '2026-07-10T00:00:00.000Z', + security_id: 'equity-compensation-security-1', + quantity: '8.0000000000', + reason_text: 'Cancelled', + balance_security_id: null, + comments: ['cancelled'], + }), + expectedEvent: { + object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', + id: 'equity-compensation-cancellation-1', + date: '2026-07-10', + security_id: 'equity-compensation-security-1', + quantity: '8', + reason_text: 'Cancelled', + comments: ['cancelled'], + }, + writerData: () => + equityCompensationCancellationDataToDaml({ + object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', + id: 'equity-compensation-cancellation-1', + date: '2026-07-10', + security_id: 'equity-compensation-security-1', + quantity: '8.0000000000', + reason_text: 'Cancelled', + comments: ['cancelled'], + }), + write: (event) => equityCompensationCancellationDataToDaml(event as OcfEquityCompensationCancellation), + convert: (data) => damlEquityCompensationCancellationToNative(data as PkgEquityCompensationCancellationOcfData), + encodeGeneratedWrapper: (data) => + Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellation.encode({ + context: VALID_CONTEXT, + cancellation_data: data as PkgEquityCompensationCancellationOcfData, + }), + invoke: async (client) => + getEquityCompensationCancellationAsOcf(client, { contractId: 'equity-compensation-cancellation-cid' }), + }, + { + entityType: 'warrantCancellation', + contractId: 'warrant-cancellation-cid', + numericField: 'quantity', + literalData: () => ({ + id: 'warrant-cancellation-1', + date: '2026-07-10T00:00:00.000Z', + security_id: 'warrant-security-1', + quantity: '3.0000000000', + reason_text: 'Cancelled', + balance_security_id: null, + comments: ['cancelled'], + }), + expectedEvent: { + object_type: 'TX_WARRANT_CANCELLATION', + id: 'warrant-cancellation-1', + date: '2026-07-10', + security_id: 'warrant-security-1', + quantity: '3', + reason_text: 'Cancelled', + comments: ['cancelled'], + }, + writerData: () => + warrantCancellationDataToDaml({ + object_type: 'TX_WARRANT_CANCELLATION', + id: 'warrant-cancellation-1', + date: '2026-07-10', + security_id: 'warrant-security-1', + quantity: '3.0000000000', + reason_text: 'Cancelled', + comments: ['cancelled'], + }), + write: (event) => warrantCancellationDataToDaml(event as OcfWarrantCancellation), + convert: (data) => damlWarrantCancellationToNative(data as PkgWarrantCancellationOcfData), + encodeGeneratedWrapper: (data) => + Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellation.encode({ + context: VALID_CONTEXT, + cancellation_data: data as PkgWarrantCancellationOcfData, + }), + invoke: async (client) => getWarrantCancellationAsOcf(client, { contractId: 'warrant-cancellation-cid' }), + }, +]; + +interface MockContractOptions { + readonly eventContractId?: unknown; + readonly includeEventContractId?: boolean; + readonly templateId?: string; + readonly packageName?: string; + readonly createArgument?: unknown; +} + +function createArgument(testCase: CancellationReaderCase, data: unknown): Record { + return { + context: VALID_CONTEXT, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data, + }; +} + +function createMockClient( + testCase: CancellationReaderCase, + data: unknown, + options: MockContractOptions = {} +): LedgerJsonApiClient { + const includeEventContractId = options.includeEventContractId ?? true; + const eventContractId = Object.prototype.hasOwnProperty.call(options, 'eventContractId') + ? options.eventContractId + : testCase.contractId; + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + ...(includeEventContractId ? { contractId: eventContractId } : {}), + templateId: options.templateId ?? ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + ...(options.packageName !== undefined ? { packageName: options.packageName } : {}), + createArgument: options.createArgument ?? createArgument(testCase, data), + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} + +function createArgumentRoot(testCase: CancellationReaderCase): string { + return `damlToOcf.${testCase.entityType}.createArgument`; +} + +function ledgerCreateArgumentRoot(testCase: CancellationReaderCase): string { + return `contract ${testCase.contractId}.eventsResponse.created.createdEvent.createArgument`; +} + +describe('decoder-backed cancellation readers', () => { + it.each(cancellationReaderCases)('$entityType maps to its exact generated template', (testCase) => { + const generatedTemplateId = { + stockCancellation: Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation.templateId, + convertibleCancellation: Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation.templateId, + equityCompensationCancellation: + Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellation.templateId, + warrantCancellation: Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellation.templateId, + }[testCase.entityType]; + + expect(ENTITY_TEMPLATE_ID_MAP[testCase.entityType]).toBe(generatedTemplateId); + expect(ENTITY_DATA_FIELD_MAP[testCase.entityType]).toBe('cancellation_data'); + }); + + it.each(cancellationReaderCases)( + '$entityType returns the exact native result from a literal fixture', + async (testCase) => { + await expect(testCase.invoke(createMockClient(testCase, testCase.literalData()))).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + } + ); + + it.each(cancellationReaderCases)('$entityType has exact dedicated/generic reader parity', async (testCase) => { + const dedicatedClient = createMockClient(testCase, testCase.literalData()); + const genericClient = createMockClient(testCase, testCase.literalData()); + + await expect(testCase.invoke(dedicatedClient)).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + await expect(getEntityAsOcf(genericClient, testCase.entityType, testCase.contractId)).resolves.toEqual({ + data: testCase.expectedEvent, + contractId: testCase.contractId, + }); + }); + + it.each(cancellationReaderCases)( + '$entityType accepts its independently generated template wrapper', + async (testCase) => { + const generatedWrapper = testCase.encodeGeneratedWrapper(testCase.literalData()); + await expect( + testCase.invoke(createMockClient(testCase, testCase.literalData(), { createArgument: generatedWrapper })) + ).resolves.toEqual({ event: testCase.expectedEvent, contractId: testCase.contractId }); + } + ); + + it.each(cancellationReaderCases)( + '$entityType writer emits exact generated data that round-trips', + async (testCase) => { + const writerData = testCase.writerData(); + await expect(testCase.invoke(createMockClient(testCase, writerData))).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + } + ); + + it.each(cancellationReaderCases)('$entityType writer preserves non-empty falsy-looking comments', (testCase) => { + const written = testCase.write({ + ...testCase.expectedEvent, + balance_security_id: `${testCase.entityType}-balance`, + comments: ['0', 'false'], + }) as Record; + + expect(written.balance_security_id).toBe(`${testCase.entityType}-balance`); + expect(written.comments).toEqual(['0', 'false']); + }); + + it.each(cancellationReaderCases)( + '$entityType writer enforces pinned non-empty cancellation text semantics', + (testCase) => { + for (const [field, value, fieldPath] of [ + ['id', '', `${testCase.entityType}.id`], + ['security_id', '', `${testCase.entityType}.security_id`], + ['reason_text', '', `${testCase.entityType}.reason_text`], + ['comments', ['valid', ''], `${testCase.entityType}.comments.1`], + ] as const) { + try { + testCase.write({ ...testCase.expectedEvent, [field]: value }); + throw new Error(`Expected ${testCase.entityType} writer to reject ${field}`); + } catch (error: unknown) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: field === 'comments' ? '' : value, + }); + } + } + } + ); + + it.each(cancellationReaderCases)( + '$entityType direct converter and reader enforce pinned non-empty cancellation text semantics', + async (testCase) => { + for (const [field, value, fieldPath] of [ + ['id', '', `${testCase.entityType}.id`], + ['security_id', '', `${testCase.entityType}.security_id`], + ['reason_text', '', `${testCase.entityType}.reason_text`], + ['comments', ['valid', ''], `${testCase.entityType}.comments.1`], + ] as const) { + const data = { ...testCase.literalData(), [field]: value }; + const expected = { + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: field === 'comments' ? '' : value, + }; + + try { + testCase.convert(data); + throw new Error(`Expected ${testCase.entityType} converter to reject ${field}`); + } catch (error: unknown) { + expect(error).toMatchObject(expected); + } + await expect(testCase.invoke(createMockClient(testCase, data))).rejects.toMatchObject(expected); + } + } + ); + + it.each(cancellationReaderCases)( + '$entityType writer rejects explicit undefined and noncanonical balance IDs', + (testCase) => { + for (const [value, code] of [ + [undefined, OcpErrorCodes.REQUIRED_FIELD_MISSING], + [null, OcpErrorCodes.INVALID_TYPE], + ['', OcpErrorCodes.INVALID_FORMAT], + ] as const) { + try { + testCase.write({ ...testCase.expectedEvent, balance_security_id: value }); + throw new Error(`Expected ${testCase.entityType} writer to reject its balance ID`); + } catch (error: unknown) { + expect(error).toBeInstanceOf(OcpValidationError); + expect(error).toMatchObject({ + code, + fieldPath: `${testCase.entityType}.balance_security_id`, + receivedValue: value, + }); + } + } + } + ); + + it.each(cancellationReaderCases)('$entityType rejects malformed required scalar fields', async (testCase) => { + for (const [field, value] of [ + ['id', 17], + ['date', null], + ['security_id', false], + ['reason_text', {}], + ['comments', null], + ] as const) { + const client = createMockClient(testCase, { ...testCase.literalData(), [field]: value }); + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + source: createArgumentRoot(testCase), + context: { entityType: testCase.entityType, decoderMessage: expect.any(String) }, + }); + } + }); + + it.each(cancellationReaderCases)('$entityType rejects a missing generated context', async (testCase) => { + const wrapper = { cancellation_data: testCase.literalData() }; + await expect( + testCase.invoke(createMockClient(testCase, testCase.literalData(), { createArgument: wrapper })) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + source: createArgumentRoot(testCase), + context: { + entityType: testCase.entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + decoderPath: 'input', + decoderMessage: expect.stringContaining("key 'context' is required"), + }, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects malformed generated context fields', async (testCase) => { + for (const [context, decoderPath] of [ + [{ issuer: 17, system_operator: VALID_CONTEXT.system_operator }, 'input.context.issuer'], + [{ issuer: VALID_CONTEXT.issuer }, 'input.context'], + ] as const) { + const wrapper = { context, cancellation_data: testCase.literalData() }; + await expect( + testCase.invoke(createMockClient(testCase, testCase.literalData(), { createArgument: wrapper })) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + source: createArgumentRoot(testCase), + context: { + entityType: testCase.entityType, + decoderPath, + decoderMessage: expect.any(String), + }, + }); + } + }); + + it.each(cancellationReaderCases)( + '$entityType rejects noncanonical generated context parties at exact paths', + async (testCase) => { + for (const [field, value, code] of [ + ['issuer', '', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['issuer', 'issuer party', OcpErrorCodes.INVALID_FORMAT], + ['system_operator', '', OcpErrorCodes.REQUIRED_FIELD_MISSING], + ['system_operator', ' ', OcpErrorCodes.INVALID_FORMAT], + ] as const) { + const wrapper = { + context: { ...VALID_CONTEXT, [field]: value }, + cancellation_data: testCase.literalData(), + }; + const fieldPath = `${createArgumentRoot(testCase)}.context.${field}`; + const expected = { + name: 'OcpValidationError', + code, + classification: 'validation_error', + fieldPath, + receivedValue: value, + context: { fieldPath }, + }; + + await expect( + testCase.invoke(createMockClient(testCase, testCase.literalData(), { createArgument: wrapper })) + ).rejects.toMatchObject(expected); + await expect( + getEntityAsOcf( + createMockClient(testCase, testCase.literalData(), { createArgument: wrapper }), + testCase.entityType, + testCase.contractId + ) + ).rejects.toMatchObject(expected); + } + } + ); + + it.each(cancellationReaderCases)('$entityType rejects the wrong generated wrapper family', async (testCase) => { + const wrapper = { context: VALID_CONTEXT, acceptance_data: testCase.literalData() }; + await expect( + testCase.invoke(createMockClient(testCase, testCase.literalData(), { createArgument: wrapper })) + ).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + source: createArgumentRoot(testCase), + context: { + entityType: testCase.entityType, + decoderPath: 'input', + decoderMessage: expect.stringContaining("key 'cancellation_data' is required"), + }, + }); + }); + + it.each(cancellationReaderCases)( + '$entityType losslessly rejects unknown wrapper, context, and payload fields', + async (testCase) => { + const data = testCase.literalData(); + const root = createArgumentRoot(testCase); + const cases = [ + [{ context: VALID_CONTEXT, cancellation_data: data, unexpected_wrapper: true }, `${root}.unexpected_wrapper`], + [ + { context: { ...VALID_CONTEXT, unexpected_context: true }, cancellation_data: data }, + `${root}.context.unexpected_context`, + ], + [ + { context: VALID_CONTEXT, cancellation_data: { ...data, unexpected_payload: true } }, + `${root}.cancellation_data.unexpected_payload`, + ], + ] as const; + + for (const [wrapper, expectedSource] of cases) { + const dedicated = createMockClient(testCase, data, { createArgument: wrapper }); + const generic = createMockClient(testCase, data, { createArgument: wrapper }); + const expected = { + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: expectedSource, + context: { + entityType: testCase.entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + fieldPath: expectedSource, + }, + }; + await expect(testCase.invoke(dedicated)).rejects.toMatchObject(expected); + await expect(getEntityAsOcf(generic, testCase.entityType, testCase.contractId)).rejects.toMatchObject(expected); + } + } + ); + + it('losslessly rejects an unknown nested monetary field at its exact path', async () => { + const testCase = cancellationReaderCases[1]; + if (testCase === undefined) throw new Error('Missing convertible cancellation case'); + const data = testCase.literalData(); + data.amount = { ...(data.amount as Record), unexpected_amount: true }; + const source = `${createArgumentRoot(testCase)}.cancellation_data.amount.unexpected_amount`; + + await expect(testCase.invoke(createMockClient(testCase, data))).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source, + context: { fieldPath: source, entityType: 'convertibleCancellation' }, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects explicit undefined optionals', async (testCase) => { + const client = createMockClient(testCase, { + ...testCase.literalData(), + balance_security_id: undefined, + }); + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_daml_json', + source: `${createArgumentRoot(testCase)}.cancellation_data.balance_security_id`, + }); + }); + + it.each(cancellationReaderCases)( + '$entityType accepts null or omitted balance IDs only as absent', + async (testCase) => { + const missing = testCase.literalData(); + delete missing.balance_security_id; + for (const data of [{ ...testCase.literalData(), balance_security_id: null }, missing]) { + await expect(testCase.invoke(createMockClient(testCase, data))).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + } + } + ); + + it.each(cancellationReaderCases)( + '$entityType preserves a non-empty balance ID without truthiness drops', + async (testCase) => { + const balanceSecurityId = `${testCase.entityType}-balance`; + await expect( + testCase.invoke( + createMockClient(testCase, { ...testCase.literalData(), balance_security_id: balanceSecurityId }) + ) + ).resolves.toEqual({ + event: { ...testCase.expectedEvent, balance_security_id: balanceSecurityId }, + contractId: testCase.contractId, + }); + } + ); + + it.each(cancellationReaderCases)('$entityType rejects an empty balance ID as noncanonical', async (testCase) => { + await expect( + testCase.invoke(createMockClient(testCase, { ...testCase.literalData(), balance_security_id: '' })) + ).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.balance_security_id`, + receivedValue: '', + }); + }); + + it.each(cancellationReaderCases)('$entityType distinguishes malformed balance ID types', async (testCase) => { + await expect( + testCase.invoke(createMockClient(testCase, { ...testCase.literalData(), balance_security_id: 17 })) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: `${createArgumentRoot(testCase)}.cancellation_data.balance_security_id`, + context: { + entityType: testCase.entityType, + decoderPath: 'input.cancellation_data.balance_security_id', + }, + }); + }); + + it.each(cancellationReaderCases)( + '$entityType writer, direct converter, and reader enforce positive fixed-point Numeric(10)', + async (testCase) => { + const replaceGeneratedNumeric = (value: unknown): Record => { + const data = testCase.literalData(); + if (testCase.numericField === 'amount') { + data.amount = { amount: value, currency: 'USD' }; + } else { + data.quantity = value; + } + return data; + }; + const replaceOcfNumeric = (value: unknown): Record => { + const event = { ...testCase.expectedEvent } as Record; + if (testCase.numericField === 'amount') { + event.amount = { amount: value, currency: 'USD' }; + } else { + event.quantity = value; + } + return event; + }; + const fieldPath = + testCase.numericField === 'amount' ? `${testCase.entityType}.amount.amount` : `${testCase.entityType}.quantity`; + + await expect(testCase.invoke(createMockClient(testCase, replaceGeneratedNumeric(17)))).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + }); + + for (const [value, code] of [ + ['0', OcpErrorCodes.OUT_OF_RANGE], + ['-0', OcpErrorCodes.OUT_OF_RANGE], + ['-1', OcpErrorCodes.OUT_OF_RANGE], + ['1e3', OcpErrorCodes.INVALID_FORMAT], + ['1.12345678901', OcpErrorCodes.INVALID_FORMAT], + ] as const) { + const expected = { + name: 'OcpValidationError', + code, + fieldPath, + receivedValue: value, + }; + const generatedData = replaceGeneratedNumeric(value); + + try { + testCase.write(replaceOcfNumeric(value)); + throw new Error(`Expected ${testCase.entityType} writer to reject ${value}`); + } catch (error: unknown) { + expect(error).toMatchObject(expected); + } + try { + testCase.convert(generatedData); + throw new Error(`Expected ${testCase.entityType} converter to reject ${value}`); + } catch (error: unknown) { + expect(error).toMatchObject(expected); + } + await expect(testCase.invoke(createMockClient(testCase, generatedData))).rejects.toMatchObject(expected); + } + } + ); + + it('validates exact convertible monetary currency semantics on every conversion boundary', async () => { + const testCase = cancellationReaderCases[1]; + if (testCase === undefined) throw new Error('Missing convertible cancellation case'); + + for (const [currency, code] of [ + ['', OcpErrorCodes.INVALID_FORMAT], + ['US', OcpErrorCodes.INVALID_FORMAT], + ['usd', OcpErrorCodes.INVALID_FORMAT], + [17, OcpErrorCodes.INVALID_TYPE], + ] as const) { + const generatedData = testCase.literalData(); + generatedData.amount = { amount: '1', currency }; + const ocfEvent = { ...testCase.expectedEvent, amount: { amount: '1', currency } }; + const expected = { + name: 'OcpValidationError', + code, + fieldPath: 'convertibleCancellation.amount.currency', + receivedValue: currency, + }; + + try { + testCase.write(ocfEvent); + throw new Error(`Expected writer to reject currency ${String(currency)}`); + } catch (error: unknown) { + expect(error).toMatchObject(expected); + } + try { + testCase.convert(generatedData); + throw new Error(`Expected converter to reject currency ${String(currency)}`); + } catch (error: unknown) { + expect(error).toMatchObject(expected); + } + + if (typeof currency === 'string') { + await expect(testCase.invoke(createMockClient(testCase, generatedData))).rejects.toMatchObject(expected); + } + } + }); + + it.each(cancellationReaderCases)('$entityType validates canonical Numeric(10) semantics', async (testCase) => { + const replaceNumeric = (value: unknown): Record => { + const data = testCase.literalData(); + if (testCase.numericField === 'amount') { + data.amount = { amount: value, currency: 'USD' }; + } else { + data.quantity = value; + } + return data; + }; + const fieldPath = + testCase.numericField === 'amount' ? `${testCase.entityType}.amount.amount` : `${testCase.entityType}.quantity`; + + await expect(testCase.invoke(createMockClient(testCase, replaceNumeric(17)))).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + }); + await expect(testCase.invoke(createMockClient(testCase, replaceNumeric('1.12345678901')))).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath, + receivedValue: '1.12345678901', + }); + await expect(testCase.invoke(createMockClient(testCase, replaceNumeric('-1')))).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.OUT_OF_RANGE, + fieldPath, + receivedValue: '-1', + }); + }); + + it.each(cancellationReaderCases)('$entityType validates dates at the exact family path', async (testCase) => { + await expect( + testCase.invoke(createMockClient(testCase, { ...testCase.literalData(), date: 'not-a-date' })) + ).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.date`, + receivedValue: 'not-a-date', + }); + }); + + it.each(cancellationReaderCases)( + '$entityType preserves non-empty falsy-looking comments and omits only an empty list', + async (testCase) => { + const comments = ['0', 'false']; + await expect( + testCase.invoke(createMockClient(testCase, { ...testCase.literalData(), comments })) + ).resolves.toEqual({ + event: { ...testCase.expectedEvent, comments }, + contractId: testCase.contractId, + }); + const expectedWithoutComments = { ...testCase.expectedEvent }; + delete expectedWithoutComments.comments; + await expect( + testCase.invoke(createMockClient(testCase, { ...testCase.literalData(), comments: [] })) + ).resolves.toEqual({ event: expectedWithoutComments, contractId: testCase.contractId }); + } + ); + + it.each(cancellationReaderCases)( + '$entityType direct converter requires every generated cancellation field except balance_security_id', + (testCase) => { + const numericPath = + testCase.numericField === 'amount' ? `${testCase.entityType}.amount` : `${testCase.entityType}.quantity`; + for (const [field, fieldPath] of [ + ['id', `${testCase.entityType}.id`], + ['date', `${testCase.entityType}.date`], + ['security_id', `${testCase.entityType}.security_id`], + [testCase.numericField, numericPath], + ['reason_text', `${testCase.entityType}.reason_text`], + ['comments', `${testCase.entityType}.comments`], + ] as const) { + const data = testCase.literalData(); + delete data[field]; + try { + testCase.convert(data); + throw new Error(`Expected ${testCase.entityType} converter to require ${field}`); + } catch (error: unknown) { + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath, + }); + } + } + + const withoutBalance = testCase.literalData(); + delete withoutBalance.balance_security_id; + expect(testCase.convert(withoutBalance)).toEqual(testCase.expectedEvent); + } + ); + + it.each(cancellationReaderCases)( + '$entityType writer and direct converter reject malformed roots at the family path', + (testCase) => { + for (const value of [null, [], 'not-a-cancellation'] as const) { + for (const invoke of [testCase.write, testCase.convert]) { + try { + invoke(value); + throw new Error(`Expected ${testCase.entityType} boundary to reject its root`); + } catch (error: unknown) { + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_TYPE, + fieldPath: testCase.entityType, + receivedValue: value, + }); + } + } + } + + const customPrototype = Object.create({ inherited: true }) as Record; + Object.assign(customPrototype, testCase.literalData()); + try { + testCase.convert(customPrototype); + throw new Error(`Expected ${testCase.entityType} converter to reject its prototype`); + } catch (error: unknown) { + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: testCase.entityType, + receivedValue: 'custom prototype', + }); + } + } + ); + + it.each(cancellationReaderCases)( + '$entityType writer and direct converter reject unknown fields at exact paths', + (testCase) => { + for (const [invoke, value] of [ + [testCase.write, { ...testCase.expectedEvent, unexpected: true }], + [testCase.convert, { ...testCase.literalData(), unexpected: true }], + ] as const) { + try { + invoke(value); + throw new Error(`Expected ${testCase.entityType} boundary to reject an unknown field`); + } catch (error: unknown) { + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `${testCase.entityType}.unexpected`, + receivedValue: true, + }); + } + } + } + ); + + it.each(cancellationReaderCases)( + '$entityType writer and direct converter reject accessors without invoking them', + (testCase) => { + for (const [invoke, base] of [ + [testCase.write, testCase.expectedEvent], + [testCase.convert, testCase.literalData()], + ] as const) { + const getter = jest.fn(() => { + throw new Error('direct cancellation accessor must not run'); + }); + const value = { ...base } as Record; + Object.defineProperty(value, 'id', { configurable: true, enumerable: true, get: getter }); + + try { + invoke(value); + throw new Error(`Expected ${testCase.entityType} boundary to reject an accessor`); + } catch (error: unknown) { + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: `${testCase.entityType}.id`, + receivedValue: 'accessor property', + }); + } + expect(getter).not.toHaveBeenCalled(); + } + } + ); + + it.each(cancellationReaderCases)( + '$entityType writer and direct converter reject proxies without invoking traps', + (testCase) => { + for (const [invoke, base] of [ + [testCase.write, testCase.expectedEvent], + [testCase.convert, testCase.literalData()], + ] as const) { + const getTrap = jest.fn(() => { + throw new Error('direct cancellation proxy get trap must not run'); + }); + const ownKeysTrap = jest.fn(() => { + throw new Error('direct cancellation proxy ownKeys trap must not run'); + }); + const value = new Proxy(base, { get: getTrap, ownKeys: ownKeysTrap }); + + try { + invoke(value); + throw new Error(`Expected ${testCase.entityType} boundary to reject a proxy`); + } catch (error: unknown) { + expect(error).toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: testCase.entityType, + receivedValue: 'JavaScript Proxy', + }); + } + expect(getTrap).not.toHaveBeenCalled(); + expect(ownKeysTrap).not.toHaveBeenCalled(); + } + } + ); + + it('rejects malformed nested convertible Monetary objects exactly and without invoking traps', () => { + const testCase = cancellationReaderCases[1]; + if (testCase === undefined) throw new Error('Missing convertible cancellation case'); + + for (const [invoke, base] of [ + [testCase.write, testCase.expectedEvent], + [testCase.convert, testCase.literalData()], + ] as const) { + const extra = { ...base, amount: { amount: '1', currency: 'USD', unexpected: true } }; + expect(() => invoke(extra)).toThrow(OcpValidationError); + try { + invoke(extra); + } catch (error: unknown) { + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleCancellation.amount.unexpected', + receivedValue: true, + }); + } + + const getter = jest.fn(() => { + throw new Error('monetary accessor must not run'); + }); + const accessorAmount: Record = { amount: '1' }; + Object.defineProperty(accessorAmount, 'currency', { configurable: true, enumerable: true, get: getter }); + try { + invoke({ ...base, amount: accessorAmount }); + throw new Error('Expected Monetary accessor to be rejected'); + } catch (error: unknown) { + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleCancellation.amount.currency', + receivedValue: 'accessor property', + }); + } + expect(getter).not.toHaveBeenCalled(); + + const getTrap = jest.fn(() => { + throw new Error('monetary proxy get trap must not run'); + }); + const ownKeysTrap = jest.fn(() => { + throw new Error('monetary proxy ownKeys trap must not run'); + }); + const proxyAmount = new Proxy({ amount: '1', currency: 'USD' }, { get: getTrap, ownKeys: ownKeysTrap }); + try { + invoke({ ...base, amount: proxyAmount }); + throw new Error('Expected Monetary proxy to be rejected'); + } catch (error: unknown) { + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + fieldPath: 'convertibleCancellation.amount', + receivedValue: 'JavaScript Proxy', + }); + } + expect(getTrap).not.toHaveBeenCalled(); + expect(ownKeysTrap).not.toHaveBeenCalled(); + } + }); + + it.each(cancellationReaderCases)('$entityType rejects sparse arrays at the ledger path', async (testCase) => { + const comments = new Array(2); + comments[1] = 'second'; + await expect( + testCase.invoke(createMockClient(testCase, { ...testCase.literalData(), comments })) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: `${ledgerCreateArgumentRoot(testCase)}.cancellation_data.comments[0]`, + context: { contractId: testCase.contractId, issueKind: 'sparse_array' }, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects proxies without invoking traps', async (testCase) => { + const getTrap = jest.fn(() => { + throw new Error('proxy get trap must not run'); + }); + const ownKeysTrap = jest.fn(() => { + throw new Error('proxy ownKeys trap must not run'); + }); + const wrapper = new Proxy(createArgument(testCase, testCase.literalData()), { + get: getTrap, + ownKeys: ownKeysTrap, + }); + + await expect( + testCase.invoke(createMockClient(testCase, testCase.literalData(), { createArgument: wrapper })) + ).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: ledgerCreateArgumentRoot(testCase), + context: { issueKind: 'proxy' }, + }); + expect(getTrap).not.toHaveBeenCalled(); + expect(ownKeysTrap).not.toHaveBeenCalled(); + }); + + it.each(cancellationReaderCases)('$entityType rejects accessors without invoking getters', async (testCase) => { + const getter = jest.fn(() => { + throw new Error('cancellation accessor must not run'); + }); + const data = testCase.literalData(); + Object.defineProperty(data, 'id', { configurable: true, enumerable: true, get: getter }); + + await expect(testCase.invoke(createMockClient(testCase, data))).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: `${ledgerCreateArgumentRoot(testCase)}.cancellation_data.id`, + context: { issueKind: 'accessor' }, + }); + expect(getter).not.toHaveBeenCalled(); + }); + + it.each(cancellationReaderCases)('$entityType rejects cyclic payloads at a bounded exact path', async (testCase) => { + const data = testCase.literalData(); + data.self = data; + await expect(testCase.invoke(createMockClient(testCase, data))).rejects.toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_create_argument_json', + source: `${ledgerCreateArgumentRoot(testCase)}.cancellation_data.self`, + context: { issueKind: 'cycle' }, + }); + }); + + it('bounds hostile cancellation diagnostics and serialized values', async () => { + const testCase = cancellationReaderCases[0]; + if (testCase === undefined) throw new Error('Missing cancellation case'); + const hostileField = `hostile_${'x'.repeat(10_000)}`; + const wrapper = { + context: VALID_CONTEXT, + cancellation_data: testCase.literalData(), + [hostileField]: 'y'.repeat(10_000), + }; + + try { + await testCase.invoke(createMockClient(testCase, testCase.literalData(), { createArgument: wrapper })); + throw new Error('Expected hostile cancellation input to be rejected'); + } catch (error: unknown) { + expect(error).toBeInstanceOf(OcpParseError); + const parseError = error as OcpParseError; + expect(parseError.classification).toBe('invalid_create_argument_json'); + expect(parseError.context).toMatchObject({ issueKind: 'oversized_property_name' }); + expect(parseError.source?.length).toBeLessThanOrEqual(512); + expect(parseError.message.length).toBeLessThanOrEqual(512); + const serialized = JSON.stringify(parseError.toJSON()); + expect(serialized.length).toBeLessThanOrEqual(2_048); + expect(serialized).not.toContain(hostileField); + } + }); + + it.each(cancellationReaderCases)('$entityType correlates a present created-event contract ID', async (testCase) => { + await expect( + testCase.invoke( + createMockClient(testCase, testCase.literalData(), { eventContractId: `${testCase.contractId}-wrong` }) + ) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'created_event_contract_id_mismatch', + source: `contract ${testCase.contractId}.eventsResponse.created.createdEvent.contractId`, + context: { + actualContractId: `${testCase.contractId}-wrong`, + requestedContractId: testCase.contractId, + operation: expect.stringContaining('CancellationAsOcf'), + }, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects malformed created-event contract IDs', async (testCase) => { + for (const eventContractId of [undefined, '', 17] as const) { + await expect( + testCase.invoke(createMockClient(testCase, testCase.literalData(), { eventContractId })) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'invalid_created_event_contract_id', + source: `contract ${testCase.contractId}.eventsResponse.created.createdEvent.contractId`, + context: { + contractId: testCase.contractId, + operation: expect.stringContaining('CancellationAsOcf'), + }, + }); + } + }); + + it.each(cancellationReaderCases)('$entityType rejects an omitted created-event contract ID', async (testCase) => { + await expect( + testCase.invoke(createMockClient(testCase, testCase.literalData(), { includeEventContractId: false })) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'missing_created_event_contract_id', + source: `contract ${testCase.contractId}.eventsResponse.created.createdEvent.contractId`, + context: { + contractId: testCase.contractId, + operation: expect.stringContaining('CancellationAsOcf'), + }, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects a contract from the wrong template', async (testCase) => { + const wrongTemplateId = + testCase.entityType === 'stockCancellation' + ? ENTITY_TEMPLATE_ID_MAP.warrantCancellation + : ENTITY_TEMPLATE_ID_MAP.stockCancellation; + await expect( + testCase.invoke(createMockClient(testCase, testCase.literalData(), { templateId: wrongTemplateId })) + ).rejects.toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'module_entity_mismatch', + contractId: testCase.contractId, + templateId: wrongTemplateId, + context: { + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + actualTemplateId: wrongTemplateId, + }, + }); + }); + + it.each(cancellationReaderCases)( + '$entityType rejects the right module on the wrong package line', + async (testCase) => { + const expectedTemplateId = ENTITY_TEMPLATE_ID_MAP[testCase.entityType]; + const wrongTemplateId = expectedTemplateId.replace(/^#[^:]+/, '#OpenCapTable-wrong'); + await expect( + testCase.invoke( + createMockClient(testCase, testCase.literalData(), { + templateId: wrongTemplateId, + packageName: 'OpenCapTable-wrong', + }) + ) + ).rejects.toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'package_name_mismatch', + contractId: testCase.contractId, + templateId: wrongTemplateId, + context: { + expectedTemplateId, + actualTemplateId: wrongTemplateId, + actualPackageName: 'OpenCapTable-wrong', + }, + }); + } + ); + + it('uses the public structured error classes for cancellation failures', () => { + expect(OcpContractError.prototype).toBeInstanceOf(Error); + expect(OcpParseError.prototype).toBeInstanceOf(Error); + expect(OcpValidationError.prototype).toBeInstanceOf(Error); + }); +}); diff --git a/test/types/cancellationReaders.types.ts b/test/types/cancellationReaders.types.ts new file mode 100644 index 00000000..7c20f913 --- /dev/null +++ b/test/types/cancellationReaders.types.ts @@ -0,0 +1,104 @@ +/** Compile-time contracts for exact cancellation-reader result families. */ + +import type { convertibleCancellationDataToDaml } from '../../src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation'; +import type { DamlConvertibleCancellationData } from '../../src/functions/OpenCapTable/convertibleCancellation/damlToOcf'; +import type { GetConvertibleCancellationAsOcfResult } from '../../src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; +import type { equityCompensationCancellationDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation'; +import type { DamlEquityCompensationCancellationData } from '../../src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf'; +import type { GetEquityCompensationCancellationAsOcfResult } from '../../src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf'; +import type { stockCancellationDataToDaml } from '../../src/functions/OpenCapTable/stockCancellation/createStockCancellation'; +import type { DamlStockCancellationData } from '../../src/functions/OpenCapTable/stockCancellation/damlToOcf'; +import type { GetStockCancellationAsOcfResult } from '../../src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf'; +import type { warrantCancellationDataToDaml } from '../../src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation'; +import type { DamlWarrantCancellationData } from '../../src/functions/OpenCapTable/warrantCancellation/damlToOcf'; +import type { GetWarrantCancellationAsOcfResult } from '../../src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf'; +import type { + PkgConvertibleCancellationOcfData, + PkgEquityCompensationCancellationOcfData, + PkgStockCancellationOcfData, + PkgWarrantCancellationOcfData, +} from '../../src/types/daml'; +import type { + OcfConvertibleCancellation, + OcfEquityCompensationCancellation, + OcfStockCancellation, + OcfWarrantCancellation, +} from '../../src/types/native'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +type IsExactly = [A] extends [B] ? ([B] extends [A] ? true : false) : false; + +type StockEvent = GetStockCancellationAsOcfResult['event']; +type ConvertibleEvent = GetConvertibleCancellationAsOcfResult['event']; +type EquityCompensationEvent = GetEquityCompensationCancellationAsOcfResult['event']; +type WarrantEvent = GetWarrantCancellationAsOcfResult['event']; + +const stockIsExact: Assert> = true; +const convertibleIsExact: Assert> = true; +const equityCompensationIsExact: Assert> = true; +const warrantIsExact: Assert> = true; +const stockIsNotAny: Assert, false>> = true; +const convertibleIsNotAny: Assert, false>> = true; +const equityCompensationIsNotAny: Assert, false>> = true; +const warrantIsNotAny: Assert, false>> = true; +const stockDamlInputIsGenerated: Assert> = true; +const convertibleDamlInputIsGenerated: Assert< + IsExactly +> = true; +const equityDamlInputIsGenerated: Assert< + IsExactly +> = true; +const warrantDamlInputIsGenerated: Assert> = true; +const stockWriterIsGenerated: Assert< + IsExactly, PkgStockCancellationOcfData> +> = true; +const convertibleWriterIsGenerated: Assert< + IsExactly, PkgConvertibleCancellationOcfData> +> = true; +const equityWriterIsGenerated: Assert< + IsExactly, PkgEquityCompensationCancellationOcfData> +> = true; +const warrantWriterIsGenerated: Assert< + IsExactly, PkgWarrantCancellationOcfData> +> = true; + +declare const stockResult: GetStockCancellationAsOcfResult; +declare const convertibleResult: GetConvertibleCancellationAsOcfResult; +declare const equityCompensationResult: GetEquityCompensationCancellationAsOcfResult; +declare const warrantResult: GetWarrantCancellationAsOcfResult; + +// @ts-expect-error exact reader results are immutable at their top-level boundary +stockResult.contractId = 'different-contract'; +// @ts-expect-error exact reader results cannot be reassigned to a different event family +convertibleResult.event = stockResult.event; + +// @ts-expect-error stock cancellation cannot be used as warrant cancellation +const wrongWarrant: OcfWarrantCancellation = stockResult.event; +// @ts-expect-error convertible cancellation cannot be used as stock cancellation +const wrongStock: OcfStockCancellation = convertibleResult.event; +// @ts-expect-error equity-compensation cancellation cannot be used as convertible cancellation +const wrongConvertible: OcfConvertibleCancellation = equityCompensationResult.event; +// @ts-expect-error warrant cancellation cannot be used as equity-compensation cancellation +const wrongEquityCompensation: OcfEquityCompensationCancellation = warrantResult.event; + +void stockIsExact; +void convertibleIsExact; +void equityCompensationIsExact; +void warrantIsExact; +void stockIsNotAny; +void convertibleIsNotAny; +void equityCompensationIsNotAny; +void warrantIsNotAny; +void stockDamlInputIsGenerated; +void convertibleDamlInputIsGenerated; +void equityDamlInputIsGenerated; +void warrantDamlInputIsGenerated; +void stockWriterIsGenerated; +void convertibleWriterIsGenerated; +void equityWriterIsGenerated; +void warrantWriterIsGenerated; +void wrongWarrant; +void wrongStock; +void wrongConvertible; +void wrongEquityCompensation; diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 552e729c..12834395 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -28,6 +28,7 @@ import { validateOcfObject } from '../utils/ocfSchemaValidator'; /** Ledger template ids for mocks — must match `readSingleContract` `expectedTemplateId` on each getter. */ const MOCK_LEDGER_TEMPLATE_IDS = { + convertibleCancellation: Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation.templateId, equityCompensationIssuance: Fairmint.OpenCapTable.OCF.EquityCompensationIssuance.EquityCompensationIssuance.templateId, warrantIssuance: Fairmint.OpenCapTable.OCF.WarrantIssuance.WarrantIssuance.templateId, @@ -44,11 +45,15 @@ const MOCK_LEDGER_TEMPLATE_IDS = { function createMockClient( dataKey: string, data: unknown, - ledgerMeta?: { templateId?: string; packageName?: string } + ledgerMeta?: { + templateId?: string; + packageName?: string; + context?: { issuer: string; system_operator: string }; + } ): LedgerJsonApiClient { const createdEvent: Record = { createArgument: { - context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + context: ledgerMeta?.context ?? { issuer: 'issuer::party', system_operator: 'system-operator::party' }, [dataKey]: data, }, }; @@ -195,7 +200,10 @@ describe('DAML to OCF Validation', () => { }; test('reads the contract and returns the canonical monetary amount', async () => { - const client = createMockClient('cancellation_data', validCancellationData); + const client = createMockClient('cancellation_data', validCancellationData, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.convertibleCancellation, + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + }); const result = await getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-contract-1', @@ -207,14 +215,22 @@ describe('DAML to OCF Validation', () => { test('rejects a fetched cancellation without an amount', async () => { const { amount: _, ...invalidData } = validCancellationData; - const client = createMockClient('cancellation_data', invalidData); + const client = createMockClient('cancellation_data', invalidData, { + templateId: MOCK_LEDGER_TEMPLATE_IDS.convertibleCancellation, + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + }); await expect( getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-contract-2' }) ).rejects.toMatchObject({ - name: 'OcpValidationError', - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - fieldPath: 'convertibleCancellation.amount', + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlToOcf.convertibleCancellation.createArgument', + context: { + entityType: 'convertibleCancellation', + decoderPath: 'input.cancellation_data', + decoderMessage: expect.stringContaining("key 'amount' is required"), + }, }); }); });