From afa594aff03c707b9f68e9527b82303ba8825e46 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 06:39:47 -0400 Subject: [PATCH 1/7] Validate cancellation reader payloads --- .../convertibleCancellation/damlToOcf.ts | 2 +- .../getConvertibleCancellationAsOcf.ts | 21 +- .../getEquityCompensationCancellationAsOcf.ts | 41 +-- .../getStockCancellationAsOcf.ts | 40 +-- .../getWarrantCancellationAsOcf.ts | 40 +-- src/utils/typeConversions.ts | 2 +- .../convertibleCancellationConverters.test.ts | 16 +- .../declarations/cancellationReaders.types.ts | 57 ++++ test/functions/cancellationReaders.test.ts | 286 ++++++++++++++++++ test/types/cancellationReaders.types.ts | 57 ++++ 10 files changed, 445 insertions(+), 117 deletions(-) create mode 100644 test/declarations/cancellationReaders.types.ts create mode 100644 test/functions/cancellationReaders.test.ts create mode 100644 test/types/cancellationReaders.types.ts diff --git a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts index 80d67f88..467225b2 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts @@ -23,7 +23,7 @@ export interface DamlConvertibleCancellationData { security_id: string; amount?: DamlMonetary; reason_text: string; - balance_security_id?: string; + balance_security_id?: string | null; comments?: string[]; } diff --git a/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts index 4ddf18b6..f1fbe09d 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'; @@ -21,9 +22,6 @@ export interface GetConvertibleCancellationAsOcfResult { 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. * @@ -37,18 +35,9 @@ export async function getConvertibleCancellationAsOcf( ): Promise { const { 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 } : {}), - }); + const data = extractAndDecodeDamlEntityData('convertibleCancellation', createArgument); + const event = damlConvertibleCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts index d07cedb8..7424d0d5 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts @@ -1,23 +1,16 @@ 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; @@ -26,10 +19,6 @@ export interface GetEquityCompensationCancellationAsOcfResult { 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. * @@ -43,23 +32,9 @@ export async function getEquityCompensationCancellationAsOcf( ): Promise { const { 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 } : {}), - }; + const data = extractAndDecodeDamlEntityData('equityCompensationCancellation', createArgument); + const event = damlEquityCompensationCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts b/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts index 60f8b933..f14bb943 100644 --- a/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts @@ -1,19 +1,12 @@ 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 { @@ -21,32 +14,15 @@ export interface GetStockCancellationAsOcfResult { 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, { 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 } : {}), - }; + const data = extractAndDecodeDamlEntityData('stockCancellation', createArgument); + const event = damlStockCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts index cfd9e48e..012dca33 100644 --- a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts @@ -1,23 +1,16 @@ 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; @@ -26,9 +19,6 @@ export interface GetWarrantCancellationAsOcfResult { 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. * @@ -42,23 +32,9 @@ export async function getWarrantCancellationAsOcf( ): Promise { const { 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 } : {}), - }; + const data = extractAndDecodeDamlEntityData('warrantCancellation', createArgument); + const event = damlWarrantCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 60a4e0f3..206744ce 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -635,7 +635,7 @@ export interface DamlQuantityCancellationData { security_id: string; quantity: string; reason_text: string; - balance_security_id?: string; + balance_security_id?: string | null; comments?: string[]; } diff --git a/test/converters/convertibleCancellationConverters.test.ts b/test/converters/convertibleCancellationConverters.test.ts index de5c4213..b9d2efb0 100644 --- a/test/converters/convertibleCancellationConverters.test.ts +++ b/test/converters/convertibleCancellationConverters.test.ts @@ -1,5 +1,6 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpValidationError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { ENTITY_TEMPLATE_ID_MAP } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation'; function createMockClient(createArgument: Record): LedgerJsonApiClient { @@ -7,6 +8,8 @@ function createMockClient(createArgument: Record): LedgerJsonAp getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'convertible-cancellation-contract-1', + templateId: ENTITY_TEMPLATE_ID_MAP.convertibleCancellation, createArgument, }, }, @@ -54,12 +57,21 @@ 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: [], }, }); await expect( getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-contract-2' }) - ).rejects.toThrow(OcpValidationError); + ).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..30264a06 --- /dev/null +++ b/test/declarations/cancellationReaders.types.ts @@ -0,0 +1,57 @@ +/** Built-declaration contracts for exact cancellation-reader result families. */ + +import type { GetConvertibleCancellationAsOcfResult } from '../../dist/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; +import type { GetEquityCompensationCancellationAsOcfResult } from '../../dist/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf'; +import type { GetStockCancellationAsOcfResult } from '../../dist/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf'; +import type { GetWarrantCancellationAsOcfResult } from '../../dist/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf'; +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; + +declare const stockResult: GetStockCancellationAsOcfResult; +declare const convertibleResult: GetConvertibleCancellationAsOcfResult; +declare const equityCompensationResult: GetEquityCompensationCancellationAsOcfResult; +declare const warrantResult: GetWarrantCancellationAsOcfResult; + +// @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 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..0ab857f2 --- /dev/null +++ b/test/functions/cancellationReaders.test.ts @@ -0,0 +1,286 @@ +/** Direct ledger-reader contracts shared by the four OCF cancellation families. */ + +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + type OcfEntityType, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { convertibleCancellationDataToDaml } from '../../src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation'; +import { getConvertibleCancellationAsOcf } from '../../src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; +import { equityCompensationCancellationDataToDaml } from '../../src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation'; +import { getEquityCompensationCancellationAsOcf } from '../../src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf'; +import { stockCancellationDataToDaml } from '../../src/functions/OpenCapTable/stockCancellation/createStockCancellation'; +import { getStockCancellationAsOcf } from '../../src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf'; +import { warrantCancellationDataToDaml } from '../../src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation'; +import { getWarrantCancellationAsOcf } from '../../src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf'; +import type { + OcfConvertibleCancellation, + OcfEquityCompensationCancellation, + OcfStockCancellation, + OcfWarrantCancellation, +} from '../../src/types/native'; + +type CancellationEntityType = Extract< + OcfEntityType, + 'convertibleCancellation' | 'equityCompensationCancellation' | 'stockCancellation' | 'warrantCancellation' +>; +type CancellationEvent = + | OcfConvertibleCancellation + | OcfEquityCompensationCancellation + | OcfStockCancellation + | OcfWarrantCancellation; + +interface CancellationReaderCase { + readonly entityType: CancellationEntityType; + readonly contractId: string; + readonly numericField: 'amount' | 'quantity'; + readonly validData: () => Record; + readonly malformedNumericData: () => Record; + readonly expectedEvent: CancellationEvent; + readonly invoke: ( + client: LedgerJsonApiClient + ) => Promise<{ readonly event: CancellationEvent; readonly contractId: string }>; +} + +const cancellationReaderCases: readonly CancellationReaderCase[] = [ + { + entityType: 'stockCancellation', + contractId: 'stock-cancellation-cid', + numericField: 'quantity', + validData: () => + stockCancellationDataToDaml({ + object_type: 'TX_STOCK_CANCELLATION', + id: 'stock-cancellation-1', + date: '2026-07-10', + security_id: 'stock-security-1', + quantity: '12.50', + reason_text: 'Cancelled', + comments: ['cancelled'], + }), + malformedNumericData: () => ({ + ...stockCancellationDataToDaml({ + object_type: 'TX_STOCK_CANCELLATION', + id: 'stock-cancellation-1', + date: '2026-07-10', + security_id: 'stock-security-1', + quantity: '12.50', + reason_text: 'Cancelled', + }), + quantity: 17, + }), + 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'], + }, + invoke: async (client) => getStockCancellationAsOcf(client, { contractId: 'stock-cancellation-cid' }), + }, + { + entityType: 'convertibleCancellation', + contractId: 'convertible-cancellation-cid', + numericField: 'amount', + validData: () => + convertibleCancellationDataToDaml({ + object_type: 'TX_CONVERTIBLE_CANCELLATION', + id: 'convertible-cancellation-1', + date: '2026-07-10', + security_id: 'convertible-security-1', + amount: { amount: '250.00', currency: 'USD' }, + reason_text: 'Cancelled', + comments: ['cancelled'], + }), + malformedNumericData: () => ({ + ...convertibleCancellationDataToDaml({ + object_type: 'TX_CONVERTIBLE_CANCELLATION', + id: 'convertible-cancellation-1', + date: '2026-07-10', + security_id: 'convertible-security-1', + amount: { amount: '250.00', currency: 'USD' }, + reason_text: 'Cancelled', + }), + amount: { amount: 17, currency: 'USD' }, + }), + 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'], + }, + invoke: async (client) => getConvertibleCancellationAsOcf(client, { contractId: 'convertible-cancellation-cid' }), + }, + { + entityType: 'equityCompensationCancellation', + contractId: 'equity-compensation-cancellation-cid', + numericField: 'quantity', + validData: () => + equityCompensationCancellationDataToDaml({ + object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', + id: 'equity-compensation-cancellation-1', + date: '2026-07-10', + security_id: 'equity-compensation-security-1', + quantity: '8.00', + reason_text: 'Cancelled', + comments: ['cancelled'], + }), + malformedNumericData: () => ({ + ...equityCompensationCancellationDataToDaml({ + object_type: 'TX_EQUITY_COMPENSATION_CANCELLATION', + id: 'equity-compensation-cancellation-1', + date: '2026-07-10', + security_id: 'equity-compensation-security-1', + quantity: '8.00', + reason_text: 'Cancelled', + }), + quantity: 17, + }), + 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'], + }, + invoke: async (client) => + getEquityCompensationCancellationAsOcf(client, { contractId: 'equity-compensation-cancellation-cid' }), + }, + { + entityType: 'warrantCancellation', + contractId: 'warrant-cancellation-cid', + numericField: 'quantity', + validData: () => + warrantCancellationDataToDaml({ + object_type: 'TX_WARRANT_CANCELLATION', + id: 'warrant-cancellation-1', + date: '2026-07-10', + security_id: 'warrant-security-1', + quantity: '3.00', + reason_text: 'Cancelled', + comments: ['cancelled'], + }), + malformedNumericData: () => ({ + ...warrantCancellationDataToDaml({ + object_type: 'TX_WARRANT_CANCELLATION', + id: 'warrant-cancellation-1', + date: '2026-07-10', + security_id: 'warrant-security-1', + quantity: '3.00', + reason_text: 'Cancelled', + }), + quantity: 17, + }), + 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'], + }, + invoke: async (client) => getWarrantCancellationAsOcf(client, { contractId: 'warrant-cancellation-cid' }), + }, +]; + +function createMockClient( + testCase: CancellationReaderCase, + data: unknown, + templateId: string = ENTITY_TEMPLATE_ID_MAP[testCase.entityType] +): LedgerJsonApiClient { + return { + getEventsByContractId: jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: testCase.contractId, + templateId, + createArgument: { [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data }, + }, + }, + }), + } as unknown as LedgerJsonApiClient; +} + +function expectDecoderFailure(error: unknown, testCase: CancellationReaderCase, field: string): void { + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: expect.any(String), + decoderMessage: expect.any(String), + }, + }); + const parseError = error as OcpParseError; + expect(`${String(parseError.context?.decoderPath)} ${String(parseError.context?.decoderMessage)}`).toContain(field); +} + +describe('decoder-backed cancellation readers', () => { + it.each(cancellationReaderCases)('$entityType returns the exact canonical event shape', async (testCase) => { + await expect(testCase.invoke(createMockClient(testCase, testCase.validData()))).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects malformed $numericField', async (testCase) => { + try { + await testCase.invoke(createMockClient(testCase, testCase.malformedNumericData())); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed ${testCase.numericField}`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, testCase.numericField); + } + }); + + it.each(cancellationReaderCases)('$entityType rejects malformed reason_text', async (testCase) => { + try { + await testCase.invoke(createMockClient(testCase, { ...testCase.validData(), reason_text: 17 })); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed reason_text`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'reason_text'); + } + }); + + it.each(cancellationReaderCases)('$entityType rejects malformed comment elements', async (testCase) => { + try { + await testCase.invoke(createMockClient(testCase, { ...testCase.validData(), comments: ['valid', 17] })); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed comments`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'comments'); + } + }); + + it.each(cancellationReaderCases)('$entityType rejects non-object nested cancellation data', async (testCase) => { + await expect(testCase.invoke(createMockClient(testCase, []))).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + message: expect.stringContaining(ENTITY_DATA_FIELD_MAP[testCase.entityType]), + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects a contract from the wrong template', async (testCase) => { + const wrongTemplateId = ENTITY_TEMPLATE_ID_MAP.document; + await expect( + testCase.invoke(createMockClient(testCase, testCase.validData(), 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, + }, + }); + }); +}); diff --git a/test/types/cancellationReaders.types.ts b/test/types/cancellationReaders.types.ts new file mode 100644 index 00000000..9a98e090 --- /dev/null +++ b/test/types/cancellationReaders.types.ts @@ -0,0 +1,57 @@ +/** Compile-time contracts for exact cancellation-reader result families. */ + +import type { GetConvertibleCancellationAsOcfResult } from '../../src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf'; +import type { GetEquityCompensationCancellationAsOcfResult } from '../../src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf'; +import type { GetStockCancellationAsOcfResult } from '../../src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf'; +import type { GetWarrantCancellationAsOcfResult } from '../../src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf'; +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; + +declare const stockResult: GetStockCancellationAsOcfResult; +declare const convertibleResult: GetConvertibleCancellationAsOcfResult; +declare const equityCompensationResult: GetEquityCompensationCancellationAsOcfResult; +declare const warrantResult: GetWarrantCancellationAsOcfResult; + +// @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 wrongWarrant; +void wrongStock; +void wrongConvertible; +void wrongEquityCompensation; From 4d1c7400a5b2a51cbc6b7cfc280e38557a48270d Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 18:15:57 -0400 Subject: [PATCH 2/7] Reject malformed cancellation balance IDs --- .../getConvertibleCancellationAsOcf.ts | 4 +- .../getEquityCompensationCancellationAsOcf.ts | 4 +- .../OpenCapTable/shared/cancellationReader.ts | 43 ++++++++++++ .../getStockCancellationAsOcf.ts | 4 +- .../getWarrantCancellationAsOcf.ts | 4 +- test/functions/cancellationReaders.test.ts | 66 +++++++++++++++++++ 6 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/cancellationReader.ts diff --git a/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts index f1fbe09d..e990cf4d 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; 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 { extractAndDecodeCancellationData } from '../shared/cancellationReader'; import { readSingleContract } from '../shared/singleContractRead'; import { damlConvertibleCancellationToNative } from './damlToOcf'; @@ -37,7 +37,7 @@ export async function getConvertibleCancellationAsOcf( operation: 'getConvertibleCancellationAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.convertibleCancellation, }); - const data = extractAndDecodeDamlEntityData('convertibleCancellation', createArgument); + const data = extractAndDecodeCancellationData('convertibleCancellation', createArgument); const event = damlConvertibleCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts index 7424d0d5..cfb5851b 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfEquityCompensationCancellation } from '../../../types/native'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; -import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { extractAndDecodeCancellationData } from '../shared/cancellationReader'; import { readSingleContract } from '../shared/singleContractRead'; import { damlEquityCompensationCancellationToNative } from './damlToOcf'; @@ -34,7 +34,7 @@ export async function getEquityCompensationCancellationAsOcf( operation: 'getEquityCompensationCancellationAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.equityCompensationCancellation, }); - const data = extractAndDecodeDamlEntityData('equityCompensationCancellation', createArgument); + const data = extractAndDecodeCancellationData('equityCompensationCancellation', createArgument); const event = damlEquityCompensationCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/shared/cancellationReader.ts b/src/functions/OpenCapTable/shared/cancellationReader.ts new file mode 100644 index 00000000..8ec645e0 --- /dev/null +++ b/src/functions/OpenCapTable/shared/cancellationReader.ts @@ -0,0 +1,43 @@ +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfEntityType } from '../capTable/batchTypes'; +import { decodeDamlEntityData, extractEntityData } from '../capTable/damlEntityData'; + +type CancellationEntityType = Extract< + OcfEntityType, + 'convertibleCancellation' | 'equityCompensationCancellation' | 'stockCancellation' | 'warrantCancellation' +>; + +/** + * Decode one cancellation payload after validating Optional fields that the generated + * DAML decoder otherwise defaults to `null` when a present value has the wrong type. + */ +export function extractAndDecodeCancellationData( + entityType: EntityType, + createArgument: unknown +): DamlDataTypeFor { + const data = extractEntityData(entityType, createArgument); + const balanceSecurityId = data.balance_security_id; + + if (balanceSecurityId !== null && balanceSecurityId !== undefined && typeof balanceSecurityId !== 'string') { + const decoderPath = 'input.balance_security_id'; + const fieldPath = `${entityType}.balance_security_id`; + const actualType = typeof balanceSecurityId; + const decoderMessage = `expected a string, null, or undefined, got ${actualType}`; + + throw new OcpParseError(`Invalid DAML data for ${entityType} at ${decoderPath}: ${decoderMessage}`, { + source: `damlEntityData.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + decoderPath, + decoderMessage, + fieldPath, + expectedType: 'string | null | undefined', + receivedType: actualType, + }, + }); + } + + return decodeDamlEntityData(entityType, data); +} diff --git a/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts b/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts index f14bb943..02b83249 100644 --- a/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockCancellation } from '../../../types/native'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; -import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { extractAndDecodeCancellationData } from '../shared/cancellationReader'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockCancellationToNative } from './damlToOcf'; @@ -22,7 +22,7 @@ export async function getStockCancellationAsOcf( operation: 'getStockCancellationAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockCancellation, }); - const data = extractAndDecodeDamlEntityData('stockCancellation', createArgument); + const data = extractAndDecodeCancellationData('stockCancellation', createArgument); const event = damlStockCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts index 012dca33..c97cd875 100644 --- a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfWarrantCancellation } from '../../../types/native'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; -import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; +import { extractAndDecodeCancellationData } from '../shared/cancellationReader'; import { readSingleContract } from '../shared/singleContractRead'; import { damlWarrantCancellationToNative } from './damlToOcf'; @@ -34,7 +34,7 @@ export async function getWarrantCancellationAsOcf( operation: 'getWarrantCancellationAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.warrantCancellation, }); - const data = extractAndDecodeDamlEntityData('warrantCancellation', createArgument); + const data = extractAndDecodeCancellationData('warrantCancellation', createArgument); const event = damlWarrantCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/test/functions/cancellationReaders.test.ts b/test/functions/cancellationReaders.test.ts index 0ab857f2..121430c4 100644 --- a/test/functions/cancellationReaders.test.ts +++ b/test/functions/cancellationReaders.test.ts @@ -259,6 +259,72 @@ describe('decoder-backed cancellation readers', () => { } }); + it.each(cancellationReaderCases)('$entityType rejects a numeric balance_security_id', async (testCase) => { + try { + await testCase.invoke(createMockClient(testCase, { ...testCase.validData(), balance_security_id: 17 })); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed balance_security_id`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'balance_security_id'); + expect(error).toMatchObject({ + context: { + decoderPath: 'input.balance_security_id', + fieldPath: `${testCase.entityType}.balance_security_id`, + expectedType: 'string | null | undefined', + receivedType: 'number', + }, + }); + } + }); + + it.each(cancellationReaderCases)('$entityType rejects an object balance_security_id', async (testCase) => { + try { + await testCase.invoke(createMockClient(testCase, { ...testCase.validData(), balance_security_id: {} })); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed balance_security_id`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, 'balance_security_id'); + expect(error).toMatchObject({ + context: { + decoderPath: 'input.balance_security_id', + fieldPath: `${testCase.entityType}.balance_security_id`, + expectedType: 'string | null | undefined', + receivedType: 'object', + }, + }); + } + }); + + it.each(cancellationReaderCases)('$entityType accepts a null balance_security_id as absent', async (testCase) => { + await expect( + testCase.invoke(createMockClient(testCase, { ...testCase.validData(), balance_security_id: null })) + ).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + }); + + it.each(cancellationReaderCases)( + '$entityType accepts an undefined balance_security_id as absent', + async (testCase) => { + await expect( + testCase.invoke(createMockClient(testCase, { ...testCase.validData(), balance_security_id: undefined })) + ).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + } + ); + + it.each(cancellationReaderCases)('$entityType preserves a valid balance_security_id', async (testCase) => { + const balanceSecurityId = `${testCase.entityType}-balance-security-1`; + + await expect( + testCase.invoke(createMockClient(testCase, { ...testCase.validData(), balance_security_id: balanceSecurityId })) + ).resolves.toEqual({ + event: { ...testCase.expectedEvent, balance_security_id: balanceSecurityId }, + contractId: testCase.contractId, + }); + }); + it.each(cancellationReaderCases)('$entityType rejects non-object nested cancellation data', async (testCase) => { await expect(testCase.invoke(createMockClient(testCase, []))).rejects.toMatchObject({ name: 'OcpParseError', From a3cefb7534f9a9b9735cb85ca19b56078f46aa6e Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 00:43:50 -0400 Subject: [PATCH 3/7] fix: validate cancellation contract wrappers --- .../capTable/cancellationContractData.ts | 189 +++++++++ .../OpenCapTable/capTable/damlEntityData.ts | 5 + .../getConvertibleCancellationAsOcf.ts | 4 +- .../getEquityCompensationCancellationAsOcf.ts | 4 +- .../OpenCapTable/shared/cancellationReader.ts | 43 --- .../getStockCancellationAsOcf.ts | 4 +- .../getWarrantCancellationAsOcf.ts | 4 +- test/batch/damlToOcfDispatcher.test.ts | 31 ++ .../convertibleCancellationConverters.test.ts | 8 +- test/functions/cancellationReaders.test.ts | 358 +++++++++++++++++- test/validation/damlToOcfValidation.test.ts | 29 +- 11 files changed, 608 insertions(+), 71 deletions(-) create mode 100644 src/functions/OpenCapTable/capTable/cancellationContractData.ts delete mode 100644 src/functions/OpenCapTable/shared/cancellationReader.ts diff --git a/src/functions/OpenCapTable/capTable/cancellationContractData.ts b/src/functions/OpenCapTable/capTable/cancellationContractData.ts new file mode 100644 index 00000000..93901b69 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/cancellationContractData.ts @@ -0,0 +1,189 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfEntityType } from './batchTypes'; + +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 CancellationCreateArgumentDecoder { + run(input: unknown): { readonly ok: true; readonly result: T } | { readonly ok: false; readonly error: DecoderError }; +} + +type CancellationCreateArgumentDecoderMap = { + readonly [EntityType in CancellationEntityType]: CancellationCreateArgumentDecoder< + CancellationCreateArgumentMap[EntityType] + >; +}; + +/** Generated template decoders correlated with each supported cancellation family. */ +const CANCELLATION_CREATE_ARGUMENT_DECODER_MAP = { + convertibleCancellation: Fairmint.OpenCapTable.OCF.ConvertibleCancellation.ConvertibleCancellation.decoder, + equityCompensationCancellation: + Fairmint.OpenCapTable.OCF.EquityCompensationCancellation.EquityCompensationCancellation.decoder, + stockCancellation: Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation.decoder, + warrantCancellation: Fairmint.OpenCapTable.OCF.WarrantCancellation.WarrantCancellation.decoder, +} as const satisfies CancellationCreateArgumentDecoderMap; + +type CancellationDataFor = + CancellationCreateArgumentMap[EntityType]['cancellation_data']; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasOwnField(record: object, field: string): boolean { + return Object.prototype.hasOwnProperty.call(record, field); +} + +function ownField(record: Record, field: string): unknown { + return hasOwnField(record, field) ? record[field] : undefined; +} + +function receivedType(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +function cancellationDecodeError( + entityType: CancellationEntityType, + decoderPath: string, + decoderMessage: string, + diagnostics: Record = {} +): OcpParseError { + return new OcpParseError(`Invalid DAML create argument for ${entityType} at ${decoderPath}: ${decoderMessage}`, { + source: `damlCancellationCreateArgument.${entityType}`, + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + decoderPath, + decoderMessage, + ...diagnostics, + }, + }); +} + +function requireOwnFields( + entityType: CancellationEntityType, + record: Record, + fields: readonly string[], + decoderPath: string +): void { + for (const field of fields) { + if (!hasOwnField(record, field)) { + throw cancellationDecodeError(entityType, decoderPath, `the key '${field}' is required as an own property`); + } + } +} + +function validateCancellationOwnProperties(entityType: CancellationEntityType, createArgument: unknown): void { + if (!isRecord(createArgument)) return; + + requireOwnFields(entityType, createArgument, ['context', 'cancellation_data'], 'input'); + + const context = ownField(createArgument, 'context'); + if (isRecord(context)) { + requireOwnFields(entityType, context, ['issuer', 'system_operator'], 'input.context'); + } + + const cancellationData = ownField(createArgument, 'cancellation_data'); + if (!isRecord(cancellationData)) return; + + const numericField = entityType === 'convertibleCancellation' ? 'amount' : 'quantity'; + requireOwnFields( + entityType, + cancellationData, + ['id', numericField, 'date', 'reason_text', 'security_id', 'comments'], + 'input.cancellation_data' + ); + + if (entityType === 'convertibleCancellation') { + const amount = ownField(cancellationData, 'amount'); + if (isRecord(amount)) { + requireOwnFields(entityType, amount, ['amount', 'currency'], 'input.cancellation_data.amount'); + } + } + + const comments = ownField(cancellationData, 'comments'); + if (Array.isArray(comments)) { + for (let index = 0; index < comments.length; index++) { + if (!hasOwnField(comments, String(index))) { + throw cancellationDecodeError( + entityType, + `input.cancellation_data.comments[${index}]`, + 'list element is missing or inherited rather than an own property' + ); + } + } + } + + if (!hasOwnField(cancellationData, 'balance_security_id')) { + if ('balance_security_id' in cancellationData) { + throw cancellationDecodeError( + entityType, + 'input.cancellation_data.balance_security_id', + "optional key 'balance_security_id' is inherited rather than an own property" + ); + } + return; + } + + const balanceSecurityId = ownField(cancellationData, 'balance_security_id'); + if (balanceSecurityId === null || balanceSecurityId === undefined || typeof balanceSecurityId === 'string') return; + + const actualType = receivedType(balanceSecurityId); + throw cancellationDecodeError( + entityType, + 'input.cancellation_data.balance_security_id', + `expected a string, null, or undefined, got ${actualType}`, + { + fieldPath: `${entityType}.balance_security_id`, + expectedType: 'string | null | undefined', + receivedType: actualType, + } + ); +} + +/** Decode the full generated contract wrapper and return its recursively decoded cancellation payload. */ +export function extractAndDecodeCancellationData( + entityType: EntityType, + createArgument: unknown +): CancellationDataFor; +export function extractAndDecodeCancellationData( + entityType: CancellationEntityType, + createArgument: unknown +): CancellationDataFor { + validateCancellationOwnProperties(entityType, createArgument); + const decoded = CANCELLATION_CREATE_ARGUMENT_DECODER_MAP[entityType].run(createArgument); + + if (!decoded.ok) { + const { at: decoderPath, message: decoderMessage } = decoded.error; + throw cancellationDecodeError(entityType, decoderPath, decoderMessage); + } + + return decoded.result.cancellation_data; +} diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index a501d87a..d4c5fc56 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -8,6 +8,7 @@ import { type DamlDataTypeFor, type OcfEntityType, } from './batchTypes'; +import { extractAndDecodeCancellationData, isCancellationEntityType } from './cancellationContractData'; interface DecoderError { readonly at: string; @@ -170,5 +171,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/getConvertibleCancellationAsOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts index e990cf4d..f1fbe09d 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/getConvertibleCancellationAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfConvertibleCancellation } from '../../../types/native'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; -import { extractAndDecodeCancellationData } from '../shared/cancellationReader'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlConvertibleCancellationToNative } from './damlToOcf'; @@ -37,7 +37,7 @@ export async function getConvertibleCancellationAsOcf( operation: 'getConvertibleCancellationAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.convertibleCancellation, }); - const data = extractAndDecodeCancellationData('convertibleCancellation', createArgument); + const data = extractAndDecodeDamlEntityData('convertibleCancellation', createArgument); const event = damlConvertibleCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts index cfb5851b..7424d0d5 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/getEquityCompensationCancellationAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfEquityCompensationCancellation } from '../../../types/native'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; -import { extractAndDecodeCancellationData } from '../shared/cancellationReader'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlEquityCompensationCancellationToNative } from './damlToOcf'; @@ -34,7 +34,7 @@ export async function getEquityCompensationCancellationAsOcf( operation: 'getEquityCompensationCancellationAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.equityCompensationCancellation, }); - const data = extractAndDecodeCancellationData('equityCompensationCancellation', createArgument); + const data = extractAndDecodeDamlEntityData('equityCompensationCancellation', createArgument); const event = damlEquityCompensationCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/shared/cancellationReader.ts b/src/functions/OpenCapTable/shared/cancellationReader.ts deleted file mode 100644 index 8ec645e0..00000000 --- a/src/functions/OpenCapTable/shared/cancellationReader.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { OcpErrorCodes, OcpParseError } from '../../../errors'; -import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfEntityType } from '../capTable/batchTypes'; -import { decodeDamlEntityData, extractEntityData } from '../capTable/damlEntityData'; - -type CancellationEntityType = Extract< - OcfEntityType, - 'convertibleCancellation' | 'equityCompensationCancellation' | 'stockCancellation' | 'warrantCancellation' ->; - -/** - * Decode one cancellation payload after validating Optional fields that the generated - * DAML decoder otherwise defaults to `null` when a present value has the wrong type. - */ -export function extractAndDecodeCancellationData( - entityType: EntityType, - createArgument: unknown -): DamlDataTypeFor { - const data = extractEntityData(entityType, createArgument); - const balanceSecurityId = data.balance_security_id; - - if (balanceSecurityId !== null && balanceSecurityId !== undefined && typeof balanceSecurityId !== 'string') { - const decoderPath = 'input.balance_security_id'; - const fieldPath = `${entityType}.balance_security_id`; - const actualType = typeof balanceSecurityId; - const decoderMessage = `expected a string, null, or undefined, got ${actualType}`; - - throw new OcpParseError(`Invalid DAML data for ${entityType} at ${decoderPath}: ${decoderMessage}`, { - source: `damlEntityData.${entityType}`, - code: OcpErrorCodes.SCHEMA_MISMATCH, - context: { - entityType, - expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], - decoderPath, - decoderMessage, - fieldPath, - expectedType: 'string | null | undefined', - receivedType: actualType, - }, - }); - } - - return decodeDamlEntityData(entityType, data); -} diff --git a/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts b/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts index 02b83249..f14bb943 100644 --- a/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/stockCancellation/getStockCancellationAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfStockCancellation } from '../../../types/native'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; -import { extractAndDecodeCancellationData } from '../shared/cancellationReader'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockCancellationToNative } from './damlToOcf'; @@ -22,7 +22,7 @@ export async function getStockCancellationAsOcf( operation: 'getStockCancellationAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockCancellation, }); - const data = extractAndDecodeCancellationData('stockCancellation', createArgument); + const data = extractAndDecodeDamlEntityData('stockCancellation', createArgument); const event = damlStockCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts index c97cd875..012dca33 100644 --- a/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/getWarrantCancellationAsOcf.ts @@ -2,7 +2,7 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; import type { OcfWarrantCancellation } from '../../../types/native'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; -import { extractAndDecodeCancellationData } from '../shared/cancellationReader'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlWarrantCancellationToNative } from './damlToOcf'; @@ -34,7 +34,7 @@ export async function getWarrantCancellationAsOcf( operation: 'getWarrantCancellationAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.warrantCancellation, }); - const data = extractAndDecodeCancellationData('warrantCancellation', createArgument); + const data = extractAndDecodeDamlEntityData('warrantCancellation', createArgument); const event = damlWarrantCancellationToNative(data); return { event, contractId: params.contractId }; } diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index 49e56e0a..acf12d37 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -160,6 +160,37 @@ describe('damlToOcf dispatcher', () => { }, }); }); + + it('enforces the full generated wrapper for generic cancellation reads', async () => { + const getEventsByContractId = jest.fn().mockResolvedValue( + buildCreatedEventsResponse( + { + 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, + }, + }, + Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation.templateId + ) + ); + const mockClient = { getEventsByContractId } as unknown as LedgerJsonApiClient; + + await expect(getEntityAsOcf(mockClient, 'stockCancellation', 'cancellation-cid')).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlCancellationCreateArgument.stockCancellation', + context: { + entityType: 'stockCancellation', + decoderPath: 'input', + decoderMessage: expect.stringContaining("key 'context' is required"), + }, + }); + }); }); describe('ENTITY_TEMPLATE_ID_MAP', () => { diff --git a/test/converters/convertibleCancellationConverters.test.ts b/test/converters/convertibleCancellationConverters.test.ts index addfa640..191abb06 100644 --- a/test/converters/convertibleCancellationConverters.test.ts +++ b/test/converters/convertibleCancellationConverters.test.ts @@ -13,7 +13,13 @@ function createMockClient(createArgument: Record): LedgerJsonAp createdEvent: { contractId: 'convertible-cancellation-contract-1', templateId: ENTITY_TEMPLATE_ID_MAP.convertibleCancellation, - createArgument, + createArgument: { + context: { + issuer: 'issuer::party', + system_operator: 'system-operator::party', + }, + ...createArgument, + }, }, }, }), diff --git a/test/functions/cancellationReaders.test.ts b/test/functions/cancellationReaders.test.ts index 121430c4..44852aad 100644 --- a/test/functions/cancellationReaders.test.ts +++ b/test/functions/cancellationReaders.test.ts @@ -1,7 +1,7 @@ /** Direct ledger-reader contracts shared by the four OCF cancellation families. */ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { OcpErrorCodes, OcpParseError } from '../../src/errors'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; import { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP, @@ -32,6 +32,11 @@ type CancellationEvent = | OcfStockCancellation | OcfWarrantCancellation; +const VALID_CONTEXT = { + issuer: 'issuer::party', + system_operator: 'system-operator::party', +} as const; + interface CancellationReaderCase { readonly entityType: CancellationEntityType; readonly contractId: string; @@ -192,18 +197,28 @@ const cancellationReaderCases: readonly CancellationReaderCase[] = [ }, ]; +interface MockContractOptions { + readonly templateId?: string; + readonly packageName?: string; + readonly createArgument?: Record; +} + function createMockClient( testCase: CancellationReaderCase, data: unknown, - templateId: string = ENTITY_TEMPLATE_ID_MAP[testCase.entityType] + options: MockContractOptions = {} ): LedgerJsonApiClient { return { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { contractId: testCase.contractId, - templateId, - createArgument: { [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data }, + templateId: options.templateId ?? ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + ...(options.packageName !== undefined ? { packageName: options.packageName } : {}), + createArgument: options.createArgument ?? { + context: VALID_CONTEXT, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data, + }, }, }, }), @@ -238,6 +253,36 @@ describe('decoder-backed cancellation readers', () => { throw new Error(`Expected ${testCase.entityType} reader to reject malformed ${testCase.numericField}`); } catch (error: unknown) { expectDecoderFailure(error, testCase, testCase.numericField); + expect(error).toMatchObject({ + context: { + decoderPath: + testCase.numericField === 'amount' + ? 'input.cancellation_data.amount.amount' + : 'input.cancellation_data.quantity', + }, + }); + } + }); + + it.each(cancellationReaderCases)('$entityType rejects malformed required scalar fields', async (testCase) => { + for (const [field, malformedData, decoderPath] of [ + ['id', { ...testCase.validData(), id: 17 }, 'input.cancellation_data.id'], + ['date', { ...testCase.validData(), date: null }, 'input.cancellation_data.date'], + [ + 'security_id', + Object.fromEntries(Object.entries(testCase.validData()).filter(([key]) => key !== 'security_id')), + 'input.cancellation_data', + ], + ['reason_text', { ...testCase.validData(), reason_text: null }, 'input.cancellation_data.reason_text'], + ['comments', { ...testCase.validData(), comments: null }, 'input.cancellation_data.comments'], + ] as const) { + try { + await testCase.invoke(createMockClient(testCase, malformedData)); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed ${field}`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, field); + expect(error).toMatchObject({ context: { decoderPath } }); + } } }); @@ -247,16 +292,146 @@ describe('decoder-backed cancellation readers', () => { throw new Error(`Expected ${testCase.entityType} reader to reject malformed reason_text`); } catch (error: unknown) { expectDecoderFailure(error, testCase, 'reason_text'); + expect(error).toMatchObject({ context: { decoderPath: 'input.cancellation_data.reason_text' } }); } }); it.each(cancellationReaderCases)('$entityType rejects malformed comment elements', async (testCase) => { - try { - await testCase.invoke(createMockClient(testCase, { ...testCase.validData(), comments: ['valid', 17] })); - throw new Error(`Expected ${testCase.entityType} reader to reject malformed comments`); - } catch (error: unknown) { - expectDecoderFailure(error, testCase, 'comments'); + await expect( + testCase.invoke(createMockClient(testCase, { ...testCase.validData(), comments: ['valid', 17] })) + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlCancellationCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input.cancellation_data.comments[1]', + decoderMessage: 'expected a string, got a number', + }, + }); + }); + + it.each(cancellationReaderCases)( + '$entityType rejects a createArgument missing generated context', + async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlCancellationCreateArgument.${testCase.entityType}`, + 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 an inherited generated wrapper', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: Object.create({ + context: VALID_CONTEXT, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }), + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlCancellationCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input', + decoderMessage: expect.stringContaining("key 'context' is required as an own property"), + }, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects malformed generated context fields', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + context: { issuer: VALID_CONTEXT.issuer, system_operator: 17 }, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlCancellationCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input.context.system_operator', + decoderMessage: 'expected a string, got a number', + }, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects inherited generated context fields', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + context: Object.create(VALID_CONTEXT), + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlCancellationCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input.context', + decoderMessage: expect.stringContaining("key 'issuer' is required as an own property"), + }, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects inherited required payload fields', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + context: VALID_CONTEXT, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: Object.create(testCase.validData()), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlCancellationCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input.cancellation_data', + decoderMessage: expect.stringContaining("key 'id' is required as an own property"), + }, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects a wrong generated wrapper variant', async (testCase) => { + const client = createMockClient(testCase, testCase.validData(), { + createArgument: { + context: VALID_CONTEXT, + acceptance_data: testCase.validData(), + }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlCancellationCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input', + decoderMessage: expect.stringContaining("key 'cancellation_data' is required"), + }, + }); }); it.each(cancellationReaderCases)('$entityType rejects a numeric balance_security_id', async (testCase) => { @@ -267,7 +442,7 @@ describe('decoder-backed cancellation readers', () => { expectDecoderFailure(error, testCase, 'balance_security_id'); expect(error).toMatchObject({ context: { - decoderPath: 'input.balance_security_id', + decoderPath: 'input.cancellation_data.balance_security_id', fieldPath: `${testCase.entityType}.balance_security_id`, expectedType: 'string | null | undefined', receivedType: 'number', @@ -284,7 +459,7 @@ describe('decoder-backed cancellation readers', () => { expectDecoderFailure(error, testCase, 'balance_security_id'); expect(error).toMatchObject({ context: { - decoderPath: 'input.balance_security_id', + decoderPath: 'input.cancellation_data.balance_security_id', fieldPath: `${testCase.entityType}.balance_security_id`, expectedType: 'string | null | undefined', receivedType: 'object', @@ -325,6 +500,135 @@ describe('decoder-backed cancellation readers', () => { }); }); + it.each(cancellationReaderCases)('$entityType accepts a missing balance_security_id as absent', async (testCase) => { + const data = Object.fromEntries( + Object.entries(testCase.validData()).filter(([field]) => field !== 'balance_security_id') + ); + + await expect(testCase.invoke(createMockClient(testCase, data))).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects an inherited balance_security_id', async (testCase) => { + const data = testCase.validData(); + delete data.balance_security_id; + Object.setPrototypeOf(data, { balance_security_id: 'inherited-balance-id' }); + + await expect(testCase.invoke(createMockClient(testCase, data))).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlCancellationCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input.cancellation_data.balance_security_id', + decoderMessage: "optional key 'balance_security_id' is inherited rather than an own property", + }, + }); + }); + + it('convertibleCancellation rejects inherited monetary members', async () => { + const testCase = cancellationReaderCases.find(({ entityType }) => entityType === 'convertibleCancellation'); + if (testCase === undefined) throw new Error('Missing convertible cancellation reader case'); + const data = testCase.validData(); + const { amount } = data; + if (amount === null || typeof amount !== 'object' || Array.isArray(amount)) { + throw new Error('Convertible cancellation fixture amount must be an object'); + } + data.amount = Object.create(amount); + + await expect(testCase.invoke(createMockClient(testCase, data))).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: 'damlCancellationCreateArgument.convertibleCancellation', + context: { + entityType: 'convertibleCancellation', + decoderPath: 'input.cancellation_data.amount', + decoderMessage: expect.stringContaining("key 'amount' is required as an own property"), + }, + }); + }); + + it.each(cancellationReaderCases)( + '$entityType rejects a malformed lexical date with its exact field path', + async (testCase) => { + const client = createMockClient(testCase, { + ...testCase.validData(), + date: 'not-a-date', + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: `${testCase.entityType}.date`, + receivedValue: 'not-a-date', + }); + await expect(testCase.invoke(client)).rejects.toBeInstanceOf(OcpValidationError); + } + ); + + it.each(cancellationReaderCases)('$entityType omits only the canonical empty comments list', async (testCase) => { + const client = createMockClient(testCase, { + ...testCase.validData(), + comments: [], + }); + const expectedEvent = { ...testCase.expectedEvent }; + delete expectedEvent.comments; + + await expect(testCase.invoke(client)).resolves.toEqual({ + event: expectedEvent, + contractId: testCase.contractId, + }); + }); + + it.each(cancellationReaderCases)('$entityType rejects sparse comments without dropping indexes', async (testCase) => { + const comments = new Array(2); + comments[1] = 'second comment'; + const client = createMockClient(testCase, { + ...testCase.validData(), + comments, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlCancellationCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input.cancellation_data.comments[0]', + decoderMessage: 'list element is missing or inherited rather than an own property', + }, + }); + }); + + it.each(cancellationReaderCases)( + '$entityType rejects comments inherited through an array prototype', + async (testCase) => { + class InheritedComments extends Array {} + Object.defineProperty(InheritedComments.prototype, 0, { + configurable: true, + value: 'inherited comment', + }); + const comments = new InheritedComments(1); + const client = createMockClient(testCase, { + ...testCase.validData(), + comments, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlCancellationCreateArgument.${testCase.entityType}`, + context: { + entityType: testCase.entityType, + decoderPath: 'input.cancellation_data.comments[0]', + decoderMessage: 'list element is missing or inherited rather than an own property', + }, + }); + } + ); + it.each(cancellationReaderCases)('$entityType rejects non-object nested cancellation data', async (testCase) => { await expect(testCase.invoke(createMockClient(testCase, []))).rejects.toMatchObject({ name: 'OcpParseError', @@ -334,9 +638,12 @@ describe('decoder-backed cancellation readers', () => { }); it.each(cancellationReaderCases)('$entityType rejects a contract from the wrong template', async (testCase) => { - const wrongTemplateId = ENTITY_TEMPLATE_ID_MAP.document; + const wrongTemplateId = + testCase.entityType === 'stockCancellation' + ? ENTITY_TEMPLATE_ID_MAP.warrantCancellation + : ENTITY_TEMPLATE_ID_MAP.stockCancellation; await expect( - testCase.invoke(createMockClient(testCase, testCase.validData(), wrongTemplateId)) + testCase.invoke(createMockClient(testCase, testCase.validData(), { templateId: wrongTemplateId })) ).rejects.toMatchObject({ name: 'OcpContractError', code: OcpErrorCodes.SCHEMA_MISMATCH, @@ -349,4 +656,29 @@ describe('decoder-backed cancellation readers', () => { }, }); }); + + 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'); + const client = createMockClient(testCase, testCase.validData(), { + templateId: wrongTemplateId, + packageName: 'OpenCapTable-wrong', + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'package_name_mismatch', + contractId: testCase.contractId, + templateId: wrongTemplateId, + context: { + expectedTemplateId, + actualTemplateId: wrongTemplateId, + actualPackageName: 'OpenCapTable-wrong', + }, + }); + } + ); }); diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 97d97d9e..7ae2ee7f 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -31,6 +31,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, @@ -45,10 +46,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: { + ...(ledgerMeta?.context !== undefined ? { context: ledgerMeta.context } : {}), [dataKey]: data, }, }; @@ -170,7 +176,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', @@ -182,14 +191,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: 'damlCancellationCreateArgument.convertibleCancellation', + context: { + entityType: 'convertibleCancellation', + decoderPath: 'input.cancellation_data', + decoderMessage: expect.stringContaining("key 'amount' is required"), + }, }); }); }); From 5433fd4c115e39181707c7cb9681c6d27cba36a7 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:04:47 -0400 Subject: [PATCH 4/7] fix: reject noncanonical cancellation balance ids --- src/utils/typeConversions.ts | 2 +- test/functions/cancellationReaders.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 2125d39f..21b561fb 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -909,7 +909,7 @@ export function cancellationBalanceSecurityIdFromDaml(value: unknown, fieldPath: /** Encode an optional OCF cancellation balance security without collapsing an explicit empty identifier. */ export function cancellationBalanceSecurityIdToDaml(value: unknown, fieldPath: string): string | null { - if (value === undefined || value === null) return null; + if (value === undefined) return null; if (typeof value !== 'string') { throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { code: OcpErrorCodes.INVALID_TYPE, diff --git a/test/functions/cancellationReaders.test.ts b/test/functions/cancellationReaders.test.ts index abd49225..4d592bd7 100644 --- a/test/functions/cancellationReaders.test.ts +++ b/test/functions/cancellationReaders.test.ts @@ -338,10 +338,11 @@ describe('decoder-backed cancellation readers', () => { }); it.each(cancellationReaderCases)( - '$entityType writer rejects explicit undefined and empty balance IDs', + '$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 { From 28ff4bf1a95e7803cc2ce4e97f2bfb1d54c13cca Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sat, 11 Jul 2026 15:23:33 -0400 Subject: [PATCH 5/7] fix: validate cancellation ledger identity --- .../capTable/cancellationContractData.ts | 4 + .../OpenCapTable/shared/singleContractRead.ts | 83 ++++++++++++------- test/batch/damlToOcfDispatcher.test.ts | 34 +++++--- test/client/OcpClient.test.ts | 26 ++++-- test/converters/documentConverters.test.ts | 1 + .../exerciseConversionConverters.test.ts | 15 ++-- .../genericConversionReadBoundaries.test.ts | 22 +++-- test/converters/issuerConverters.test.ts | 2 + .../stockClassAdjustmentConverters.test.ts | 34 +++++--- .../valuationVestingConverters.test.ts | 17 ++-- test/functions/cancellationReaders.test.ts | 68 +++++++++++++-- .../generatedDamlReaderValidation.test.ts | 22 +++-- test/validation/damlToOcfValidation.test.ts | 13 +-- test/validation/generatedDamlBoundary.test.ts | 8 +- 14 files changed, 242 insertions(+), 107 deletions(-) diff --git a/src/functions/OpenCapTable/capTable/cancellationContractData.ts b/src/functions/OpenCapTable/capTable/cancellationContractData.ts index a2c5980f..889ec590 100644 --- a/src/functions/OpenCapTable/capTable/cancellationContractData.ts +++ b/src/functions/OpenCapTable/capTable/cancellationContractData.ts @@ -2,6 +2,7 @@ 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'; @@ -112,6 +113,9 @@ function createCancellationCreateArgumentDecoder; - createdEvent: LedgerCreatedEvent; + createdEvent: ValidatedLedgerCreatedEvent; templateId?: string; packageName?: string; templateIdentity?: ParsedTemplateIdentity; @@ -162,6 +166,52 @@ function missingContractDataError( }); } +function assertCorrelatedCreatedContractId( + createdEvent: LedgerCreatedEvent, + requestedContractId: string, + operation: string +): asserts createdEvent is ValidatedLedgerCreatedEvent { + const source = `contract ${requestedContractId}.eventsResponse.created.createdEvent.contractId`; + if (!Object.prototype.hasOwnProperty.call(createdEvent, 'contractId')) { + throw new OcpParseError('Invalid contract events response: missing created-event contract ID', { + source, + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'missing_created_contract_id', + context: { + contractId: requestedContractId, + operation, + }, + }); + } + + const createdContractId = createdEvent.contractId; + if (typeof createdContractId !== 'string' || createdContractId.length === 0) { + throw new OcpParseError('Invalid contract events response: created-event contract ID must be a non-empty string', { + source, + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'invalid_created_contract_id', + context: { + contractId: requestedContractId, + operation, + receivedValue: createdContractId, + }, + }); + } + + if (createdContractId !== requestedContractId) { + throw new OcpContractError('Created-event contract ID does not match the requested contract', { + contractId: requestedContractId, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'contract_id_mismatch', + context: { + operation, + expectedContractId: requestedContractId, + actualContractId: createdContractId, + }, + }); + } +} + export async function readSingleContract( client: LedgerJsonApiClient, params: GetByContractIdParams, @@ -187,6 +237,8 @@ export async function readSingleContract( ); } + assertCorrelatedCreatedContractId(createdEvent, params.contractId, options.operation); + if (createdEvent.createArgument == null) { throw missingContractDataError( options.missingDataError ?? 'contract', @@ -199,33 +251,6 @@ export async function readSingleContract( ); } - if (Object.prototype.hasOwnProperty.call(createdEvent, 'contractId')) { - if (typeof createdEvent.contractId !== 'string') { - throw new OcpParseError('Invalid contract events response: created-event contract ID must be a string', { - source: `contract ${params.contractId}.eventsResponse.created.createdEvent.contractId`, - code: OcpErrorCodes.INVALID_RESPONSE, - classification: 'invalid_created_contract_id', - context: { - contractId: params.contractId, - operation: options.operation, - receivedValue: createdEvent.contractId, - }, - }); - } - if (createdEvent.contractId !== params.contractId) { - throw new OcpContractError('Created-event contract ID does not match the requested contract', { - contractId: params.contractId, - code: OcpErrorCodes.SCHEMA_MISMATCH, - classification: 'contract_id_mismatch', - context: { - operation: options.operation, - expectedContractId: params.contractId, - actualContractId: createdEvent.contractId, - }, - }); - } - } - const templateId = typeof createdEvent.templateId === 'string' ? createdEvent.templateId : undefined; const packageName = typeof createdEvent.packageName === 'string' ? createdEvent.packageName : undefined; const templateIdentity = options.expectedTemplateId @@ -251,7 +276,7 @@ export async function readSingleContract( }); return { - contractId: params.contractId, + contractId: createdEvent.contractId, createArgument, createdEvent, ...(templateId !== undefined ? { templateId } : {}), diff --git a/test/batch/damlToOcfDispatcher.test.ts b/test/batch/damlToOcfDispatcher.test.ts index f97421ad..d1922538 100644 --- a/test/batch/damlToOcfDispatcher.test.ts +++ b/test/batch/damlToOcfDispatcher.test.ts @@ -34,10 +34,11 @@ import { const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-operator::party' } as const; -function buildCreatedEventsResponse(createArgument: Record, templateId?: string) { +function buildCreatedEventsResponse(createArgument: Record, templateId: string, contractId: string) { return { created: { createdEvent: { + contractId, ...(templateId ? { templateId } : {}), createArgument: { context: GENERATED_CONTEXT, ...createArgument }, }, @@ -339,7 +340,8 @@ describe('damlToOcf dispatcher', () => { comments: [], }, }, - Fairmint.OpenCapTable.OCF.WarrantRetraction.WarrantRetraction.templateId + Fairmint.OpenCapTable.OCF.WarrantRetraction.WarrantRetraction.templateId, + 'wrong-template-cid' ) ); const mockClient = { getEventsByContractId } as unknown as LedgerJsonApiClient; @@ -354,6 +356,7 @@ describe('damlToOcf dispatcher', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'acceptance-cid', templateId: Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance.templateId, createArgument: { acceptance_data: { @@ -385,6 +388,7 @@ describe('damlToOcf dispatcher', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'cancellation-cid', templateId: Fairmint.OpenCapTable.OCF.StockCancellation.StockCancellation.templateId, createArgument: { cancellation_data: { @@ -428,7 +432,8 @@ describe('damlToOcf dispatcher', () => { uri: 'https://example.com/document.pdf', }, }, - Fairmint.OpenCapTable.OCF.Document.Document.templateId + Fairmint.OpenCapTable.OCF.Document.Document.templateId, + 'document-lossy' ) ); @@ -463,7 +468,8 @@ describe('damlToOcf dispatcher', () => { ], }, }, - Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId + Fairmint.OpenCapTable.OCF.VestingTerms.VestingTerms.templateId, + 'vesting-duplicates' ) ); @@ -551,7 +557,7 @@ describe('damlToOcf dispatcher', () => { async (entityType, templateId, field, expectedCode, data) => { const getEventsByContractId = jest .fn() - .mockResolvedValue(buildCreatedEventsResponse({ [field]: data }, templateId)); + .mockResolvedValue(buildCreatedEventsResponse({ [field]: data }, templateId, `${entityType}-malformed`)); await expect( getEntityAsOcf( @@ -590,7 +596,8 @@ describe('damlToOcf dispatcher', () => { { issuer_data: issuerDataToDaml(createTestIssuerData({ id: 'iss-1', legal_name: 'Issuer Corp' })), }, - Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId + Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, + 'issuer-cid' ), ], [ @@ -601,7 +608,8 @@ describe('damlToOcf dispatcher', () => { { stakeholder_data: stakeholderDataToDaml(createTestStakeholderData({ id: 'sh-1' })), }, - Fairmint.OpenCapTable.OCF.Stakeholder.Stakeholder.templateId + Fairmint.OpenCapTable.OCF.Stakeholder.Stakeholder.templateId, + 'stakeholder-cid' ), ], [ @@ -612,7 +620,8 @@ describe('damlToOcf dispatcher', () => { { stock_class_data: stockClassDataToDaml(createTestStockClassData({ id: 'sc-1', name: 'Common' })), }, - Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId + Fairmint.OpenCapTable.OCF.StockClass.StockClass.templateId, + 'stock-class-cid' ), ], [ @@ -631,7 +640,8 @@ describe('damlToOcf dispatcher', () => { }) ), }, - Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId + Fairmint.OpenCapTable.OCF.StockIssuance.StockIssuance.templateId, + 'stock-issuance-cid' ), ], [ @@ -651,7 +661,8 @@ describe('damlToOcf dispatcher', () => { comments: [], }, }, - Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance.templateId + Fairmint.OpenCapTable.OCF.StockAcceptance.StockAcceptance.templateId, + 'stock-acceptance-cid' ), ], ])('%s forwards readAs to getEventsByContractId', async (_name, invoke, response) => { @@ -695,7 +706,8 @@ describe('damlToOcf dispatcher', () => { comments: [], }, }, - Fairmint.OpenCapTable.OCF.StockTransfer.StockTransfer.templateId + Fairmint.OpenCapTable.OCF.StockTransfer.StockTransfer.templateId, + 'transfer-cid' ) ); const mockClient = { getEventsByContractId } as unknown as LedgerJsonApiClient; diff --git a/test/client/OcpClient.test.ts b/test/client/OcpClient.test.ts index 581327b3..724c0462 100644 --- a/test/client/OcpClient.test.ts +++ b/test/client/OcpClient.test.ts @@ -501,6 +501,7 @@ describe('OcpClient OpenCapTable entity facade', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: `wrong-template-${entityType}`, templateId: wrongTemplateId, createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: {} }, }, @@ -528,6 +529,7 @@ describe('OcpClient OpenCapTable entity facade', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: `malformed-payload-${entityType}`, templateId: entry.templateId, createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: {} }, }, @@ -563,6 +565,7 @@ describe('OcpClient OpenCapTable entity facade', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'lossy-document', templateId: ENTITY_REGISTRY.document.templateId, createArgument: { context: GENERATED_CONTEXT, @@ -633,6 +636,7 @@ describe('OcpClient OpenCapTable entity facade', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'ratio-wrapper', templateId: ENTITY_REGISTRY.stockClassConversionRatioAdjustment.templateId, createArgument: malformed.createArgument, }, @@ -780,14 +784,17 @@ describe('OcpClient OpenCapTable entity facade', () => { }, ] as const)('namespace and getByObjectType reject $name', async ({ entityType, objectType, data, expectedCode }) => { const entry = ENTITY_REGISTRY[entityType]; - const getEventsByContractId = jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: entry.templateId, - createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: data }, + const getEventsByContractId = jest.fn(async ({ contractId }: { contractId: string }) => + Promise.resolve({ + created: { + createdEvent: { + contractId, + templateId: entry.templateId, + createArgument: { context: GENERATED_CONTEXT, [entry.dataField]: data }, + }, }, - }, - }); + }) + ); const ocp = new OcpClient({ ledger: { getEventsByContractId } as never }); await expect( @@ -811,6 +818,7 @@ describe('OcpClient OpenCapTable entity facade', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'issuer-plus', templateId: ENTITY_REGISTRY.issuer.templateId, createArgument: { context: GENERATED_CONTEXT, @@ -841,6 +849,7 @@ describe('OcpClient OpenCapTable entity facade', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'issuer-accessor', templateId: ENTITY_REGISTRY.issuer.templateId, createArgument, }, @@ -859,6 +868,7 @@ describe('OcpClient OpenCapTable entity facade', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'vesting-duplicates', templateId: ENTITY_REGISTRY.vestingTerms.templateId, createArgument: { context: GENERATED_CONTEXT, @@ -950,6 +960,7 @@ describe('OcpClient OpenCapTable entity facade', () => { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'issuer-cid-1', templateId: issuerTemplateId, createArgument: { context: GENERATED_CONTEXT, @@ -986,6 +997,7 @@ describe('OcpClient OpenCapTable entity facade', () => { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'stock-retraction-cid-1', templateId: Fairmint.OpenCapTable.OCF.StockRetraction.StockRetraction.templateId, createArgument: { context: GENERATED_CONTEXT, diff --git a/test/converters/documentConverters.test.ts b/test/converters/documentConverters.test.ts index 85656294..3ec1560e 100644 --- a/test/converters/documentConverters.test.ts +++ b/test/converters/documentConverters.test.ts @@ -127,6 +127,7 @@ describe('Document converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'document-lossy', templateId: Fairmint.OpenCapTable.OCF.Document.Document.templateId, createArgument: { context: GENERATED_CONTEXT, diff --git a/test/converters/exerciseConversionConverters.test.ts b/test/converters/exerciseConversionConverters.test.ts index 7371e76f..4d8d42e3 100644 --- a/test/converters/exerciseConversionConverters.test.ts +++ b/test/converters/exerciseConversionConverters.test.ts @@ -19,13 +19,16 @@ import type { OcfConvertibleConversion, OcfStockConversion, OcfWarrantExercise } // Mock client factory for DAML → OCF converter tests function createMockClient(createArgument: Record): LedgerJsonApiClient { return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - createArgument, + getEventsByContractId: jest.fn(async ({ contractId }: { contractId: string }) => + Promise.resolve({ + created: { + createdEvent: { + contractId, + createArgument, + }, }, - }, - }), + }) + ), } as unknown as LedgerJsonApiClient; } diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts index 68e1d2dc..32f7e02d 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -264,17 +264,20 @@ const BOUNDARY_CASES: readonly BoundaryCase[] = [ function mockLedger(entityType: OcfEntityType, data: Record): LedgerJsonApiClient { return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId: ENTITY_TEMPLATE_ID_MAP[entityType], - createArgument: { - context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, - [ENTITY_DATA_FIELD_MAP[entityType]]: data, + getEventsByContractId: jest.fn(async ({ contractId }: { contractId: string }) => + Promise.resolve({ + created: { + createdEvent: { + contractId, + templateId: ENTITY_TEMPLATE_ID_MAP[entityType], + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + [ENTITY_DATA_FIELD_MAP[entityType]]: data, + }, }, }, - }, - }), + }) + ), } as unknown as LedgerJsonApiClient; } @@ -887,6 +890,7 @@ describe('lossless direct and dedicated generated DAML readers', () => { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'contract-id', templateId: ENTITY_TEMPLATE_ID_MAP.stockClassConversionRatioAdjustment, createArgument: { context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, diff --git a/test/converters/issuerConverters.test.ts b/test/converters/issuerConverters.test.ts index d0a56f7e..28f1f893 100644 --- a/test/converters/issuerConverters.test.ts +++ b/test/converters/issuerConverters.test.ts @@ -564,6 +564,7 @@ describe('Issuer Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'issuer-unknown-initial-shares', templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, createArgument: { context: GENERATED_CONTEXT, @@ -594,6 +595,7 @@ describe('Issuer Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'issuer-malformed-comments', templateId: Fairmint.OpenCapTable.OCF.Issuer.Issuer.templateId, createArgument: { context: GENERATED_CONTEXT, diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 6d1cae15..648a5131 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -662,6 +662,7 @@ describe('Stock Class Adjustment Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'adj-cid', templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, @@ -721,6 +722,7 @@ describe('Stock Class Adjustment Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'adj-full-wrapper-cid', templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, @@ -744,6 +746,7 @@ describe('Stock Class Adjustment Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'adj-missing-wrapper', templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, @@ -809,6 +812,7 @@ describe('Stock Class Adjustment Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'adj-invalid-wrapper', templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, @@ -834,6 +838,7 @@ describe('Stock Class Adjustment Converters', () => { const getEventsByContractId = jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'adj-null-mechanism', templateId: Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment .templateId, @@ -908,22 +913,25 @@ describe('Stock Class Adjustment Converters', () => { describe('getStockConsolidationAsOcf', () => { function clientWithSecurityIds(securityIds: string[]): LedgerJsonApiClient { return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - createArgument: { - consolidation_data: { - id: 'consolidation-direct-001', - date: '2024-03-01T00:00:00.000Z', - security_ids: securityIds, - resulting_security_id: 'new-sec-direct-001', - reason_text: null, - comments: [], + getEventsByContractId: jest.fn(async ({ contractId }: { contractId: string }) => + Promise.resolve({ + created: { + createdEvent: { + contractId, + createArgument: { + consolidation_data: { + id: 'consolidation-direct-001', + date: '2024-03-01T00:00:00.000Z', + security_ids: securityIds, + resulting_security_id: 'new-sec-direct-001', + reason_text: null, + comments: [], + }, }, }, }, - }, - }), + }) + ), } as unknown as LedgerJsonApiClient; } diff --git a/test/converters/valuationVestingConverters.test.ts b/test/converters/valuationVestingConverters.test.ts index 2c066aa7..a540683b 100644 --- a/test/converters/valuationVestingConverters.test.ts +++ b/test/converters/valuationVestingConverters.test.ts @@ -1639,14 +1639,17 @@ describe('Vesting read-path wrapper compatibility', () => { function mockClientWithCreateArgument(createArgument: Record): LedgerJsonApiClient { return { - getEventsByContractId: jest.fn().mockResolvedValue({ - ...baseEventPayload, - created: { - createdEvent: { - createArgument, + getEventsByContractId: jest.fn(async ({ contractId }: { contractId: string }) => + Promise.resolve({ + ...baseEventPayload, + created: { + createdEvent: { + contractId, + createArgument, + }, }, - }, - }), + }) + ), } as unknown as LedgerJsonApiClient; } diff --git a/test/functions/cancellationReaders.test.ts b/test/functions/cancellationReaders.test.ts index 4d592bd7..992e6451 100644 --- a/test/functions/cancellationReaders.test.ts +++ b/test/functions/cancellationReaders.test.ts @@ -245,11 +245,14 @@ function createMockClient( 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: options.eventContractId ?? testCase.contractId } : {}), + ...(includeEventContractId ? { contractId: eventContractId } : {}), templateId: options.templateId ?? ENTITY_TEMPLATE_ID_MAP[testCase.entityType], ...(options.packageName !== undefined ? { packageName: options.packageName } : {}), createArgument: options.createArgument ?? createArgument(testCase, data), @@ -419,6 +422,43 @@ describe('decoder-backed cancellation readers', () => { } }); + 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( @@ -737,24 +777,36 @@ describe('decoder-backed cancellation readers', () => { }); }); - it.each(cancellationReaderCases)( - '$entityType rejects a malformed present created-event contract ID', - async (testCase) => { + 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: 17 })) + testCase.invoke(createMockClient(testCase, testCase.literalData(), { eventContractId })) ).rejects.toMatchObject({ name: 'OcpParseError', code: OcpErrorCodes.INVALID_RESPONSE, classification: 'invalid_created_contract_id', source: `contract ${testCase.contractId}.eventsResponse.created.createdEvent.contractId`, + context: { + contractId: testCase.contractId, + operation: expect.stringContaining('CancellationAsOcf'), + }, }); } - ); + }); - it.each(cancellationReaderCases)('$entityType accepts an omitted created-event contract ID', async (testCase) => { + it.each(cancellationReaderCases)('$entityType rejects an omitted created-event contract ID', async (testCase) => { await expect( testCase.invoke(createMockClient(testCase, testCase.literalData(), { includeEventContractId: false })) - ).resolves.toEqual({ event: testCase.expectedEvent, contractId: testCase.contractId }); + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.INVALID_RESPONSE, + classification: 'missing_created_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) => { diff --git a/test/functions/generatedDamlReaderValidation.test.ts b/test/functions/generatedDamlReaderValidation.test.ts index 6be17ae4..b422a6e6 100644 --- a/test/functions/generatedDamlReaderValidation.test.ts +++ b/test/functions/generatedDamlReaderValidation.test.ts @@ -65,17 +65,20 @@ function createMockClient( const { dataField } = ENTITY_REGISTRY[testCase.entityType]; const data = malformed ? { ...testCase.validData(), [testCase.field]: 17 } : testCase.validData(); return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent: { - templateId, - createArgument: { - context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, - [dataField]: data, + getEventsByContractId: jest.fn(async ({ contractId }: { contractId: string }) => + Promise.resolve({ + created: { + createdEvent: { + contractId, + templateId, + createArgument: { + context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, + [dataField]: data, + }, }, }, - }, - }), + }) + ), } as unknown as LedgerJsonApiClient; } @@ -252,6 +255,7 @@ describe('generated DAML reader validation', () => { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'stock-issuance-cid', templateId, createArgument: { context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, diff --git a/test/validation/damlToOcfValidation.test.ts b/test/validation/damlToOcfValidation.test.ts index 98b563fd..29a14577 100644 --- a/test/validation/damlToOcfValidation.test.ts +++ b/test/validation/damlToOcfValidation.test.ts @@ -60,11 +60,13 @@ function createMockClient( createdEvent.packageName = ledgerMeta.packageName; } return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { - createdEvent, - }, - }), + getEventsByContractId: jest.fn(async ({ contractId }: { contractId: string }) => + Promise.resolve({ + created: { + createdEvent: { ...createdEvent, contractId }, + }, + }) + ), } as unknown as LedgerJsonApiClient; } @@ -732,6 +734,7 @@ describe('DAML to OCF Validation', () => { getEventsByContractId: jest.fn().mockResolvedValue({ created: { createdEvent: { + contractId: 'relationship-ambiguous', templateId: MOCK_LEDGER_TEMPLATE_IDS.stakeholderRelationshipChangeEvent, createArgument: { context: { issuer: 'issuer::party', system_operator: 'system-operator::party' }, diff --git a/test/validation/generatedDamlBoundary.test.ts b/test/validation/generatedDamlBoundary.test.ts index 9002b325..4327231b 100644 --- a/test/validation/generatedDamlBoundary.test.ts +++ b/test/validation/generatedDamlBoundary.test.ts @@ -24,9 +24,11 @@ const GENERATED_CONTEXT = { issuer: 'issuer::party', system_operator: 'system-op function mockClient(templateId: string, createArgument: unknown): LedgerJsonApiClient { return { - getEventsByContractId: jest.fn().mockResolvedValue({ - created: { createdEvent: { templateId, createArgument } }, - }), + getEventsByContractId: jest.fn(async ({ contractId }: { contractId: string }) => + Promise.resolve({ + created: { createdEvent: { contractId, templateId, createArgument } }, + }) + ), } as unknown as LedgerJsonApiClient; } From 9e50df21354918ec4b34222c3ebdb98b4698cd45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Jul 2026 19:46:38 +0000 Subject: [PATCH 6/7] Auto-fix: build changes --- test/mocks/fairmint-canton-node-sdk.ts | 3 ++- test/utils/getFeaturedAppRightContractDetails.test.ts | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/test/mocks/fairmint-canton-node-sdk.ts b/test/mocks/fairmint-canton-node-sdk.ts index 0c4dcc89..7f7be419 100644 --- a/test/mocks/fairmint-canton-node-sdk.ts +++ b/test/mocks/fairmint-canton-node-sdk.ts @@ -219,7 +219,8 @@ export async function getFeaturedAppRightContractDetails(validatorApi: Validator if (!featuredAppRight) { throw new Error(`No featured app right found for party ${partyId}`); } - const synchronizerIdFromFeatured = typeof featuredAppRight.domain_id === 'string' ? featuredAppRight.domain_id : undefined; + const synchronizerIdFromFeatured = + typeof featuredAppRight.domain_id === 'string' ? featuredAppRight.domain_id : undefined; let synchronizerId = synchronizerIdFromFeatured; if (!synchronizerId) { // The featured-apps endpoint may not include the synchronizer/domain id. diff --git a/test/utils/getFeaturedAppRightContractDetails.test.ts b/test/utils/getFeaturedAppRightContractDetails.test.ts index 2d021441..f563591a 100644 --- a/test/utils/getFeaturedAppRightContractDetails.test.ts +++ b/test/utils/getFeaturedAppRightContractDetails.test.ts @@ -37,9 +37,7 @@ describe('getFeaturedAppRightContractDetails', () => { }, }; (responseWithDomain.featured_app_right as Record).domain_id = 'featured-domain'; - jest - .spyOn(validatorApi, 'lookupFeaturedAppRight') - .mockResolvedValue(responseWithDomain); + jest.spyOn(validatorApi, 'lookupFeaturedAppRight').mockResolvedValue(responseWithDomain); const featured = await getFeaturedAppRightContractDetails(validatorApi); From a50e583f521dba5e308bf3b21132d53ec1d35156 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 10:00:21 -0400 Subject: [PATCH 7/7] fix: enforce cancellation DAML invariants --- .../createConvertibleCancellation.ts | 24 +- .../convertibleCancellation/damlToOcf.ts | 16 +- .../createEquityCompensationCancellation.ts | 18 +- .../damlToOcf.ts | 4 +- .../OpenCapTable/shared/cancellationValues.ts | 318 ++++++++++++++ .../createStockCancellation.ts | 18 +- .../stockCancellation/damlToOcf.ts | 4 +- .../createWarrantCancellation.ts | 18 +- .../warrantCancellation/damlToOcf.ts | 4 +- src/utils/typeConversions.ts | 105 ----- test/functions/cancellationReaders.test.ts | 395 +++++++++++++++++- 11 files changed, 711 insertions(+), 213 deletions(-) create mode 100644 src/functions/OpenCapTable/shared/cancellationValues.ts diff --git a/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts b/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts index 2952519e..94e24dfd 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/createConvertibleCancellation.ts @@ -1,27 +1,7 @@ import type { OcfConvertibleCancellation } from '../../../types'; import type { PkgConvertibleCancellationOcfData } from '../../../types/daml'; -import { - cancellationBalanceSecurityIdToDaml, - dateStringToDAMLTime, - monetaryToDaml, -} from '../../../utils/typeConversions'; -import { assertCanonicalJsonGraph, optionalStringArrayToDaml, requireMonetary } from '../shared/ocfValues'; +import { convertibleCancellationValuesToDaml } from '../shared/cancellationValues'; export function convertibleCancellationDataToDaml(d: OcfConvertibleCancellation): PkgConvertibleCancellationOcfData { - assertCanonicalJsonGraph(d, 'convertibleCancellation', { rejectUndefined: true }); - return { - id: d.id, - security_id: d.security_id, - amount: monetaryToDaml( - requireMonetary(d.amount, 'convertibleCancellation.amount'), - 'convertibleCancellation.amount' - ), - reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date, 'convertibleCancellation.date'), - balance_security_id: cancellationBalanceSecurityIdToDaml( - d.balance_security_id, - 'convertibleCancellation.balance_security_id' - ), - comments: optionalStringArrayToDaml(d.comments, 'convertibleCancellation.comments'), - }; + return convertibleCancellationValuesToDaml(d); } diff --git a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts index cc6a938d..e28f742c 100644 --- a/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/convertibleCancellation/damlToOcf.ts @@ -4,8 +4,7 @@ import type { OcfConvertibleCancellation } from '../../../types'; import type { PkgConvertibleCancellationOcfData } from '../../../types/daml'; -import { cancellationBalanceSecurityIdFromDaml, damlTimeToDateString } from '../../../utils/typeConversions'; -import { requireMonetary } from '../shared/ocfValues'; +import { convertibleCancellationValuesFromDaml } from '../shared/cancellationValues'; /** Exact generated DAML input accepted by the convertible-cancellation converter. */ export type DamlConvertibleCancellationData = PkgConvertibleCancellationOcfData; @@ -17,19 +16,8 @@ export type DamlConvertibleCancellationData = PkgConvertibleCancellationOcfData; * @returns The native OCF ConvertibleCancellation object */ export function damlConvertibleCancellationToNative(d: DamlConvertibleCancellationData): OcfConvertibleCancellation { - const balanceSecurityId = cancellationBalanceSecurityIdFromDaml( - d.balance_security_id, - 'convertibleCancellation.balance_security_id' - ); - return { + ...convertibleCancellationValuesFromDaml(d), object_type: 'TX_CONVERTIBLE_CANCELLATION', - id: d.id, - date: damlTimeToDateString(d.date, 'convertibleCancellation.date'), - security_id: d.security_id, - amount: requireMonetary(d.amount, 'convertibleCancellation.amount'), - reason_text: d.reason_text, - ...(balanceSecurityId !== undefined ? { balance_security_id: balanceSecurityId } : {}), - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), }; } diff --git a/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts b/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts index 008e3bdb..c2f50470 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/createEquityCompensationCancellation.ts @@ -1,23 +1,9 @@ import type { OcfEquityCompensationCancellation } from '../../../types'; import type { PkgEquityCompensationCancellationOcfData } from '../../../types/daml'; -import { canonicalizeNonnegativeDamlNumeric10 } from '../../../utils/damlNumeric'; -import { cancellationBalanceSecurityIdToDaml, dateStringToDAMLTime } from '../../../utils/typeConversions'; -import { assertCanonicalJsonGraph, optionalStringArrayToDaml } from '../shared/ocfValues'; +import { quantityCancellationValuesToDaml } from '../shared/cancellationValues'; export function equityCompensationCancellationDataToDaml( d: OcfEquityCompensationCancellation ): PkgEquityCompensationCancellationOcfData { - assertCanonicalJsonGraph(d, 'equityCompensationCancellation', { rejectUndefined: true }); - return { - id: d.id, - security_id: d.security_id, - reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date, 'equityCompensationCancellation.date'), - quantity: canonicalizeNonnegativeDamlNumeric10(d.quantity, 'equityCompensationCancellation.quantity'), - balance_security_id: cancellationBalanceSecurityIdToDaml( - d.balance_security_id, - 'equityCompensationCancellation.balance_security_id' - ), - comments: optionalStringArrayToDaml(d.comments, 'equityCompensationCancellation.comments'), - }; + 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 5d24b69b..ef7cf7a1 100644 --- a/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/equityCompensationCancellation/damlToOcf.ts @@ -4,7 +4,7 @@ import type { OcfEquityCompensationCancellation } from '../../../types'; import type { PkgEquityCompensationCancellationOcfData } from '../../../types/daml'; -import { quantityCancellationToNative } from '../../../utils/typeConversions'; +import { quantityCancellationValuesFromDaml } from '../shared/cancellationValues'; /** * DAML EquityCompensationCancellation data structure. @@ -22,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/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/stockCancellation/createStockCancellation.ts b/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts index 26bbc1af..b4d65e4a 100644 --- a/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts +++ b/src/functions/OpenCapTable/stockCancellation/createStockCancellation.ts @@ -1,21 +1,7 @@ import type { OcfStockCancellation } from '../../../types'; import type { PkgStockCancellationOcfData } from '../../../types/daml'; -import { canonicalizeNonnegativeDamlNumeric10 } from '../../../utils/damlNumeric'; -import { cancellationBalanceSecurityIdToDaml, dateStringToDAMLTime } from '../../../utils/typeConversions'; -import { assertCanonicalJsonGraph, optionalStringArrayToDaml } from '../shared/ocfValues'; +import { quantityCancellationValuesToDaml } from '../shared/cancellationValues'; export function stockCancellationDataToDaml(d: OcfStockCancellation): PkgStockCancellationOcfData { - assertCanonicalJsonGraph(d, 'stockCancellation', { rejectUndefined: true }); - return { - id: d.id, - security_id: d.security_id, - reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date, 'stockCancellation.date'), - quantity: canonicalizeNonnegativeDamlNumeric10(d.quantity, 'stockCancellation.quantity'), - balance_security_id: cancellationBalanceSecurityIdToDaml( - d.balance_security_id, - 'stockCancellation.balance_security_id' - ), - comments: optionalStringArrayToDaml(d.comments, 'stockCancellation.comments'), - }; + return quantityCancellationValuesToDaml(d, 'stockCancellation', 'TX_STOCK_CANCELLATION'); } diff --git a/src/functions/OpenCapTable/stockCancellation/damlToOcf.ts b/src/functions/OpenCapTable/stockCancellation/damlToOcf.ts index 7b6a2c86..e8aa1112 100644 --- a/src/functions/OpenCapTable/stockCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockCancellation/damlToOcf.ts @@ -4,7 +4,7 @@ import type { OcfStockCancellation } from '../../../types'; import type { PkgStockCancellationOcfData } from '../../../types/daml'; -import { quantityCancellationToNative } from '../../../utils/typeConversions'; +import { quantityCancellationValuesFromDaml } from '../shared/cancellationValues'; /** * DAML StockCancellation data structure. @@ -20,7 +20,7 @@ export type DamlStockCancellationData = PkgStockCancellationOcfData; */ 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/warrantCancellation/createWarrantCancellation.ts b/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts index a5e144bd..a1c07e82 100644 --- a/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts +++ b/src/functions/OpenCapTable/warrantCancellation/createWarrantCancellation.ts @@ -1,21 +1,7 @@ import type { OcfWarrantCancellation } from '../../../types'; import type { PkgWarrantCancellationOcfData } from '../../../types/daml'; -import { canonicalizeNonnegativeDamlNumeric10 } from '../../../utils/damlNumeric'; -import { cancellationBalanceSecurityIdToDaml, dateStringToDAMLTime } from '../../../utils/typeConversions'; -import { assertCanonicalJsonGraph, optionalStringArrayToDaml } from '../shared/ocfValues'; +import { quantityCancellationValuesToDaml } from '../shared/cancellationValues'; export function warrantCancellationDataToDaml(d: OcfWarrantCancellation): PkgWarrantCancellationOcfData { - assertCanonicalJsonGraph(d, 'warrantCancellation', { rejectUndefined: true }); - return { - id: d.id, - security_id: d.security_id, - reason_text: d.reason_text, - date: dateStringToDAMLTime(d.date, 'warrantCancellation.date'), - quantity: canonicalizeNonnegativeDamlNumeric10(d.quantity, 'warrantCancellation.quantity'), - balance_security_id: cancellationBalanceSecurityIdToDaml( - d.balance_security_id, - 'warrantCancellation.balance_security_id' - ), - comments: optionalStringArrayToDaml(d.comments, 'warrantCancellation.comments'), - }; + return quantityCancellationValuesToDaml(d, 'warrantCancellation', 'TX_WARRANT_CANCELLATION'); } diff --git a/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts b/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts index 927ef9de..e4d60514 100644 --- a/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts +++ b/src/functions/OpenCapTable/warrantCancellation/damlToOcf.ts @@ -4,7 +4,7 @@ import type { OcfWarrantCancellation } from '../../../types'; import type { PkgWarrantCancellationOcfData } from '../../../types/daml'; -import { quantityCancellationToNative } from '../../../utils/typeConversions'; +import { quantityCancellationValuesFromDaml } from '../shared/cancellationValues'; /** * DAML WarrantCancellation data structure. @@ -20,7 +20,7 @@ export type DamlWarrantCancellationData = PkgWarrantCancellationOcfData; */ export function damlWarrantCancellationToNative(d: DamlWarrantCancellationData): OcfWarrantCancellation { return { - ...quantityCancellationToNative(d, 'warrantCancellation.date'), + ...quantityCancellationValuesFromDaml(d, 'warrantCancellation'), object_type: 'TX_WARRANT_CANCELLATION', }; } diff --git a/src/utils/typeConversions.ts b/src/utils/typeConversions.ts index 57cf52a2..6813952f 100644 --- a/src/utils/typeConversions.ts +++ b/src/utils/typeConversions.ts @@ -883,108 +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 { - readonly id: string; - readonly date: string; - readonly security_id: string; - readonly quantity: string; - readonly reason_text: string; - readonly balance_security_id: string | null; - readonly 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[]; -} - -/** Decode the generated DAML Optional used for cancellation balance securities without truthiness coercion. */ -export function cancellationBalanceSecurityIdFromDaml(value: unknown, fieldPath: string): string | undefined { - if (value === undefined) { - throw new OcpValidationError(fieldPath, `${fieldPath} must be null or a non-empty string`, { - code: OcpErrorCodes.REQUIRED_FIELD_MISSING, - expectedType: 'null or non-empty string', - receivedValue: value, - }); - } - if (value === null) return undefined; - if (typeof value !== 'string') { - throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'null or non-empty string', - receivedValue: value, - }); - } - if (value.length === 0) { - throw new OcpValidationError(fieldPath, `${fieldPath} must not be empty`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'null or non-empty string', - receivedValue: value, - }); - } - return value; -} - -/** Encode an optional OCF cancellation balance security without collapsing an explicit empty identifier. */ -export function cancellationBalanceSecurityIdToDaml(value: unknown, fieldPath: string): string | null { - if (value === undefined) return null; - if (typeof value !== 'string') { - throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { - code: OcpErrorCodes.INVALID_TYPE, - expectedType: 'non-empty string or omitted property', - receivedValue: value, - }); - } - if (value.length === 0) { - throw new OcpValidationError(fieldPath, `${fieldPath} must not be empty`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType: 'non-empty string or omitted property', - receivedValue: value, - }); - } - return value; -} - -/** - * 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 { - const fieldPathSeparator = dateFieldPath.lastIndexOf('.'); - const fieldPathPrefix = fieldPathSeparator >= 0 ? dateFieldPath.slice(0, fieldPathSeparator + 1) : ''; - const balanceSecurityId = cancellationBalanceSecurityIdFromDaml( - d.balance_security_id, - `${fieldPathPrefix}balance_security_id` - ); - - return { - id: d.id, - date: damlTimeToDateString(d.date, dateFieldPath), - security_id: d.security_id, - quantity: canonicalizeNonnegativeDamlNumeric10(d.quantity, `${fieldPathPrefix}quantity`), - reason_text: d.reason_text, - ...(balanceSecurityId !== undefined ? { balance_security_id: balanceSecurityId } : {}), - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), - }; -} diff --git a/test/functions/cancellationReaders.test.ts b/test/functions/cancellationReaders.test.ts index 02ca6b31..136bd171 100644 --- a/test/functions/cancellationReaders.test.ts +++ b/test/functions/cancellationReaders.test.ts @@ -10,12 +10,16 @@ import { } 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, @@ -54,6 +58,7 @@ interface CancellationReaderCase { 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 @@ -94,6 +99,7 @@ const cancellationReaderCases: readonly CancellationReaderCase[] = [ 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, @@ -134,6 +140,7 @@ const cancellationReaderCases: readonly CancellationReaderCase[] = [ 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, @@ -174,6 +181,7 @@ const cancellationReaderCases: readonly CancellationReaderCase[] = [ 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, @@ -215,6 +223,7 @@ const cancellationReaderCases: readonly CancellationReaderCase[] = [ 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, @@ -329,17 +338,69 @@ describe('decoder-backed cancellation readers', () => { } ); - it.each(cancellationReaderCases)('$entityType writer preserves canonical falsy values', (testCase) => { + 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'], + comments: ['0', 'false'], }) as Record; expect(written.balance_security_id).toBe(`${testCase.entityType}-balance`); - expect(written.comments).toEqual(['', '0', 'false']); + 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) => { @@ -595,6 +656,107 @@ describe('decoder-backed cancellation readers', () => { }); }); + 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(); @@ -627,19 +789,6 @@ describe('decoder-backed cancellation readers', () => { }); }); - it('validates canonical convertible monetary currency semantics', async () => { - const testCase = cancellationReaderCases[1]; - if (testCase === undefined) throw new Error('Missing convertible cancellation case'); - const data = testCase.literalData(); - data.amount = { amount: '1', currency: 'usd' }; - await expect(testCase.invoke(createMockClient(testCase, data))).rejects.toMatchObject({ - name: 'OcpValidationError', - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'convertibleCancellation.amount.currency', - receivedValue: 'usd', - }); - }); - 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' })) @@ -652,9 +801,9 @@ describe('decoder-backed cancellation readers', () => { }); it.each(cancellationReaderCases)( - '$entityType preserves falsy comments and omits only an empty list', + '$entityType preserves non-empty falsy-looking comments and omits only an empty list', async (testCase) => { - const comments = ['', '0', 'false']; + const comments = ['0', 'false']; await expect( testCase.invoke(createMockClient(testCase, { ...testCase.literalData(), comments })) ).resolves.toEqual({ @@ -669,6 +818,216 @@ describe('decoder-backed cancellation readers', () => { } ); + 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';