From 1f8931fc037d1e5637874e9000c5c1738bae8e22 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 09:31:19 -0400 Subject: [PATCH 1/3] Validate stock corporate action reader payloads --- ...mlToStockClassConversionRatioAdjustment.ts | 63 +- ...tockClassConversionRatioAdjustmentAsOcf.ts | 59 +- .../stockClassSplit/damlToStockClassSplit.ts | 23 +- .../getStockClassSplitAsOcf.ts | 44 +- .../damlToStockConsolidation.ts | 21 +- .../getStockConsolidationAsOcf.ts | 34 +- .../stockReissuance/damlToStockReissuance.ts | 13 +- .../getStockReissuanceAsOcf.ts | 36 +- .../OpenCapTable/stockRepurchase/damlToOcf.ts | 16 +- .../getStockRepurchaseAsOcf.ts | 35 +- .../stockClassAdjustmentConverters.test.ts | 7 +- .../stockCorporateActionReaders.types.ts | 206 +++++ .../stockCorporateActionReaders.test.ts | 785 ++++++++++++++++++ .../stockCorporateActionReaders.types.ts | 206 +++++ 14 files changed, 1297 insertions(+), 251 deletions(-) create mode 100644 test/declarations/stockCorporateActionReaders.types.ts create mode 100644 test/functions/stockCorporateActionReaders.test.ts create mode 100644 test/types/stockCorporateActionReaders.types.ts diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index 30b93503..0c5f3688 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -2,23 +2,38 @@ * DAML to OCF converter for StockClassConversionRatioAdjustment. */ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; /** DAML StockClassConversionRatioAdjustmentOcfData structure */ -export interface DamlStockClassConversionRatioAdjustmentData { - id: string; - date: string; - stock_class_id: string; - new_ratio_conversion_mechanism: { - conversion_price: { amount: string; currency: string }; - ratio: { - numerator: string | number; - denominator: string | number; - }; - rounding_type: string; - }; - comments: string[]; +export type DamlStockClassConversionRatioAdjustmentData = DamlDataTypeFor<'stockClassConversionRatioAdjustment'>; + +function unreachableRoundingType(value: never): never { + throw new OcpValidationError( + 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.rounding_type', + 'Unsupported DAML rounding type', + { + code: OcpErrorCodes.INVALID_TYPE, + receivedValue: value, + } + ); +} + +function roundingTypeToNative( + value: DamlStockClassConversionRatioAdjustmentData['new_ratio_conversion_mechanism']['rounding_type'] +): 'CEILING' | 'FLOOR' | 'NORMAL' { + switch (value) { + case 'OcfRoundingCeiling': + return 'CEILING'; + case 'OcfRoundingFloor': + return 'FLOOR'; + case 'OcfRoundingNormal': + return 'NORMAL'; + default: + return unreachableRoundingType(value); + } } /** @@ -29,15 +44,6 @@ export interface DamlStockClassConversionRatioAdjustmentData { export function damlStockClassConversionRatioAdjustmentToNative( d: DamlStockClassConversionRatioAdjustmentData ): OcfStockClassConversionRatioAdjustment { - const numeratorStr = - typeof d.new_ratio_conversion_mechanism.ratio.numerator === 'number' - ? d.new_ratio_conversion_mechanism.ratio.numerator.toString() - : d.new_ratio_conversion_mechanism.ratio.numerator; - const denominatorStr = - typeof d.new_ratio_conversion_mechanism.ratio.denominator === 'number' - ? d.new_ratio_conversion_mechanism.ratio.denominator.toString() - : d.new_ratio_conversion_mechanism.ratio.denominator; - return { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', id: d.id, @@ -47,16 +53,11 @@ export function damlStockClassConversionRatioAdjustmentToNative( type: 'RATIO_CONVERSION', conversion_price: damlMonetaryToNative(d.new_ratio_conversion_mechanism.conversion_price), ratio: { - numerator: normalizeNumericString(numeratorStr), - denominator: normalizeNumericString(denominatorStr), + numerator: normalizeNumericString(d.new_ratio_conversion_mechanism.ratio.numerator), + denominator: normalizeNumericString(d.new_ratio_conversion_mechanism.ratio.denominator), }, - rounding_type: - d.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingCeiling' - ? 'CEILING' - : d.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingFloor' - ? 'FLOOR' - : 'NORMAL', + rounding_type: roundingTypeToNative(d.new_ratio_conversion_mechanism.rounding_type), }, - ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), + ...(d.comments.length ? { comments: d.comments } : {}), }; } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts index cc3cfa75..cd5e2bb8 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -1,22 +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 { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockClassConversionRatioAdjustmentToNative } from './damlToStockClassConversionRatioAdjustment'; -export interface OcfStockClassConversionRatioAdjustmentEvent { - object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT'; - id: string; - date: string; - stock_class_id: string; - new_ratio_conversion_mechanism: { - type: 'RATIO_CONVERSION'; - conversion_price: { amount: string; currency: string }; - ratio: { numerator: string; denominator: string }; - rounding_type: 'NORMAL' | 'CEILING' | 'FLOOR'; - }; - comments?: string[]; -} +export type OcfStockClassConversionRatioAdjustmentEvent = OcfStockClassConversionRatioAdjustment; export type GetStockClassConversionRatioAdjustmentAsOcfParams = GetByContractIdParams; export interface GetStockClassConversionRatioAdjustmentAsOcfResult { @@ -24,48 +14,15 @@ export interface GetStockClassConversionRatioAdjustmentAsOcfResult { contractId: string; } -/** Type alias for DAML StockClassConversionRatioAdjustment contract createArgument */ -type StockClassConversionRatioAdjustmentCreateArgument = - Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment; - export async function getStockClassConversionRatioAdjustmentAsOcf( client: LedgerJsonApiClient, params: GetStockClassConversionRatioAdjustmentAsOcfParams ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getStockClassConversionRatioAdjustmentAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockClassConversionRatioAdjustment, }); - const contract = createArgument as StockClassConversionRatioAdjustmentCreateArgument; - const data = contract.adjustment_data; - - // Extract numerator and denominator from new_ratio_conversion_mechanism.ratio (OcfRatio type) - const newRatioNumerator = data.new_ratio_conversion_mechanism.ratio.numerator as string | number; - const newRatioNumeratorStr = typeof newRatioNumerator === 'number' ? newRatioNumerator.toString() : newRatioNumerator; - - const newRatioDenominator = data.new_ratio_conversion_mechanism.ratio.denominator as string | number; - const newRatioDenominatorStr = - typeof newRatioDenominator === 'number' ? newRatioDenominator.toString() : newRatioDenominator; - - const event: OcfStockClassConversionRatioAdjustmentEvent = { - object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: data.id, - date: damlTimeToDateString(data.date, 'stockClassConversionRatioAdjustment.date'), - stock_class_id: data.stock_class_id, - new_ratio_conversion_mechanism: { - type: 'RATIO_CONVERSION', - conversion_price: damlMonetaryToNative(data.new_ratio_conversion_mechanism.conversion_price), - ratio: { - numerator: normalizeNumericString(newRatioNumeratorStr), - denominator: normalizeNumericString(newRatioDenominatorStr), - }, - rounding_type: - data.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingCeiling' - ? 'CEILING' - : data.new_ratio_conversion_mechanism.rounding_type === 'OcfRoundingFloor' - ? 'FLOOR' - : 'NORMAL', - }, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const data = extractAndDecodeDamlEntityData('stockClassConversionRatioAdjustment', createArgument); + const event = damlStockClassConversionRatioAdjustmentToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts index 65a32abf..72b7d68e 100644 --- a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts +++ b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts @@ -4,18 +4,10 @@ import type { OcfStockClassSplit } from '../../../types/native'; import { damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; /** DAML StockClassSplitOcfData structure */ -export interface DamlStockClassSplitData { - id: string; - date: string; - stock_class_id: string; - split_ratio: { - numerator: string | number; - denominator: string | number; - }; - comments: string[]; -} +export type DamlStockClassSplitData = DamlDataTypeFor<'stockClassSplit'>; /** * Convert DAML StockClassSplit data to native OCF format. @@ -23,20 +15,15 @@ export interface DamlStockClassSplitData { * Handles the nested OcfRatio structure and normalizes numeric strings. */ export function damlStockClassSplitToNative(d: DamlStockClassSplitData): OcfStockClassSplit { - const numeratorStr = - typeof d.split_ratio.numerator === 'number' ? d.split_ratio.numerator.toString() : d.split_ratio.numerator; - const denominatorStr = - typeof d.split_ratio.denominator === 'number' ? d.split_ratio.denominator.toString() : d.split_ratio.denominator; - return { object_type: 'TX_STOCK_CLASS_SPLIT', id: d.id, date: damlTimeToDateString(d.date, 'stockClassSplit.date'), stock_class_id: d.stock_class_id, split_ratio: { - numerator: normalizeNumericString(numeratorStr), - denominator: normalizeNumericString(denominatorStr), + numerator: normalizeNumericString(d.split_ratio.numerator), + denominator: normalizeNumericString(d.split_ratio.denominator), }, - ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), + ...(d.comments.length ? { comments: d.comments } : {}), }; } diff --git a/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts b/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts index b291ae19..01458235 100644 --- a/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts @@ -1,17 +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 { OcfStockClassSplit } from '../../../types/native'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockClassSplitToNative } from './damlToStockClassSplit'; -export interface OcfStockClassSplitEvent { - object_type: 'TX_STOCK_CLASS_SPLIT'; - id: string; - date: string; - stock_class_id: string; - split_ratio: { numerator: string; denominator: string }; - comments?: string[]; -} +export type OcfStockClassSplitEvent = OcfStockClassSplit; export type GetStockClassSplitAsOcfParams = GetByContractIdParams; export interface GetStockClassSplitAsOcfResult { @@ -19,38 +14,15 @@ export interface GetStockClassSplitAsOcfResult { contractId: string; } -/** Type alias for DAML StockClassSplit contract createArgument */ -type StockClassSplitCreateArgument = Fairmint.OpenCapTable.OCF.StockClassSplit.StockClassSplit; - export async function getStockClassSplitAsOcf( client: LedgerJsonApiClient, params: GetStockClassSplitAsOcfParams ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getStockClassSplitAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockClassSplit, }); - const contract = createArgument as StockClassSplitCreateArgument; - const data = contract.split_data; - - // Extract numerator and denominator from the split_ratio (OcfRatio type) - const splitRatioNumerator = data.split_ratio.numerator as string | number; - const splitRatioNumeratorStr = - typeof splitRatioNumerator === 'number' ? splitRatioNumerator.toString() : splitRatioNumerator; - - const splitRatioDenominator = data.split_ratio.denominator as string | number; - const splitRatioDenominatorStr = - typeof splitRatioDenominator === 'number' ? splitRatioDenominator.toString() : splitRatioDenominator; - - const event: OcfStockClassSplitEvent = { - object_type: 'TX_STOCK_CLASS_SPLIT', - id: data.id, - date: damlTimeToDateString(data.date, 'stockClassSplit.date'), - stock_class_id: data.stock_class_id, - split_ratio: { - numerator: normalizeNumericString(splitRatioNumeratorStr), - denominator: normalizeNumericString(splitRatioDenominatorStr), - }, - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const data = extractAndDecodeDamlEntityData('stockClassSplit', createArgument); + const event = damlStockClassSplitToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts index d01f95a6..93b03f61 100644 --- a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts +++ b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts @@ -2,18 +2,13 @@ * DAML to OCF converter for StockConsolidation. */ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; import type { OcfStockConsolidation } from '../../../types/native'; import { damlTimeToDateString, toNonEmptyArray } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; /** DAML StockConsolidationOcfData structure */ -export interface DamlStockConsolidationData { - id: string; - date: string; - security_ids: string[]; - resulting_security_id: string; // DAML has singular - reason_text: string | null; - comments: string[]; -} +export type DamlStockConsolidationData = DamlDataTypeFor<'stockConsolidation'>; /** * Convert DAML StockConsolidation data to native OCF format. @@ -21,6 +16,14 @@ export interface DamlStockConsolidationData { * Converts DAML StockConsolidation data to canonical OCF format. */ export function damlStockConsolidationToNative(d: DamlStockConsolidationData): OcfStockConsolidation { + if (new Set(d.security_ids).size !== d.security_ids.length) { + throw new OcpValidationError('stockConsolidation.security_ids', 'Array items must be unique', { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'array of unique security IDs', + receivedValue: d.security_ids, + }); + } + return { object_type: 'TX_STOCK_CONSOLIDATION', id: d.id, @@ -28,6 +31,6 @@ export function damlStockConsolidationToNative(d: DamlStockConsolidationData): O security_ids: toNonEmptyArray(d.security_ids, 'stockConsolidation.security_ids'), resulting_security_id: d.resulting_security_id, ...(d.reason_text ? { reason_text: d.reason_text } : {}), - ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), + ...(d.comments.length ? { comments: d.comments } : {}), }; } diff --git a/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts b/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts index e1621e01..1d0499f2 100644 --- a/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts +++ b/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts @@ -1,18 +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 } from '../../../utils/typeConversions'; +import type { OcfStockConsolidation } from '../../../types/native'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockConsolidationToNative } from './damlToStockConsolidation'; -export interface OcfStockConsolidationEvent { - object_type: 'TX_STOCK_CONSOLIDATION'; - id: string; - date: string; - security_ids: string[]; - resulting_security_id: string; - reason_text?: string; - comments?: string[]; -} +export type OcfStockConsolidationEvent = OcfStockConsolidation; export type GetStockConsolidationAsOcfParams = GetByContractIdParams; export interface GetStockConsolidationAsOcfResult { @@ -20,27 +14,15 @@ export interface GetStockConsolidationAsOcfResult { contractId: string; } -/** Type alias for DAML StockConsolidation contract createArgument */ -type StockConsolidationCreateArgument = Fairmint.OpenCapTable.OCF.StockConsolidation.StockConsolidation; - export async function getStockConsolidationAsOcf( client: LedgerJsonApiClient, params: GetStockConsolidationAsOcfParams ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getStockConsolidationAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockConsolidation, }); - const contract = createArgument as StockConsolidationCreateArgument; - const data = contract.consolidation_data; - - const event: OcfStockConsolidationEvent = { - object_type: 'TX_STOCK_CONSOLIDATION', - id: data.id, - date: damlTimeToDateString(data.date, 'stockConsolidation.date'), - security_ids: data.security_ids, - resulting_security_id: data.resulting_security_id, - ...(data.reason_text ? { reason_text: data.reason_text } : {}), - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const data = extractAndDecodeDamlEntityData('stockConsolidation', createArgument); + const event = damlStockConsolidationToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts index b524831f..1d7a7f5a 100644 --- a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts +++ b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts @@ -4,17 +4,10 @@ import type { OcfStockReissuance } from '../../../types/native'; import { damlTimeToDateString } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; /** DAML StockReissuanceOcfData structure */ -export interface DamlStockReissuanceData { - id: string; - date: string; - security_id: string; - resulting_security_ids: string[]; - reason_text: string | null; - split_transaction_id: string | null; - comments: string[]; -} +export type DamlStockReissuanceData = DamlDataTypeFor<'stockReissuance'>; /** * Convert DAML StockReissuance data to native OCF format. @@ -28,6 +21,6 @@ export function damlStockReissuanceToNative(d: DamlStockReissuanceData): OcfStoc resulting_security_ids: d.resulting_security_ids, ...(d.reason_text ? { reason_text: d.reason_text } : {}), ...(d.split_transaction_id ? { split_transaction_id: d.split_transaction_id } : {}), - ...(Array.isArray(d.comments) && d.comments.length ? { comments: d.comments } : {}), + ...(d.comments.length ? { comments: d.comments } : {}), }; } diff --git a/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts b/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts index ae77a40a..8539217d 100644 --- a/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.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 } from '../../../utils/typeConversions'; +import type { OcfStockReissuance } from '../../../types/native'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockReissuanceToNative } from './damlToStockReissuance'; -export interface OcfStockReissuanceEvent { - object_type: 'TX_STOCK_REISSUANCE'; - id: string; - date: string; - security_id: string; - resulting_security_ids: string[]; - reason_text?: string; - split_transaction_id?: string; - comments?: string[]; -} +export type OcfStockReissuanceEvent = OcfStockReissuance; export type GetStockReissuanceAsOcfParams = GetByContractIdParams; export interface GetStockReissuanceAsOcfResult { @@ -21,28 +14,15 @@ export interface GetStockReissuanceAsOcfResult { contractId: string; } -/** Type alias for DAML StockReissuance contract createArgument */ -type StockReissuanceCreateArgument = Fairmint.OpenCapTable.OCF.StockReissuance.StockReissuance; - export async function getStockReissuanceAsOcf( client: LedgerJsonApiClient, params: GetStockReissuanceAsOcfParams ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getStockReissuanceAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockReissuance, }); - const contract = createArgument as StockReissuanceCreateArgument; - const data = contract.reissuance_data; - - const event: OcfStockReissuanceEvent = { - object_type: 'TX_STOCK_REISSUANCE', - id: data.id, - date: damlTimeToDateString(data.date, 'stockReissuance.date'), - security_id: data.security_id, - resulting_security_ids: data.resulting_security_ids, - ...(data.reason_text ? { reason_text: data.reason_text } : {}), - ...(data.split_transaction_id ? { split_transaction_id: data.split_transaction_id } : {}), - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const data = extractAndDecodeDamlEntityData('stockReissuance', createArgument); + const event = damlStockReissuanceToNative(data); return { event, contractId: params.contractId }; } diff --git a/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts b/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts index 809af595..2870f520 100644 --- a/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts @@ -4,21 +4,13 @@ import type { OcfStockRepurchase } from '../../../types'; import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor } from '../capTable/batchTypes'; /** * DAML StockRepurchase data structure. * This matches the shape of data returned from DAML contracts. */ -export interface DamlStockRepurchaseData { - id: string; - date: string; - security_id: string; - quantity: string; - price: { amount: string; currency: string }; - balance_security_id?: string; - consideration_text?: string; - comments?: string[]; -} +export type DamlStockRepurchaseData = DamlDataTypeFor<'stockRepurchase'>; /** * Convert DAML StockRepurchase data to native OCF format. @@ -30,12 +22,12 @@ export function damlStockRepurchaseToNative(d: DamlStockRepurchaseData): OcfStoc return { object_type: 'TX_STOCK_REPURCHASE', id: d.id, - date: damlTimeToDateString(d.date), + date: damlTimeToDateString(d.date, 'stockRepurchase.date'), security_id: d.security_id, quantity: normalizeNumericString(d.quantity), price: damlMonetaryToNative(d.price), ...(d.balance_security_id ? { balance_security_id: d.balance_security_id } : {}), ...(d.consideration_text ? { consideration_text: d.consideration_text } : {}), - ...(Array.isArray(d.comments) && d.comments.length > 0 ? { comments: d.comments } : {}), + ...(d.comments.length > 0 ? { comments: d.comments } : {}), }; } diff --git a/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts b/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts index 3679fae1..bb65540a 100644 --- a/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts +++ b/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts @@ -1,19 +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 type { OcfStockRepurchase } from '../../../types/native'; -import { damlMonetaryToNative, damlTimeToDateString, normalizeNumericString } from '../../../utils/typeConversions'; +import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; +import { damlStockRepurchaseToNative } from './damlToOcf'; /** * OCF Stock Repurchase Event with object_type discriminator OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/repurchase/StockRepurchase.schema.json */ -export interface OcfStockRepurchaseEvent extends Omit { - object_type: 'TX_STOCK_REPURCHASE'; - /** Quantity as string for OCF JSON serialization */ - quantity: string; -} +export type OcfStockRepurchaseEvent = OcfStockRepurchase; export type GetStockRepurchaseAsOcfParams = GetByContractIdParams; @@ -22,33 +19,15 @@ export interface GetStockRepurchaseAsOcfResult { contractId: string; } -/** Type alias for DAML StockRepurchase contract createArgument */ -type StockRepurchaseCreateArgument = Fairmint.OpenCapTable.OCF.StockRepurchase.StockRepurchase; - export async function getStockRepurchaseAsOcf( client: LedgerJsonApiClient, params: GetStockRepurchaseAsOcfParams ): Promise { const { createArgument } = await readSingleContract(client, params, { operation: 'getStockRepurchaseAsOcf', + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockRepurchase, }); - const contract = createArgument as StockRepurchaseCreateArgument; - const data = contract.repurchase_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: OcfStockRepurchaseEvent = { - object_type: 'TX_STOCK_REPURCHASE', - id: data.id, - date: damlTimeToDateString(data.date, 'stockRepurchase.date'), - security_id: data.security_id, - quantity: normalizeNumericString(quantityStr), - price: damlMonetaryToNative(data.price), - ...(data.balance_security_id ? { balance_security_id: data.balance_security_id } : {}), - ...(data.consideration_text ? { consideration_text: data.consideration_text } : {}), - ...(Array.isArray(data.comments) && data.comments.length ? { comments: data.comments } : {}), - }; + const data = extractAndDecodeDamlEntityData('stockRepurchase', createArgument); + const event = damlStockRepurchaseToNative(data); return { event, contractId: params.contractId }; } diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index a85188af..ab132528 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -15,7 +15,10 @@ */ import { convertToDaml } from '../../src/functions/OpenCapTable/capTable/ocfToDaml'; -import { damlStockClassConversionRatioAdjustmentToNative } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import { + type DamlStockClassConversionRatioAdjustmentData, + damlStockClassConversionRatioAdjustmentToNative, +} from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; import { damlStockClassSplitToNative } from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; import { damlStockConsolidationToNative } from '../../src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation'; import { damlStockReissuanceToNative } from '../../src/functions/OpenCapTable/stockReissuance/damlToStockReissuance'; @@ -352,7 +355,7 @@ describe('Stock Class Adjustment Converters', () => { rounding_type: 'OcfRoundingNormal', }, comments: ['Anti-dilution adjustment'], - }; + } satisfies DamlStockClassConversionRatioAdjustmentData; const result = damlStockClassConversionRatioAdjustmentToNative(damlData); diff --git a/test/declarations/stockCorporateActionReaders.types.ts b/test/declarations/stockCorporateActionReaders.types.ts new file mode 100644 index 00000000..5ce79846 --- /dev/null +++ b/test/declarations/stockCorporateActionReaders.types.ts @@ -0,0 +1,206 @@ +/** Built-declaration contracts for stock corporate-action readers and converters. */ + +import type { DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import type { + DamlStockClassConversionRatioAdjustmentData, + damlStockClassConversionRatioAdjustmentToNative, +} from '../../dist/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import type { GetStockClassConversionRatioAdjustmentAsOcfResult } from '../../dist/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; +import type { + DamlStockClassSplitData, + damlStockClassSplitToNative, +} from '../../dist/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import type { GetStockClassSplitAsOcfResult } from '../../dist/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf'; +import type { + DamlStockConsolidationData, + damlStockConsolidationToNative, +} from '../../dist/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation'; +import type { GetStockConsolidationAsOcfResult } from '../../dist/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf'; +import type { + DamlStockReissuanceData, + damlStockReissuanceToNative, +} from '../../dist/functions/OpenCapTable/stockReissuance/damlToStockReissuance'; +import type { GetStockReissuanceAsOcfResult } from '../../dist/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf'; +import type { + DamlStockRepurchaseData, + damlStockRepurchaseToNative, +} from '../../dist/functions/OpenCapTable/stockRepurchase/damlToOcf'; +import type { GetStockRepurchaseAsOcfResult } from '../../dist/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf'; +import type { + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockReissuance, + OcfStockRepurchase, +} 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 ConversionRatioEvent = GetStockClassConversionRatioAdjustmentAsOcfResult['event']; +type SplitEvent = GetStockClassSplitAsOcfResult['event']; +type ConsolidationEvent = GetStockConsolidationAsOcfResult['event']; +type ReissuanceEvent = GetStockReissuanceAsOcfResult['event']; +type RepurchaseEvent = GetStockRepurchaseAsOcfResult['event']; + +const conversionRatioEventIsExact: Assert> = + true; +const splitEventIsExact: Assert> = true; +const consolidationEventIsExact: Assert> = true; +const reissuanceEventIsExact: Assert> = true; +const repurchaseEventIsExact: Assert> = true; +const conversionRatioResultIsExact: Assert< + IsExactly< + GetStockClassConversionRatioAdjustmentAsOcfResult, + { event: OcfStockClassConversionRatioAdjustment; contractId: string } + > +> = true; +const splitResultIsExact: Assert< + IsExactly +> = true; +const consolidationResultIsExact: Assert< + IsExactly +> = true; +const reissuanceResultIsExact: Assert< + IsExactly +> = true; +const repurchaseResultIsExact: Assert< + IsExactly +> = true; + +const conversionRatioEventIsNotAny: Assert, false>> = true; +const splitEventIsNotAny: Assert, false>> = true; +const consolidationEventIsNotAny: Assert, false>> = true; +const reissuanceEventIsNotAny: Assert, false>> = true; +const repurchaseEventIsNotAny: Assert, false>> = true; + +const conversionRatioDamlIsExact: Assert< + IsExactly> +> = true; +const splitDamlIsExact: Assert>> = true; +const consolidationDamlIsExact: Assert>> = + true; +const reissuanceDamlIsExact: Assert>> = true; +const repurchaseDamlIsExact: Assert>> = true; + +const conversionRatioConverterInputIsExact: Assert< + IsExactly< + Parameters[0], + DamlDataTypeFor<'stockClassConversionRatioAdjustment'> + > +> = true; +const splitConverterInputIsExact: Assert< + IsExactly[0], DamlDataTypeFor<'stockClassSplit'>> +> = true; +const consolidationConverterInputIsExact: Assert< + IsExactly[0], DamlDataTypeFor<'stockConsolidation'>> +> = true; +const reissuanceConverterInputIsExact: Assert< + IsExactly[0], DamlDataTypeFor<'stockReissuance'>> +> = true; +const repurchaseConverterInputIsExact: Assert< + IsExactly[0], DamlDataTypeFor<'stockRepurchase'>> +> = true; +const conversionRatioConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const splitConverterInputIsNotAny: Assert[0]>, false>> = + true; +const consolidationConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const reissuanceConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const repurchaseConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; + +type ConversionRatioRounding = + OcfStockClassConversionRatioAdjustment['new_ratio_conversion_mechanism']['rounding_type']; +const roundingUnionIsExact: Assert> = true; + +declare const conversionRatioResult: GetStockClassConversionRatioAdjustmentAsOcfResult; +declare const splitResult: GetStockClassSplitAsOcfResult; +declare const consolidationResult: GetStockConsolidationAsOcfResult; +declare const reissuanceResult: GetStockReissuanceAsOcfResult; +declare const repurchaseResult: GetStockRepurchaseAsOcfResult; +declare const conversionRatioDaml: DamlStockClassConversionRatioAdjustmentData; +declare const splitDaml: DamlStockClassSplitData; +declare const consolidationDaml: DamlStockConsolidationData; +declare const reissuanceDaml: DamlStockReissuanceData; +declare const repurchaseDaml: DamlStockRepurchaseData; + +const firstConsolidatedSecurityId: string = consolidationResult.event.security_ids[0]; +const firstReissuedSecurityId: string | undefined = reissuanceResult.event.resulting_security_ids[0]; + +// @ts-expect-error built conversion-ratio DAML cannot be passed to the split converter +const wrongSplitDamlInput: Parameters[0] = conversionRatioDaml; +// @ts-expect-error built split DAML cannot be passed to the consolidation converter +const wrongConsolidationDamlInput: Parameters[0] = splitDaml; +// @ts-expect-error built consolidation DAML cannot be passed to the reissuance converter +const wrongReissuanceDamlInput: Parameters[0] = consolidationDaml; +// @ts-expect-error built reissuance DAML cannot be passed to the repurchase converter +const wrongRepurchaseDamlInput: Parameters[0] = reissuanceDaml; +// @ts-expect-error built repurchase DAML cannot be passed to the conversion-ratio converter +const wrongConversionRatioDamlInput: Parameters[0] = + repurchaseDaml; + +// @ts-expect-error built conversion-ratio adjustment cannot be used as a split +const wrongSplit: OcfStockClassSplit = conversionRatioResult.event; +// @ts-expect-error built split cannot be used as a consolidation +const wrongConsolidation: OcfStockConsolidation = splitResult.event; +// @ts-expect-error built consolidation cannot be used as a reissuance +const wrongReissuance: OcfStockReissuance = consolidationResult.event; +// @ts-expect-error built reissuance cannot be used as a repurchase +const wrongRepurchase: OcfStockRepurchase = reissuanceResult.event; +// @ts-expect-error built repurchase cannot be used as a conversion-ratio adjustment +const wrongConversionRatio: OcfStockClassConversionRatioAdjustment = repurchaseResult.event; +// @ts-expect-error built canonical rounding is a closed union +const invalidRounding: ConversionRatioRounding = 'BANKERS'; + +void conversionRatioEventIsExact; +void splitEventIsExact; +void consolidationEventIsExact; +void reissuanceEventIsExact; +void repurchaseEventIsExact; +void conversionRatioResultIsExact; +void splitResultIsExact; +void consolidationResultIsExact; +void reissuanceResultIsExact; +void repurchaseResultIsExact; +void conversionRatioEventIsNotAny; +void splitEventIsNotAny; +void consolidationEventIsNotAny; +void reissuanceEventIsNotAny; +void repurchaseEventIsNotAny; +void conversionRatioDamlIsExact; +void splitDamlIsExact; +void consolidationDamlIsExact; +void reissuanceDamlIsExact; +void repurchaseDamlIsExact; +void conversionRatioConverterInputIsExact; +void splitConverterInputIsExact; +void consolidationConverterInputIsExact; +void reissuanceConverterInputIsExact; +void repurchaseConverterInputIsExact; +void conversionRatioConverterInputIsNotAny; +void splitConverterInputIsNotAny; +void consolidationConverterInputIsNotAny; +void reissuanceConverterInputIsNotAny; +void repurchaseConverterInputIsNotAny; +void roundingUnionIsExact; +void firstConsolidatedSecurityId; +void firstReissuedSecurityId; +void wrongSplitDamlInput; +void wrongConsolidationDamlInput; +void wrongReissuanceDamlInput; +void wrongRepurchaseDamlInput; +void wrongConversionRatioDamlInput; +void wrongSplit; +void wrongConsolidation; +void wrongReissuance; +void wrongRepurchase; +void wrongConversionRatio; +void invalidRounding; diff --git a/test/functions/stockCorporateActionReaders.test.ts b/test/functions/stockCorporateActionReaders.test.ts new file mode 100644 index 00000000..feeb1eb7 --- /dev/null +++ b/test/functions/stockCorporateActionReaders.test.ts @@ -0,0 +1,785 @@ +/** Direct ledger-reader contracts for stock corporate-action transactions. */ + +import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; +import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../src/errors'; +import { + ENTITY_DATA_FIELD_MAP, + ENTITY_TEMPLATE_ID_MAP, + type OcfEntityType, +} from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import { stockClassAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment'; +import { getStockClassConversionRatioAdjustmentAsOcf } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; +import { stockClassConversionRatioAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml'; +import { getStockClassSplitAsOcf } from '../../src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf'; +import { stockClassSplitDataToDaml } from '../../src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml'; +import { getStockConsolidationAsOcf } from '../../src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf'; +import { stockConsolidationDataToDaml } from '../../src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml'; +import { getStockReissuanceAsOcf } from '../../src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf'; +import { stockReissuanceDataToDaml } from '../../src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml'; +import { getStockRepurchaseAsOcf } from '../../src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf'; +import { stockRepurchaseDataToDaml } from '../../src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml'; +import type { + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockReissuance, + OcfStockRepurchase, + RoundingType, +} from '../../src/types/native'; + +type StockCorporateActionEntityType = Extract< + OcfEntityType, + | 'stockClassConversionRatioAdjustment' + | 'stockClassSplit' + | 'stockConsolidation' + | 'stockReissuance' + | 'stockRepurchase' +>; + +type StockCorporateAction = + | OcfStockClassConversionRatioAdjustment + | OcfStockClassSplit + | OcfStockConsolidation + | OcfStockReissuance + | OcfStockRepurchase; + +function conversionRatioAdjustmentData(roundingType: RoundingType = 'NORMAL'): Record { + return stockClassConversionRatioAdjustmentDataToDaml({ + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'conversion-ratio-adjustment-1', + date: '2026-07-10', + stock_class_id: 'preferred-stock-class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '2.2500', currency: 'USD' }, + ratio: { numerator: '3.0000', denominator: '2.0000' }, + rounding_type: roundingType, + }, + comments: ['anti-dilution adjustment'], + }); +} + +function stockClassSplitData(): Record { + return stockClassSplitDataToDaml({ + object_type: 'TX_STOCK_CLASS_SPLIT', + id: 'stock-class-split-1', + date: '2026-07-10', + stock_class_id: 'common-stock-class-1', + split_ratio: { numerator: '4.0000', denominator: '1.0000' }, + comments: ['four-for-one split'], + }); +} + +function stockConsolidationData(): Record { + return stockConsolidationDataToDaml({ + object_type: 'TX_STOCK_CONSOLIDATION', + id: 'stock-consolidation-1', + date: '2026-07-10', + security_ids: ['security-old-1', 'security-old-2'], + resulting_security_id: 'security-consolidated-1', + reason_text: 'Reverse split cleanup', + comments: ['securities consolidated'], + }); +} + +function stockReissuanceData(): Record { + return stockReissuanceDataToDaml({ + object_type: 'TX_STOCK_REISSUANCE', + id: 'stock-reissuance-1', + date: '2026-07-10', + security_id: 'security-retired-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'Certificate replacement', + split_transaction_id: 'stock-class-split-1', + comments: ['security reissued'], + }); +} + +function stockRepurchaseData(): Record { + return stockRepurchaseDataToDaml({ + object_type: 'TX_STOCK_REPURCHASE', + id: 'stock-repurchase-1', + date: '2026-07-10', + security_id: 'security-repurchased-1', + quantity: '12.5000', + price: { amount: '2.2500', currency: 'USD' }, + balance_security_id: 'security-balance-1', + consideration_text: 'Cash repurchase', + comments: ['shares repurchased'], + }); +} + +interface StockCorporateActionReaderCase { + readonly entityType: StockCorporateActionEntityType; + readonly contractId: string; + readonly requiredScalar: string; + readonly validData: () => Record; + readonly expectedEvent: StockCorporateAction; + readonly invoke: ( + client: LedgerJsonApiClient, + readAs?: string[] + ) => Promise<{ readonly event: StockCorporateAction; readonly contractId: string }>; +} + +const conversionRatioAdjustmentCase: StockCorporateActionReaderCase = { + entityType: 'stockClassConversionRatioAdjustment', + contractId: 'conversion-ratio-adjustment-cid', + requiredScalar: 'stock_class_id', + validData: conversionRatioAdjustmentData, + expectedEvent: { + object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + id: 'conversion-ratio-adjustment-1', + date: '2026-07-10', + stock_class_id: 'preferred-stock-class-1', + new_ratio_conversion_mechanism: { + type: 'RATIO_CONVERSION', + conversion_price: { amount: '2.25', currency: 'USD' }, + ratio: { numerator: '3', denominator: '2' }, + rounding_type: 'NORMAL', + }, + comments: ['anti-dilution adjustment'], + }, + invoke: async (client, readAs) => + getStockClassConversionRatioAdjustmentAsOcf(client, { + contractId: 'conversion-ratio-adjustment-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), +}; + +const stockClassSplitCase: StockCorporateActionReaderCase = { + entityType: 'stockClassSplit', + contractId: 'stock-class-split-cid', + requiredScalar: 'stock_class_id', + validData: stockClassSplitData, + expectedEvent: { + object_type: 'TX_STOCK_CLASS_SPLIT', + id: 'stock-class-split-1', + date: '2026-07-10', + stock_class_id: 'common-stock-class-1', + split_ratio: { numerator: '4', denominator: '1' }, + comments: ['four-for-one split'], + }, + invoke: async (client, readAs) => + getStockClassSplitAsOcf(client, { + contractId: 'stock-class-split-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), +}; + +const stockConsolidationCase: StockCorporateActionReaderCase = { + entityType: 'stockConsolidation', + contractId: 'stock-consolidation-cid', + requiredScalar: 'resulting_security_id', + validData: stockConsolidationData, + expectedEvent: { + object_type: 'TX_STOCK_CONSOLIDATION', + id: 'stock-consolidation-1', + date: '2026-07-10', + security_ids: ['security-old-1', 'security-old-2'], + resulting_security_id: 'security-consolidated-1', + reason_text: 'Reverse split cleanup', + comments: ['securities consolidated'], + }, + invoke: async (client, readAs) => + getStockConsolidationAsOcf(client, { + contractId: 'stock-consolidation-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), +}; + +const stockReissuanceCase: StockCorporateActionReaderCase = { + entityType: 'stockReissuance', + contractId: 'stock-reissuance-cid', + requiredScalar: 'security_id', + validData: stockReissuanceData, + expectedEvent: { + object_type: 'TX_STOCK_REISSUANCE', + id: 'stock-reissuance-1', + date: '2026-07-10', + security_id: 'security-retired-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'Certificate replacement', + split_transaction_id: 'stock-class-split-1', + comments: ['security reissued'], + }, + invoke: async (client, readAs) => + getStockReissuanceAsOcf(client, { + contractId: 'stock-reissuance-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), +}; + +const stockRepurchaseCase: StockCorporateActionReaderCase = { + entityType: 'stockRepurchase', + contractId: 'stock-repurchase-cid', + requiredScalar: 'security_id', + validData: stockRepurchaseData, + expectedEvent: { + object_type: 'TX_STOCK_REPURCHASE', + id: 'stock-repurchase-1', + date: '2026-07-10', + security_id: 'security-repurchased-1', + quantity: '12.5', + price: { amount: '2.25', currency: 'USD' }, + balance_security_id: 'security-balance-1', + consideration_text: 'Cash repurchase', + comments: ['shares repurchased'], + }, + invoke: async (client, readAs) => + getStockRepurchaseAsOcf(client, { + contractId: 'stock-repurchase-cid', + ...(readAs !== undefined ? { readAs } : {}), + }), +}; + +const stockCorporateActionCases = [ + conversionRatioAdjustmentCase, + stockClassSplitCase, + stockConsolidationCase, + stockReissuanceCase, + stockRepurchaseCase, +] as const; + +function createMockClient( + testCase: StockCorporateActionReaderCase, + data: unknown, + options: { + readonly createArgument?: unknown; + /** Null deliberately omits ledger template identity. */ + readonly templateId?: string | null; + } = {} +): { readonly client: LedgerJsonApiClient; readonly getEventsByContractId: jest.Mock } { + const createArgument = Object.prototype.hasOwnProperty.call(options, 'createArgument') + ? options.createArgument + : { [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: data }; + const templateId = + options.templateId === undefined ? ENTITY_TEMPLATE_ID_MAP[testCase.entityType] : options.templateId; + const getEventsByContractId = jest.fn().mockResolvedValue({ + created: { + createdEvent: { + contractId: testCase.contractId, + ...(templateId !== null ? { templateId } : {}), + createArgument, + }, + }, + }); + return { + client: { getEventsByContractId } as unknown as LedgerJsonApiClient, + getEventsByContractId, + }; +} + +function expectDecoderFailure(error: unknown, testCase: StockCorporateActionReaderCase, 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); +} + +async function expectDecoderRejection( + testCase: StockCorporateActionReaderCase, + data: unknown, + field: string +): Promise { + const { client } = createMockClient(testCase, data); + try { + await testCase.invoke(client); + throw new Error(`Expected ${testCase.entityType} reader to reject malformed ${field}`); + } catch (error: unknown) { + expectDecoderFailure(error, testCase, field); + } +} + +function setPath(data: Record, path: readonly string[], value: unknown): Record { + const finalKey = path[path.length - 1]; + if (!finalKey) throw new Error('Mutation path must not be empty'); + let target = data; + for (const key of path.slice(0, -1)) { + const nested = target[key]; + if (nested === null || typeof nested !== 'object' || Array.isArray(nested)) { + throw new Error(`Fixture path ${path.join('.')} is not an object`); + } + target = nested as Record; + } + target[finalKey] = value; + return data; +} + +function deletePath(data: Record, path: readonly string[]): Record { + const finalKey = path[path.length - 1]; + if (!finalKey) throw new Error('Deletion path must not be empty'); + let target = data; + for (const key of path.slice(0, -1)) { + const nested = target[key]; + if (nested === null || typeof nested !== 'object' || Array.isArray(nested)) { + throw new Error(`Fixture path ${path.join('.')} is not an object`); + } + target = nested as Record; + } + delete target[finalKey]; + return data; +} + +describe('decoder-backed stock corporate-action readers', () => { + it.each(stockCorporateActionCases)( + '$entityType returns its exact canonical event and forwards readAs', + async (testCase) => { + const { client, getEventsByContractId } = createMockClient(testCase, testCase.validData()); + + await expect(testCase.invoke(client, ['issuer::reader'])).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + expect(getEventsByContractId).toHaveBeenCalledWith({ + contractId: testCase.contractId, + readAs: ['issuer::reader'], + }); + } + ); + + it.each(stockCorporateActionCases)( + '$entityType prefers its canonical wrapper over whole-argument lookalikes', + async (testCase) => { + const canonicalData = testCase.validData(); + const { client } = createMockClient(testCase, canonicalData, { + createArgument: { + ...canonicalData, + id: 'wrong-top-level-id', + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: canonicalData, + }, + }); + + await expect(testCase.invoke(client)).resolves.toEqual({ + event: testCase.expectedEvent, + contractId: testCase.contractId, + }); + } + ); + + it.each(stockCorporateActionCases)('$entityType rejects malformed transaction dates', async (testCase) => { + await expectDecoderRejection(testCase, { ...testCase.validData(), date: 17 }, 'date'); + }); + + it.each(stockCorporateActionCases)('$entityType rejects missing transaction dates', async (testCase) => { + const data = testCase.validData(); + delete data.date; + await expectDecoderRejection(testCase, data, 'date'); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a missing transaction id', async (testCase) => { + const data = testCase.validData(); + delete data.id; + await expectDecoderRejection(testCase, data, 'id'); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a malformed transaction id', async (testCase) => { + await expectDecoderRejection(testCase, { ...testCase.validData(), id: 17 }, 'id'); + }); + + it.each(stockCorporateActionCases)('$entityType rejects semantically invalid transaction dates', async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), date: '2026-99-99' }); + + await expect(testCase.invoke(client)).rejects.toBeInstanceOf(OcpValidationError); + await expect(testCase.invoke(client)).rejects.toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT }); + }); + + it.each(stockCorporateActionCases)('$entityType rejects malformed comments', async (testCase) => { + await expectDecoderRejection(testCase, { ...testCase.validData(), comments: ['valid', 17] }, 'comments'); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a missing required comments list', async (testCase) => { + const data = testCase.validData(); + delete data.comments; + await expectDecoderRejection(testCase, data, 'comments'); + }); + + it.each(stockCorporateActionCases)('$entityType omits empty comments canonically', async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), comments: [] }); + const result = await testCase.invoke(client); + + expect(result.event.comments).toBeUndefined(); + expect('comments' in result.event).toBe(false); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a missing required scalar', async (testCase) => { + const data = testCase.validData(); + delete data[testCase.requiredScalar]; + await expectDecoderRejection(testCase, data, testCase.requiredScalar); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a malformed required scalar', async (testCase) => { + await expectDecoderRejection( + testCase, + { ...testCase.validData(), [testCase.requiredScalar]: 17 }, + testCase.requiredScalar + ); + }); + + it.each(stockCorporateActionCases)( + '$entityType rejects fields discarded by the generated codec', + async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), unexpected_field: true }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: 'input.unexpected_field', + decoderMessage: 'raw field was discarded by the generated codec', + }, + }); + } + ); + + it.each(stockCorporateActionCases)('$entityType rejects a missing canonical wrapper', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { createArgument: {} }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + message: expect.stringContaining(ENTITY_DATA_FIELD_MAP[testCase.entityType]), + }); + }); + + it.each(stockCorporateActionCases)('$entityType rejects whole-createArgument data lookalikes', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: testCase.validData(), + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + message: expect.stringContaining(ENTITY_DATA_FIELD_MAP[testCase.entityType]), + }); + }); + + it.each(stockCorporateActionCases)('$entityType rejects non-object nested data', async (testCase) => { + const { client } = createMockClient(testCase, []); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + message: expect.stringContaining(ENTITY_DATA_FIELD_MAP[testCase.entityType]), + }); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a missing ledger template identity', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { templateId: null }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpContractError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'missing_template_id', + contractId: testCase.contractId, + context: { + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[testCase.entityType], + }, + }); + }); + + it.each(stockCorporateActionCases)('$entityType rejects a contract from the wrong template', async (testCase) => { + const wrongTemplateId = ENTITY_TEMPLATE_ID_MAP.document; + const { client } = createMockClient(testCase, testCase.validData(), { templateId: wrongTemplateId }); + + await expect(testCase.invoke(client)).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, + }, + }); + }); +}); + +describe('numeric and nested stock corporate-action fields', () => { + const numericFields = [ + { + label: 'conversion price amount', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'conversion_price', 'amount'], + }, + { + label: 'conversion ratio numerator', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'ratio', 'numerator'], + }, + { + label: 'conversion ratio denominator', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'ratio', 'denominator'], + }, + { label: 'split numerator', testCase: stockClassSplitCase, path: ['split_ratio', 'numerator'] }, + { label: 'split denominator', testCase: stockClassSplitCase, path: ['split_ratio', 'denominator'] }, + { label: 'repurchase quantity', testCase: stockRepurchaseCase, path: ['quantity'] }, + { label: 'repurchase price amount', testCase: stockRepurchaseCase, path: ['price', 'amount'] }, + ] as const; + + it.each(numericFields)('rejects a numeric primitive for $label', async ({ testCase, path }) => { + await expectDecoderRejection(testCase, setPath(testCase.validData(), path, 17), path[path.length - 1] ?? 'numeric'); + }); + + it.each(numericFields)('rejects an invalid numeric string for $label', async ({ testCase, path }) => { + const { client } = createMockClient(testCase, setPath(testCase.validData(), path, '1e3')); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'numericString', + }); + }); + + const requiredNestedFields = [ + { + label: 'complete conversion mechanism', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism'], + }, + { + label: 'conversion price', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'conversion_price'], + }, + { + label: 'conversion price currency', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'conversion_price', 'currency'], + }, + { + label: 'conversion ratio', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'ratio'], + }, + { + label: 'conversion ratio denominator', + testCase: conversionRatioAdjustmentCase, + path: ['new_ratio_conversion_mechanism', 'ratio', 'denominator'], + }, + { label: 'split ratio', testCase: stockClassSplitCase, path: ['split_ratio'] }, + { label: 'split ratio numerator', testCase: stockClassSplitCase, path: ['split_ratio', 'numerator'] }, + { label: 'repurchase quantity', testCase: stockRepurchaseCase, path: ['quantity'] }, + { label: 'repurchase price', testCase: stockRepurchaseCase, path: ['price'] }, + { label: 'repurchase price currency', testCase: stockRepurchaseCase, path: ['price', 'currency'] }, + ] as const; + + it.each(requiredNestedFields)('rejects a missing $label', async ({ testCase, path }) => { + await expectDecoderRejection(testCase, deletePath(testCase.validData(), path), path[path.length - 1] ?? 'nested'); + }); + + it.each([ + { + label: 'consolidation security IDs', + testCase: stockConsolidationCase, + field: 'security_ids', + }, + { + label: 'reissuance resulting security IDs', + testCase: stockReissuanceCase, + field: 'resulting_security_ids', + }, + ] as const)('rejects a missing required $label list', async ({ testCase, field }) => { + const data = testCase.validData(); + delete data[field]; + await expectDecoderRejection(testCase, data, field); + }); + + it.each([ + { + label: 'consolidation security IDs', + testCase: stockConsolidationCase, + field: 'security_ids', + }, + { + label: 'reissuance resulting security IDs', + testCase: stockReissuanceCase, + field: 'resulting_security_ids', + }, + ] as const)('rejects malformed nested values in the $label list', async ({ testCase, field }) => { + await expectDecoderRejection(testCase, { ...testCase.validData(), [field]: ['valid-id', 17] }, field); + }); +}); + +describe('conversion-ratio rounding and shared-wrapper isolation', () => { + it.each([ + ['OcfRoundingNormal', 'NORMAL'], + ['OcfRoundingCeiling', 'CEILING'], + ['OcfRoundingFloor', 'FLOOR'], + ] as const)('maps %s to %s', async (damlRoundingType, nativeRoundingType) => { + const data = setPath( + conversionRatioAdjustmentData(), + ['new_ratio_conversion_mechanism', 'rounding_type'], + damlRoundingType + ); + const { client } = createMockClient(conversionRatioAdjustmentCase, data); + + const result = await conversionRatioAdjustmentCase.invoke(client); + if (result.event.object_type !== 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT') { + throw new Error('Expected a conversion-ratio adjustment event'); + } + expect(result.event.new_ratio_conversion_mechanism.rounding_type).toBe(nativeRoundingType); + }); + + it('rejects an unsupported generated rounding variant', async () => { + const data = setPath( + conversionRatioAdjustmentData(), + ['new_ratio_conversion_mechanism', 'rounding_type'], + 'OcfRoundingBankers' + ); + + await expectDecoderRejection(conversionRatioAdjustmentCase, data, 'rounding_type'); + }); + + it('does not confuse another adjustment family sharing adjustment_data', async () => { + const authorizedSharesData = stockClassAuthorizedSharesAdjustmentDataToDaml({ + object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', + id: 'authorized-shares-adjustment-1', + date: '2026-07-10', + stock_class_id: 'preferred-stock-class-1', + new_shares_authorized: '1000000', + }); + const { client } = createMockClient(conversionRatioAdjustmentCase, authorizedSharesData, { + templateId: ENTITY_TEMPLATE_ID_MAP.stockClassAuthorizedSharesAdjustment, + }); + + await expect(conversionRatioAdjustmentCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpContractError', + classification: 'module_entity_mismatch', + context: { + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockClassConversionRatioAdjustment, + actualTemplateId: ENTITY_TEMPLATE_ID_MAP.stockClassAuthorizedSharesAdjustment, + }, + }); + }); + + it('rejects another adjustment family even under the expected template identity', async () => { + const authorizedSharesData = stockClassAuthorizedSharesAdjustmentDataToDaml({ + object_type: 'TX_STOCK_CLASS_AUTHORIZED_SHARES_ADJUSTMENT', + id: 'authorized-shares-adjustment-1', + date: '2026-07-10', + stock_class_id: 'preferred-stock-class-1', + new_shares_authorized: '1000000', + }); + + await expectDecoderRejection(conversionRatioAdjustmentCase, authorizedSharesData, 'new_ratio_conversion_mechanism'); + }); +}); + +describe('optional stock corporate-action fields', () => { + const optionalFields = [ + { testCase: stockConsolidationCase, field: 'reason_text' }, + { testCase: stockReissuanceCase, field: 'reason_text' }, + { testCase: stockReissuanceCase, field: 'split_transaction_id' }, + { testCase: stockRepurchaseCase, field: 'balance_security_id' }, + { testCase: stockRepurchaseCase, field: 'consideration_text' }, + ] as const; + + it.each(optionalFields)( + '$testCase.entityType accepts omitted $field through generated null defaults', + async ({ testCase, field }) => { + const data = testCase.validData(); + delete data[field]; + const { client } = createMockClient(testCase, data); + + const result = await testCase.invoke(client); + const event = result.event as unknown as Record; + expect(event[field]).toBeUndefined(); + expect(field in event).toBe(false); + } + ); + + it.each(optionalFields)('$testCase.entityType omits null $field canonically', async ({ testCase, field }) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), [field]: null }); + + const result = await testCase.invoke(client); + const event = result.event as unknown as Record; + expect(event[field]).toBeUndefined(); + expect(field in event).toBe(false); + }); + + it.each(optionalFields)( + '$testCase.entityType rejects malformed optional $field losslessly', + async ({ testCase, field }) => { + const { client } = createMockClient(testCase, { + ...testCase.validData(), + [field]: { malformed: true }, + }); + + await expect(testCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + context: { + entityType: testCase.entityType, + decoderPath: `input.${field}`, + decoderMessage: 'raw object was decoded and encoded as null', + }, + }); + } + ); +}); + +describe('stock consolidation, reissuance, and repurchase invariants', () => { + it('rejects a consolidation with no source securities', async () => { + const { client } = createMockClient(stockConsolidationCase, { + ...stockConsolidationData(), + security_ids: [], + }); + + await expect(stockConsolidationCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + fieldPath: 'stockConsolidation.security_ids', + }); + }); + + it('rejects duplicate consolidation source securities', async () => { + const { client } = createMockClient(stockConsolidationCase, { + ...stockConsolidationData(), + security_ids: ['security-old-1', 'security-old-1'], + }); + + await expect(stockConsolidationCase.invoke(client)).rejects.toMatchObject({ + name: 'OcpValidationError', + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockConsolidation.security_ids', + }); + }); + + it('allows a reissuance with no resulting securities', async () => { + const { client } = createMockClient(stockReissuanceCase, { + ...stockReissuanceData(), + resulting_security_ids: [], + }); + + const result = await stockReissuanceCase.invoke(client); + expect(result.event).toMatchObject({ + object_type: 'TX_STOCK_REISSUANCE', + resulting_security_ids: [], + }); + }); + + it('normalizes both repurchase quantity and price without conflating them', async () => { + const { client } = createMockClient(stockRepurchaseCase, { + ...stockRepurchaseData(), + quantity: '125.0000000000', + price: { amount: '7.5000000000', currency: 'EUR' }, + }); + + const result = await stockRepurchaseCase.invoke(client); + expect(result.event).toMatchObject({ + object_type: 'TX_STOCK_REPURCHASE', + quantity: '125', + price: { amount: '7.5', currency: 'EUR' }, + }); + }); +}); diff --git a/test/types/stockCorporateActionReaders.types.ts b/test/types/stockCorporateActionReaders.types.ts new file mode 100644 index 00000000..ccf3b303 --- /dev/null +++ b/test/types/stockCorporateActionReaders.types.ts @@ -0,0 +1,206 @@ +/** Compile-time contracts for stock corporate-action readers and converters. */ + +import type { DamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import type { + DamlStockClassConversionRatioAdjustmentData, + damlStockClassConversionRatioAdjustmentToNative, +} from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; +import type { GetStockClassConversionRatioAdjustmentAsOcfResult } from '../../src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; +import type { + DamlStockClassSplitData, + damlStockClassSplitToNative, +} from '../../src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit'; +import type { GetStockClassSplitAsOcfResult } from '../../src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf'; +import type { + DamlStockConsolidationData, + damlStockConsolidationToNative, +} from '../../src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation'; +import type { GetStockConsolidationAsOcfResult } from '../../src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf'; +import type { + DamlStockReissuanceData, + damlStockReissuanceToNative, +} from '../../src/functions/OpenCapTable/stockReissuance/damlToStockReissuance'; +import type { GetStockReissuanceAsOcfResult } from '../../src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf'; +import type { + DamlStockRepurchaseData, + damlStockRepurchaseToNative, +} from '../../src/functions/OpenCapTable/stockRepurchase/damlToOcf'; +import type { GetStockRepurchaseAsOcfResult } from '../../src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf'; +import type { + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockReissuance, + OcfStockRepurchase, +} 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 ConversionRatioEvent = GetStockClassConversionRatioAdjustmentAsOcfResult['event']; +type SplitEvent = GetStockClassSplitAsOcfResult['event']; +type ConsolidationEvent = GetStockConsolidationAsOcfResult['event']; +type ReissuanceEvent = GetStockReissuanceAsOcfResult['event']; +type RepurchaseEvent = GetStockRepurchaseAsOcfResult['event']; + +const conversionRatioEventIsExact: Assert> = + true; +const splitEventIsExact: Assert> = true; +const consolidationEventIsExact: Assert> = true; +const reissuanceEventIsExact: Assert> = true; +const repurchaseEventIsExact: Assert> = true; +const conversionRatioResultIsExact: Assert< + IsExactly< + GetStockClassConversionRatioAdjustmentAsOcfResult, + { event: OcfStockClassConversionRatioAdjustment; contractId: string } + > +> = true; +const splitResultIsExact: Assert< + IsExactly +> = true; +const consolidationResultIsExact: Assert< + IsExactly +> = true; +const reissuanceResultIsExact: Assert< + IsExactly +> = true; +const repurchaseResultIsExact: Assert< + IsExactly +> = true; + +const conversionRatioEventIsNotAny: Assert, false>> = true; +const splitEventIsNotAny: Assert, false>> = true; +const consolidationEventIsNotAny: Assert, false>> = true; +const reissuanceEventIsNotAny: Assert, false>> = true; +const repurchaseEventIsNotAny: Assert, false>> = true; + +const conversionRatioDamlIsExact: Assert< + IsExactly> +> = true; +const splitDamlIsExact: Assert>> = true; +const consolidationDamlIsExact: Assert>> = + true; +const reissuanceDamlIsExact: Assert>> = true; +const repurchaseDamlIsExact: Assert>> = true; + +const conversionRatioConverterInputIsExact: Assert< + IsExactly< + Parameters[0], + DamlDataTypeFor<'stockClassConversionRatioAdjustment'> + > +> = true; +const splitConverterInputIsExact: Assert< + IsExactly[0], DamlDataTypeFor<'stockClassSplit'>> +> = true; +const consolidationConverterInputIsExact: Assert< + IsExactly[0], DamlDataTypeFor<'stockConsolidation'>> +> = true; +const reissuanceConverterInputIsExact: Assert< + IsExactly[0], DamlDataTypeFor<'stockReissuance'>> +> = true; +const repurchaseConverterInputIsExact: Assert< + IsExactly[0], DamlDataTypeFor<'stockRepurchase'>> +> = true; +const conversionRatioConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const splitConverterInputIsNotAny: Assert[0]>, false>> = + true; +const consolidationConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const reissuanceConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; +const repurchaseConverterInputIsNotAny: Assert< + IsExactly[0]>, false> +> = true; + +type ConversionRatioRounding = + OcfStockClassConversionRatioAdjustment['new_ratio_conversion_mechanism']['rounding_type']; +const roundingUnionIsExact: Assert> = true; + +declare const conversionRatioResult: GetStockClassConversionRatioAdjustmentAsOcfResult; +declare const splitResult: GetStockClassSplitAsOcfResult; +declare const consolidationResult: GetStockConsolidationAsOcfResult; +declare const reissuanceResult: GetStockReissuanceAsOcfResult; +declare const repurchaseResult: GetStockRepurchaseAsOcfResult; +declare const conversionRatioDaml: DamlStockClassConversionRatioAdjustmentData; +declare const splitDaml: DamlStockClassSplitData; +declare const consolidationDaml: DamlStockConsolidationData; +declare const reissuanceDaml: DamlStockReissuanceData; +declare const repurchaseDaml: DamlStockRepurchaseData; + +const firstConsolidatedSecurityId: string = consolidationResult.event.security_ids[0]; +const firstReissuedSecurityId: string | undefined = reissuanceResult.event.resulting_security_ids[0]; + +// @ts-expect-error conversion-ratio DAML cannot be passed to the split converter +const wrongSplitDamlInput: Parameters[0] = conversionRatioDaml; +// @ts-expect-error split DAML cannot be passed to the consolidation converter +const wrongConsolidationDamlInput: Parameters[0] = splitDaml; +// @ts-expect-error consolidation DAML cannot be passed to the reissuance converter +const wrongReissuanceDamlInput: Parameters[0] = consolidationDaml; +// @ts-expect-error reissuance DAML cannot be passed to the repurchase converter +const wrongRepurchaseDamlInput: Parameters[0] = reissuanceDaml; +// @ts-expect-error repurchase DAML cannot be passed to the conversion-ratio converter +const wrongConversionRatioDamlInput: Parameters[0] = + repurchaseDaml; + +// @ts-expect-error conversion-ratio adjustment cannot be used as a split +const wrongSplit: OcfStockClassSplit = conversionRatioResult.event; +// @ts-expect-error split cannot be used as a consolidation +const wrongConsolidation: OcfStockConsolidation = splitResult.event; +// @ts-expect-error consolidation cannot be used as a reissuance +const wrongReissuance: OcfStockReissuance = consolidationResult.event; +// @ts-expect-error reissuance cannot be used as a repurchase +const wrongRepurchase: OcfStockRepurchase = reissuanceResult.event; +// @ts-expect-error repurchase cannot be used as a conversion-ratio adjustment +const wrongConversionRatio: OcfStockClassConversionRatioAdjustment = repurchaseResult.event; +// @ts-expect-error canonical rounding is a closed union +const invalidRounding: ConversionRatioRounding = 'BANKERS'; + +void conversionRatioEventIsExact; +void splitEventIsExact; +void consolidationEventIsExact; +void reissuanceEventIsExact; +void repurchaseEventIsExact; +void conversionRatioResultIsExact; +void splitResultIsExact; +void consolidationResultIsExact; +void reissuanceResultIsExact; +void repurchaseResultIsExact; +void conversionRatioEventIsNotAny; +void splitEventIsNotAny; +void consolidationEventIsNotAny; +void reissuanceEventIsNotAny; +void repurchaseEventIsNotAny; +void conversionRatioDamlIsExact; +void splitDamlIsExact; +void consolidationDamlIsExact; +void reissuanceDamlIsExact; +void repurchaseDamlIsExact; +void conversionRatioConverterInputIsExact; +void splitConverterInputIsExact; +void consolidationConverterInputIsExact; +void reissuanceConverterInputIsExact; +void repurchaseConverterInputIsExact; +void conversionRatioConverterInputIsNotAny; +void splitConverterInputIsNotAny; +void consolidationConverterInputIsNotAny; +void reissuanceConverterInputIsNotAny; +void repurchaseConverterInputIsNotAny; +void roundingUnionIsExact; +void firstConsolidatedSecurityId; +void firstReissuedSecurityId; +void wrongSplitDamlInput; +void wrongConsolidationDamlInput; +void wrongReissuanceDamlInput; +void wrongRepurchaseDamlInput; +void wrongConversionRatioDamlInput; +void wrongSplit; +void wrongConsolidation; +void wrongReissuance; +void wrongRepurchase; +void wrongConversionRatio; +void invalidRounding; From af6e3a0138d3fa2fdf67ce153685da1c309d3eb2 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 11:04:55 -0400 Subject: [PATCH 2/3] Harden stock corporate action boundaries --- src/OcpClient.ts | 23 +-- .../OpenCapTable/capTable/batchTypes.ts | 1 + .../OpenCapTable/capTable/damlEntityData.ts | 22 ++- .../OpenCapTable/capTable/damlToOcf.ts | 24 +-- .../OpenCapTable/capTable/entityTypes.ts | 16 +- .../stockCorporateActionContractData.ts | 175 ++++++++++++++++++ .../shared/generatedDamlValues.ts | 19 +- .../shared/stockCorporateActionValues.ts | 134 ++++++++++++++ ...mlToStockClassConversionRatioAdjustment.ts | 65 +++---- ...tockClassConversionRatioAdjustmentAsOcf.ts | 43 +---- ...lassConversionRatioAdjustmentDataToDaml.ts | 24 ++- .../stockClassSplit/damlToStockClassSplit.ts | 28 ++- .../getStockClassSplitAsOcf.ts | 13 +- .../stockClassSplitDataToDaml.ts | 19 +- .../damlToStockConsolidation.ts | 32 +++- .../getStockConsolidationAsOcf.ts | 13 +- .../stockConsolidationDataToDaml.ts | 24 ++- .../stockReissuance/damlToStockReissuance.ts | 33 +++- .../getStockReissuanceAsOcf.ts | 13 +- .../stockReissuanceDataToDaml.ts | 28 ++- .../OpenCapTable/stockRepurchase/damlToOcf.ts | 32 +++- .../getStockRepurchaseAsOcf.ts | 13 +- .../stockRepurchaseDataToDaml.ts | 25 ++- src/types/common.ts | 9 + src/types/native.ts | 2 +- src/utils/cantonOcfExtractor.ts | 5 +- .../stockCorporateActionOperations.test.ts | 145 +++++++++------ test/client/conversionExerciseReaders.test.ts | 28 ++- .../stockCorporateActionReaders.test.ts | 124 +++++++------ .../conversionDescriptorBoundaries.test.ts | 21 +++ .../conversionWriterBoundaries.test.ts | 6 +- .../exerciseConversionConverters.test.ts | 2 +- .../genericConversionReadBoundaries.test.ts | 11 +- .../schemaCardinalityBoundaries.test.ts | 4 +- .../stockClassAdjustmentConverters.test.ts | 28 +-- test/createOcf/falsyFieldRoundtrip.test.ts | 2 +- .../stockCorporateActionReaders.types.ts | 55 ++++-- .../stockCorporateActionSurfaces.types.ts | 20 +- .../conversionExerciseReaders.test.ts | 15 +- .../generatedDamlNumericReaders.test.ts | 33 ++-- .../stockCorporateActionReaders.test.ts | 162 ++++++++++------ .../functions/stockIssuanceBoundaries.test.ts | 25 ++- .../publicEntrypoint.types.ts | 18 +- .../canonicalOcfObjectInventory.json | 4 +- .../schemaConformanceRegistry.ts | 8 +- .../stockCorporateActionReaders.types.ts | 55 ++++-- .../stockCorporateActionSurfaces.types.ts | 20 +- 47 files changed, 1112 insertions(+), 509 deletions(-) create mode 100644 src/functions/OpenCapTable/capTable/stockCorporateActionContractData.ts create mode 100644 src/functions/OpenCapTable/shared/stockCorporateActionValues.ts diff --git a/src/OcpClient.ts b/src/OcpClient.ts index 419c38aa..861b63c5 100644 --- a/src/OcpClient.ts +++ b/src/OcpClient.ts @@ -86,10 +86,10 @@ import { CapTableBatch, type CapTableBatchParams } from './functions/OpenCapTabl import { getEntityAsOcf } from './functions/OpenCapTable/capTable/damlToOcf'; import { mapOcfObjectTypeToEntityType, - type OcfDataTypeFor, type OcfEntityType, type OcfReadableDataForObjectType, type OcfReadableObjectType, + type OcfReadDataTypeFor, } from './functions/OpenCapTable/capTable/entityTypes'; import { classifyIssuerCapTables, @@ -133,18 +133,13 @@ import type { OcfStockAcceptanceOutput, OcfStockCancellationOutput, OcfStockClassAuthorizedSharesAdjustmentOutput, - OcfStockClassConversionRatioAdjustmentOutput, OcfStockClassOutput, - OcfStockClassSplitOutput, - OcfStockConsolidationOutput, OcfStockConversionOutput, OcfStockIssuanceOutput, OcfStockLegendTemplateOutput, OcfStockPlanOutput, OcfStockPlanPoolAdjustmentOutput, OcfStockPlanReturnToPoolOutput, - OcfStockReissuanceOutput, - OcfStockRepurchaseOutput, OcfStockRetractionOutput, OcfStockTransferOutput, OcfValuationOutput, @@ -364,13 +359,13 @@ function toContractResult(data: T, contractId: string): ContractResult { { code: OcpErrorCodes.REQUIRED_FIELD_MISSING, receivedValue: data } ); } - return { data, contractId }; + return Object.freeze({ data, contractId }); } function makeGenericEntityReader( client: LedgerJsonApiClient, entityType: T -): EntityReader> { +): EntityReader> { return { get: async ({ contractId, ...options }) => { const r = await getEntityAsOcf(client, entityType, contractId, options); @@ -818,7 +813,7 @@ export class OcpClient { private createOpenCapTableMethods(): OpenCapTableMethods { const client = this.ledger; - const genericEntity = (entityType: T): EntityReader> => + const genericEntity = (entityType: T): EntityReader> => makeGenericEntityReader(client, entityType); const methods = { @@ -1116,15 +1111,15 @@ interface OpenCapTableMethods { // Adjustments issuerAuthorizedSharesAdjustment: EntityReader; stockClassAuthorizedSharesAdjustment: EntityReader; - stockClassConversionRatioAdjustment: EntityReader; - stockClassSplit: EntityReader; + stockClassConversionRatioAdjustment: EntityReader>; + stockClassSplit: EntityReader>; stockPlanPoolAdjustment: EntityReader; stockPlanReturnToPool: EntityReader; // Other transactions - stockRepurchase: EntityReader; - stockConsolidation: EntityReader; - stockReissuance: EntityReader; + stockRepurchase: EntityReader>; + stockConsolidation: EntityReader>; + stockReissuance: EntityReader>; equityCompensationRelease: EntityReader; equityCompensationRepricing: EntityReader; diff --git a/src/functions/OpenCapTable/capTable/batchTypes.ts b/src/functions/OpenCapTable/capTable/batchTypes.ts index 5e4b37c9..d5e14240 100644 --- a/src/functions/OpenCapTable/capTable/batchTypes.ts +++ b/src/functions/OpenCapTable/capTable/batchTypes.ts @@ -40,6 +40,7 @@ export { type OcfEntityTypeForObjectType, type OcfReadableDataForObjectType, type OcfReadableObjectType, + type OcfReadDataTypeFor, } from './entityTypes'; // Re-export DAML types for convenience diff --git a/src/functions/OpenCapTable/capTable/damlEntityData.ts b/src/functions/OpenCapTable/capTable/damlEntityData.ts index ec90fd79..35c5c41d 100644 --- a/src/functions/OpenCapTable/capTable/damlEntityData.ts +++ b/src/functions/OpenCapTable/capTable/damlEntityData.ts @@ -1,10 +1,15 @@ import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import { OcpErrorCodes, OcpParseError } from '../../../errors'; -import { extractGeneratedCreateArgumentData, requireGeneratedRecord } from '../../../utils/generatedDamlValidation'; +import { + assertSafeGeneratedDamlJson, + extractGeneratedCreateArgumentData, + requireGeneratedRecord, +} from '../../../utils/generatedDamlValidation'; import { initialSharesAuthorizedFromDaml } from '../../../utils/typeConversions'; import { projectDamlIssuerDataToNative } from '../issuer/getIssuerAsOcf'; import { parseDamlSafeInteger } from '../shared/damlIntegers'; -import { assertCanonicalJsonGraph, requireDecimalString } from '../shared/ocfValues'; +import { requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; +import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { damlOptionalStakeholderRelationshipToNative } from '../stakeholderRelationshipChangeEvent/damlToOcf'; import { extractAndDecodeAcceptanceData, isAcceptanceEntityType } from './acceptanceContractData'; import { @@ -22,6 +27,10 @@ import { isComplexIssuanceEntityType, validateComplexIssuanceDamlDataInput, } from './issuanceContractData'; +import { + extractAndDecodeStockCorporateActionData, + isStockCorporateActionEntityType, +} from './stockCorporateActionContractData'; import { extractAndDecodeTransferData, isTransferEntityType, @@ -75,7 +84,7 @@ function preflightSemanticDamlEntityData(entityType: OcfEntityType, input: unkno hasOwnField(input, 'quantity_converted') && input.quantity_converted !== null ) { - requireDecimalString(input.quantity_converted, 'convertibleConversion.quantity_converted'); + requireGeneratedDamlNumeric10(input.quantity_converted, 'convertibleConversion.quantity_converted'); } } @@ -96,6 +105,9 @@ function createEntityDataDecoder( if (isComplexIssuanceEntityType(entityType)) { validateComplexIssuanceDamlDataInput(entityType, input); } + if (isStockCorporateActionEntityType(entityType) && input !== undefined) { + assertSafeGeneratedDamlJson(input, `damlToOcf.${entityType}`); + } assertCanonicalJsonGraph(input, entityType); preflightSemanticDamlEntityData(entityType, input); const options = { @@ -357,5 +369,9 @@ export function extractAndDecodeDamlEntityData( return extractAndDecodeVestingData(entityType, createArgument); } + if (isStockCorporateActionEntityType(entityType)) { + return extractAndDecodeStockCorporateActionData(entityType, createArgument); + } + return decodeDamlEntityData(entityType, extractEntityData(entityType, createArgument)); } diff --git a/src/functions/OpenCapTable/capTable/damlToOcf.ts b/src/functions/OpenCapTable/capTable/damlToOcf.ts index d2f1712b..93263100 100644 --- a/src/functions/OpenCapTable/capTable/damlToOcf.ts +++ b/src/functions/OpenCapTable/capTable/damlToOcf.ts @@ -14,7 +14,12 @@ import { OcpErrorCodes, OcpParseError } from '../../../errors'; import type { ReadScopeParams } from '../../../types/common'; import { assertCanonicalJsonGraph } from '../shared/ocfValues'; import { readSingleContract } from '../shared/singleContractRead'; -import { ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfDataTypeFor, type OcfEntityType } from './batchTypes'; +import { + ENTITY_TEMPLATE_ID_MAP, + type DamlDataTypeFor, + type OcfEntityType, + type OcfReadDataTypeFor, +} from './batchTypes'; import { extractAndDecodeDamlEntityData } from './damlEntityData'; // Import converters from entity folders @@ -43,7 +48,6 @@ import { damlStockCancellationToNative } from '../stockCancellation/damlToOcf'; import { damlStockClassDataToNative } from '../stockClass/getStockClassAsOcf'; import { damlStockClassAuthorizedSharesAdjustmentDataToNative } from '../stockClassAuthorizedSharesAdjustment/getStockClassAuthorizedSharesAdjustmentAsOcf'; import { damlStockClassConversionRatioAdjustmentToNative } from '../stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment'; -import { decodeStockClassConversionRatioAdjustmentCreateArgument } from '../stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf'; import { damlStockClassSplitToNative } from '../stockClassSplit/damlToStockClassSplit'; import { damlStockConsolidationToNative } from '../stockConsolidation/damlToStockConsolidation'; import { damlStockConversionToNative } from '../stockConversion/damlToOcf'; @@ -103,11 +107,11 @@ export type SupportedOcfReadType = OcfEntityType; export function convertToOcf( type: EntityType, damlData: DamlDataTypeFor -): OcfDataTypeFor; +): OcfReadDataTypeFor; export function convertToOcf( type: SupportedOcfReadType, data: DamlDataTypeFor -): OcfDataTypeFor { +): OcfReadDataTypeFor { // Transfer converters perform their own parse-error preflight before generated // decoding. Dispatch them before the generic writer-oriented JSON validator so // every direct and dispatcher transfer read reports the same public error family. @@ -301,9 +305,9 @@ export { extractCreateArgument } from '../shared/singleContractRead'; */ export interface GetEntityAsOcfResult { /** The native OCF data */ - data: OcfDataTypeFor; + readonly data: OcfReadDataTypeFor; /** The contract ID */ - contractId: string; + readonly contractId: string; } export interface GetEntityAsOcfOptions extends ReadScopeParams {} @@ -353,12 +357,8 @@ export async function getEntityAsOcf( // Convert DAML data to native OCF format const nativeData = convertToOcf(entityType, decodedEntityData); - if (entityType === 'stockClassConversionRatioAdjustment') { - decodeStockClassConversionRatioAdjustmentCreateArgument(createArgument, `damlToOcf.${entityType}.createArgument`); - } - - return { + return Object.freeze({ data: nativeData, contractId, - }; + }); } diff --git a/src/functions/OpenCapTable/capTable/entityTypes.ts b/src/functions/OpenCapTable/capTable/entityTypes.ts index bcc94622..8c25fb78 100644 --- a/src/functions/OpenCapTable/capTable/entityTypes.ts +++ b/src/functions/OpenCapTable/capTable/entityTypes.ts @@ -6,6 +6,7 @@ * of the package's root declaration graph. */ +import type { DeepReadonly } from '../../../types/common'; import type { OcfConvertibleAcceptance, OcfConvertibleCancellation, @@ -118,6 +119,19 @@ export type OcfEntityType = keyof OcfEntityDataMap; /** Canonical OCF data for one entity kind. */ export type OcfDataTypeFor = OcfEntityDataMap[T]; +/** Entity kinds whose read results are recursively frozen snapshots. */ +export type ImmutableOcfReadEntityType = + | 'stockClassConversionRatioAdjustment' + | 'stockClassSplit' + | 'stockConsolidation' + | 'stockReissuance' + | 'stockRepurchase'; + +/** Canonical data returned by a reader, including immutable corporate-action snapshots. */ +export type OcfReadDataTypeFor = T extends ImmutableOcfReadEntityType + ? DeepReadonly> + : OcfDataTypeFor; + /** Entity kinds that can be created through UpdateCapTable. */ export type OcfCreatableEntityType = Exclude; @@ -247,7 +261,7 @@ export type OcfReadableObjectType = keyof typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE; export type OcfEntityTypeForObjectType = (typeof OCF_OBJECT_TYPE_TO_ENTITY_TYPE)[T]; /** Canonical data returned by one object-type reader. */ -export type OcfReadableDataForObjectType = OcfDataTypeFor< +export type OcfReadableDataForObjectType = OcfReadDataTypeFor< OcfEntityTypeForObjectType >; diff --git a/src/functions/OpenCapTable/capTable/stockCorporateActionContractData.ts b/src/functions/OpenCapTable/capTable/stockCorporateActionContractData.ts new file mode 100644 index 00000000..2e14e041 --- /dev/null +++ b/src/functions/OpenCapTable/capTable/stockCorporateActionContractData.ts @@ -0,0 +1,175 @@ +import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; +import { OcpErrorCodes, OcpParseError } from '../../../errors'; +import { toSafeDiagnosticText } from '../../../errors/OcpError'; +import { + assertSafeGeneratedDamlJson, + extractGeneratedCreateArgumentData, +} from '../../../utils/generatedDamlValidation'; +import { validatePartyId } from '../../../utils/validation'; +import { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP, type DamlDataTypeFor, type OcfEntityType } from './batchTypes'; +import { assertLosslessGeneratedDamlRoundTrip } from './damlCodecLosslessness'; + +export type StockCorporateActionEntityType = Extract< + OcfEntityType, + | 'stockClassConversionRatioAdjustment' + | 'stockClassSplit' + | 'stockConsolidation' + | 'stockReissuance' + | 'stockRepurchase' +>; + +export function isStockCorporateActionEntityType( + entityType: OcfEntityType +): entityType is StockCorporateActionEntityType { + return ( + entityType === 'stockClassConversionRatioAdjustment' || + entityType === 'stockClassSplit' || + entityType === 'stockConsolidation' || + entityType === 'stockReissuance' || + entityType === 'stockRepurchase' + ); +} + +interface GeneratedContextWrapper { + readonly context: { + readonly issuer: string; + readonly system_operator: string; + }; +} + +interface DecoderError { + readonly at: string; + readonly message: string; +} + +interface GeneratedCreateArgumentCodec { + readonly decoder: { + run( + input: unknown + ): { readonly ok: true; readonly result: T } | { readonly ok: false; readonly error: DecoderError }; + }; + encode(value: T): unknown; +} + +type StockCorporateActionCreateArgumentDecoderMap = { + readonly [EntityType in StockCorporateActionEntityType]: (createArgument: unknown) => DamlDataTypeFor; +}; + +function createArgumentError( + entityType: StockCorporateActionEntityType, + source: string, + message: string, + context: Readonly> +): OcpParseError { + return new OcpParseError(message, { + source, + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'invalid_generated_create_argument', + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + ...context, + }, + }); +} + +function createStockCorporateActionCreateArgumentDecoder< + const EntityType extends StockCorporateActionEntityType, + CreateArgument extends GeneratedContextWrapper, +>( + entityType: EntityType, + codec: GeneratedCreateArgumentCodec, + selectData: (value: CreateArgument) => DamlDataTypeFor +): (createArgument: unknown) => DamlDataTypeFor { + return (createArgument) => { + const rootPath = `damlToOcf.${entityType}.createArgument`; + // This descriptor-only bounded pass runs before generated code can touch a + // property, so proxies, accessors, cycles, and oversized inputs stay inert. + assertSafeGeneratedDamlJson(createArgument, rootPath); + const rawData = extractGeneratedCreateArgumentData(createArgument, rootPath, { + dataField: ENTITY_DATA_FIELD_MAP[entityType], + }); + + const decoded = codec.decoder.run(createArgument); + if (!decoded.ok) { + const { at: decoderPath, message: decoderMessage } = decoded.error; + const decoderSource = decoderPath === 'input' ? rootPath : `${rootPath}${decoderPath.slice('input'.length)}`; + throw createArgumentError( + entityType, + decoderSource, + `Invalid generated DAML create argument for ${entityType} at ${decoderPath}: ${decoderMessage}`, + { decoderPath, decoderMessage } + ); + } + + let encoded: unknown; + try { + encoded = codec.encode(decoded.result); + } catch (error) { + throw createArgumentError( + entityType, + rootPath, + `Unable to encode generated DAML create argument for ${entityType}: ${toSafeDiagnosticText(error)}`, + { phase: 'encode' } + ); + } + + assertSafeGeneratedDamlJson(encoded, `${rootPath}.__encoded`); + assertLosslessGeneratedDamlRoundTrip(createArgument, encoded, { + rootPath, + description: `${entityType} create argument`, + decodeSource: rootPath, + context: { + entityType, + expectedTemplateId: ENTITY_TEMPLATE_ID_MAP[entityType], + }, + }); + + validatePartyId(decoded.result.context.issuer, `${rootPath}.context.issuer`); + validatePartyId(decoded.result.context.system_operator, `${rootPath}.context.system_operator`); + // Preserve the exact raw payload for the correlated entity-data decoder. + // That second boundary owns field-level losslessness, while this full-wrapper + // decoder proves the generated template/context shape and Party semantics. + void selectData(decoded.result); + return rawData as DamlDataTypeFor; + }; +} + +const STOCK_CORPORATE_ACTION_CREATE_ARGUMENT_DECODER_MAP = { + stockClassConversionRatioAdjustment: createStockCorporateActionCreateArgumentDecoder( + 'stockClassConversionRatioAdjustment', + Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment, + (value) => value.adjustment_data + ), + stockClassSplit: createStockCorporateActionCreateArgumentDecoder( + 'stockClassSplit', + Fairmint.OpenCapTable.OCF.StockClassSplit.StockClassSplit, + (value) => value.split_data + ), + stockConsolidation: createStockCorporateActionCreateArgumentDecoder( + 'stockConsolidation', + Fairmint.OpenCapTable.OCF.StockConsolidation.StockConsolidation, + (value) => value.consolidation_data + ), + stockReissuance: createStockCorporateActionCreateArgumentDecoder( + 'stockReissuance', + Fairmint.OpenCapTable.OCF.StockReissuance.StockReissuance, + (value) => value.reissuance_data + ), + stockRepurchase: createStockCorporateActionCreateArgumentDecoder( + 'stockRepurchase', + Fairmint.OpenCapTable.OCF.StockRepurchase.StockRepurchase, + (value) => value.repurchase_data + ), +} as const satisfies StockCorporateActionCreateArgumentDecoderMap; + +/** Decode one exact full generated wrapper and return its correlated corporate-action payload. */ +export function extractAndDecodeStockCorporateActionData( + entityType: EntityType, + createArgument: unknown +): DamlDataTypeFor { + const decoder = STOCK_CORPORATE_ACTION_CREATE_ARGUMENT_DECODER_MAP[entityType] as ( + value: unknown + ) => DamlDataTypeFor; + return decoder(createArgument); +} diff --git a/src/functions/OpenCapTable/shared/generatedDamlValues.ts b/src/functions/OpenCapTable/shared/generatedDamlValues.ts index fec0b442..0c25ad27 100644 --- a/src/functions/OpenCapTable/shared/generatedDamlValues.ts +++ b/src/functions/OpenCapTable/shared/generatedDamlValues.ts @@ -23,7 +23,7 @@ function invalidType(fieldPath: string, expectedType: string, receivedValue: unk }); } -/** Decode a generated DAML Numeric(10) string with exact fixed-point syntax and range handling. */ +/** Decode a generated DAML Numeric(10) string with exact range and exponent handling. */ export function requireGeneratedDamlNumeric10( value: unknown, fieldPath: string, @@ -36,10 +36,13 @@ export function requireGeneratedDamlNumeric10( ? 'nonnegative DAML Numeric(10) string' : 'DAML Numeric(10) string'; - if (value === undefined) requiredValue(fieldPath, expectedType, value); + if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); if (typeof value !== 'string') invalidType(fieldPath, expectedType, value); - const numeric = canonicalizeNumeric10(value, { allowExponent: false }); + // The pinned generated @daml/types Numeric(10) codec accepts scientific + // notation and preserves it on encode. Canonical OCF output remains fixed + // point, while OCF writers continue to reject exponent syntax. + const numeric = canonicalizeNumeric10(value, { allowExponent: true }); if (!numeric.ok) { throw new OcpValidationError(fieldPath, numeric.message, { code: OcpErrorCodes.INVALID_FORMAT, @@ -67,7 +70,7 @@ export function requireGeneratedDamlNumeric10( function requireGeneratedCurrencyCode(value: unknown, fieldPath: string): string { const expectedType = 'three-letter uppercase ISO 4217 currency code'; - if (value === undefined) requiredValue(fieldPath, expectedType, value); + if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); if (typeof value !== 'string') invalidType(fieldPath, expectedType, value); if (!/^[A-Z]{3}$/.test(value)) { throw new OcpValidationError(fieldPath, 'Currency must be exactly three uppercase ASCII letters', { @@ -80,7 +83,11 @@ function requireGeneratedCurrencyCode(value: unknown, fieldPath: string): string } /** Decode an exact generated DAML Monetary record without invoking traps or discarding fields. */ -export function requireGeneratedDamlMonetary(value: unknown, fieldPath: string): Monetary { +export function requireGeneratedDamlMonetary( + value: unknown, + fieldPath: string, + amountRange: GeneratedDamlNumericRange = 'nonnegative' +): Monetary { const expectedType = 'exact generated DAML Monetary record'; if (value === null || value === undefined) requiredValue(fieldPath, expectedType, value); assertNotRuntimeProxy(value, fieldPath, expectedType); @@ -88,7 +95,7 @@ export function requireGeneratedDamlMonetary(value: unknown, fieldPath: string): assertExactObjectFields(value, ['amount', 'currency'], fieldPath); return { - amount: requireGeneratedDamlNumeric10(value.amount, `${fieldPath}.amount`, 'nonnegative'), + amount: requireGeneratedDamlNumeric10(value.amount, `${fieldPath}.amount`, amountRange), currency: requireGeneratedCurrencyCode(value.currency, `${fieldPath}.currency`), }; } diff --git a/src/functions/OpenCapTable/shared/stockCorporateActionValues.ts b/src/functions/OpenCapTable/shared/stockCorporateActionValues.ts new file mode 100644 index 00000000..839a1bc0 --- /dev/null +++ b/src/functions/OpenCapTable/shared/stockCorporateActionValues.ts @@ -0,0 +1,134 @@ +import { OcpErrorCodes, OcpValidationError } from '../../../errors'; +import type { DeepReadonly } from '../../../types/common'; +import type { + OcfStockClassConversionRatioAdjustment, + OcfStockClassSplit, + OcfStockConsolidation, + OcfStockReissuance, + OcfStockRepurchase, +} from '../../../types/native'; +import { requireStringArray } from './ocfValues'; + +type StockCorporateAction = + | OcfStockClassConversionRatioAdjustment + | OcfStockClassSplit + | OcfStockConsolidation + | OcfStockReissuance + | OcfStockRepurchase; + +function required(fieldPath: string, expectedType: string, receivedValue: unknown): never { + throw new OcpValidationError(fieldPath, `${fieldPath} is required`, { + code: OcpErrorCodes.REQUIRED_FIELD_MISSING, + expectedType, + receivedValue, + }); +} + +function invalidType(fieldPath: string, expectedType: string, receivedValue: unknown): never { + throw new OcpValidationError(fieldPath, `${fieldPath} has an invalid type`, { + code: OcpErrorCodes.INVALID_TYPE, + expectedType, + receivedValue, + }); +} + +/** Enforce the non-empty DAML Text refinements shared by stock corporate actions. */ +export function requireStockCorporateActionText(value: unknown, fieldPath: string): string { + if (value === null || value === undefined) required(fieldPath, 'non-empty string', value); + if (typeof value !== 'string') invalidType(fieldPath, 'non-empty string', value); + if (value.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must be non-empty`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'non-empty string', + receivedValue: value, + }); + } + return value; +} + +/** Decode/encode an optional Text whose present DAML value must be non-empty. */ +export function optionalStockCorporateActionText(value: unknown, fieldPath: string): string | undefined { + if (value === undefined || value === null) return undefined; + return requireStockCorporateActionText(value, fieldPath); +} + +/** Encode a canonical optional Text as the exact generated DAML Optional representation. */ +export function optionalStockCorporateActionTextToDaml(value: unknown, fieldPath: string): string | null { + return optionalStockCorporateActionText(value, fieldPath) ?? null; +} + +/** Validate comments: the list may be empty, but every DAML Text element must be non-empty. */ +export function requireStockCorporateActionComments(value: unknown, fieldPath: string): string[] { + return requireStringArray(value, fieldPath).map((comment, index) => + requireStockCorporateActionText(comment, `${fieldPath}[${index}]`) + ); +} + +/** Encode optional canonical comments using the exact required generated DAML list. */ +export function stockCorporateActionCommentsToDaml(value: unknown, fieldPath: string): string[] { + if (value === undefined) return []; + return requireStockCorporateActionComments(value, fieldPath); +} + +/** + * Validate a required identifier list against OCF cardinality and pinned DAML uniqueness/text refinements. + */ +export function requireStockCorporateActionIdentifiers(value: unknown, fieldPath: string): [string, ...string[]] { + const identifiers = requireStringArray(value, fieldPath).map((identifier, index) => + requireStockCorporateActionText(identifier, `${fieldPath}[${index}]`) + ); + if (identifiers.length === 0) { + throw new OcpValidationError(fieldPath, `${fieldPath} must contain at least one identifier`, { + code: OcpErrorCodes.OUT_OF_RANGE, + expectedType: 'non-empty array of unique, non-empty strings', + receivedValue: identifiers, + }); + } + + const firstIndexByIdentifier = new Map(); + for (let index = 0; index < identifiers.length; index += 1) { + const identifier = identifiers[index]; + if (identifier === undefined) throw new TypeError('Validated corporate-action identifier disappeared'); + const duplicateOfIndex = firstIndexByIdentifier.get(identifier); + if (duplicateOfIndex !== undefined) { + throw new OcpValidationError(`${fieldPath}[${index}]`, `${fieldPath} identifiers must be unique`, { + code: OcpErrorCodes.INVALID_FORMAT, + expectedType: 'unique identifier', + receivedValue: identifier, + context: { duplicateIndex: index, duplicateOfIndex }, + }); + } + firstIndexByIdentifier.set(identifier, index); + } + + const [first, ...remaining] = identifiers; + if (first === undefined) throw new TypeError('Non-empty corporate-action identifier validation returned empty'); + return [first, ...remaining]; +} + +function cloneAndFreeze(value: Value): DeepReadonly { + if (value === null || typeof value !== 'object') return value as DeepReadonly; + if (Array.isArray(value)) { + return Object.freeze(value.map((item) => cloneAndFreeze(item))) as DeepReadonly; + } + + const clone: Record = {}; + for (const key of Object.keys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) { + throw new TypeError('Constructed corporate-action event must contain only data properties'); + } + Object.defineProperty(clone, key, { + configurable: false, + enumerable: true, + value: cloneAndFreeze(descriptor.value), + writable: false, + }); + } + return Object.freeze(clone) as DeepReadonly; +} + +/** Return a detached, recursively frozen stock corporate-action read snapshot. */ +export function freezeStockCorporateActionEvent(event: T): DeepReadonly { + return cloneAndFreeze(event); +} diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts index 6ef06c20..ac84c27c 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/damlToStockClassConversionRatioAdjustment.ts @@ -3,9 +3,14 @@ import { OcpErrorCodes, OcpParseError, OcpValidationError } from '../../../errors'; import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; import { damlTimeToDateString, isRecord } from '../../../utils/typeConversions'; -import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../capTable/batchTypes'; import { decodeDamlEntityData } from '../capTable/damlEntityData'; -import { requireDenseArray, requireMonetary, requirePositiveDecimal } from '../shared/ocfValues'; +import { requireGeneratedDamlMonetary, requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; +import { + freezeStockCorporateActionEvent, + requireStockCorporateActionComments, + requireStockCorporateActionText, +} from '../shared/stockCorporateActionValues'; /** Exact generated ledger representation accepted by the direct reader. */ export type DamlStockClassConversionRatioAdjustmentData = DamlDataTypeFor<'stockClassConversionRatioAdjustment'>; @@ -26,35 +31,14 @@ function invalidType(field: string, expectedType: string, receivedValue: unknown }); } -function invalidFormat(field: string, expectedType: string, receivedValue: unknown): OcpValidationError { - return new OcpValidationError(field, `${field} has an invalid format`, { - code: OcpErrorCodes.INVALID_FORMAT, - expectedType, - receivedValue, - }); -} - function requireRecord(value: unknown, field: string): Record { if (value === null || value === undefined) throw requiredMissing(field, 'object', value); if (!isRecord(value)) throw invalidType(field, 'object', value); return value; } -function requireNonEmptyString(value: unknown, field: string): string { - if (value === null || value === undefined) throw requiredMissing(field, 'non-empty string', value); - if (typeof value !== 'string') throw invalidType(field, 'non-empty string', value); - if (value.length === 0) throw invalidFormat(field, 'non-empty string', value); - return value; -} - -function requireString(value: unknown, field: string): string { - if (value === null || value === undefined) throw requiredMissing(field, 'string', value); - if (typeof value !== 'string') throw invalidType(field, 'string', value); - return value; -} - function roundingTypeFromDaml(value: unknown, field: string): 'NORMAL' | 'CEILING' | 'FLOOR' { - const constructor = requireNonEmptyString(value, field); + const constructor = requireStockCorporateActionText(value, field); switch (constructor) { case 'OcfRoundingNormal': return 'NORMAL'; @@ -70,40 +54,39 @@ function roundingTypeFromDaml(value: unknown, field: string): 'NORMAL' | 'CEILIN } } -function commentsFromDaml(value: unknown): string[] | undefined { - if (value === null || value === undefined) return undefined; - const comments = requireDenseArray(value, 'stockClassConversionRatioAdjustment.comments').map((comment, index) => - requireString(comment, `stockClassConversionRatioAdjustment.comments.${index}`) - ); - return comments.length === 0 ? undefined : comments; -} - /** Convert exact generated DAML adjustment data to canonical OCF. */ export function damlStockClassConversionRatioAdjustmentToNative( input: DamlStockClassConversionRatioAdjustmentData -): OcfStockClassConversionRatioAdjustment { +): OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'> { const data = decodeDamlEntityData('stockClassConversionRatioAdjustment', input); const mechanismField = 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism'; const mechanism = requireRecord(data.new_ratio_conversion_mechanism, mechanismField); const ratio = requireRecord(mechanism.ratio, `${mechanismField}.ratio`); - const comments = commentsFromDaml(data.comments); + const comments = requireStockCorporateActionComments(data.comments, 'stockClassConversionRatioAdjustment.comments'); const native: OcfStockClassConversionRatioAdjustment = { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: requireString(data.id, 'stockClassConversionRatioAdjustment.id'), + id: requireStockCorporateActionText(data.id, 'stockClassConversionRatioAdjustment.id'), date: damlTimeToDateString(data.date, 'stockClassConversionRatioAdjustment.date'), - stock_class_id: requireString(data.stock_class_id, 'stockClassConversionRatioAdjustment.stock_class_id'), + stock_class_id: requireStockCorporateActionText( + data.stock_class_id, + 'stockClassConversionRatioAdjustment.stock_class_id' + ), new_ratio_conversion_mechanism: { type: 'RATIO_CONVERSION', - conversion_price: requireMonetary(mechanism.conversion_price, `${mechanismField}.conversion_price`), + conversion_price: requireGeneratedDamlMonetary(mechanism.conversion_price, `${mechanismField}.conversion_price`), ratio: { - numerator: requirePositiveDecimal(ratio.numerator, `${mechanismField}.ratio.numerator`), - denominator: requirePositiveDecimal(ratio.denominator, `${mechanismField}.ratio.denominator`), + numerator: requireGeneratedDamlNumeric10(ratio.numerator, `${mechanismField}.ratio.numerator`, 'positive'), + denominator: requireGeneratedDamlNumeric10( + ratio.denominator, + `${mechanismField}.ratio.denominator`, + 'positive' + ), }, rounding_type: roundingTypeFromDaml(mechanism.rounding_type, `${mechanismField}.rounding_type`), }, - ...(comments !== undefined ? { comments } : {}), + ...(comments.length > 0 ? { comments } : {}), }; - return native; + return freezeStockCorporateActionEvent(native); } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts index 859261cf..5d0b9394 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/getStockClassConversionRatioAdjustmentAsOcf.ts @@ -1,59 +1,28 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; -import { Fairmint } from '@fairmint/open-captable-protocol-daml-js'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStockClassConversionRatioAdjustment } from '../../../types/native'; -import { decodeGeneratedDaml, extractGeneratedCreateArgumentData } from '../../../utils/generatedDamlValidation'; +import type { OcfReadDataTypeFor } from '../capTable/batchTypes'; import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockClassConversionRatioAdjustmentToNative } from './damlToStockClassConversionRatioAdjustment'; -export type OcfStockClassConversionRatioAdjustmentEvent = OcfStockClassConversionRatioAdjustment; +export type OcfStockClassConversionRatioAdjustmentEvent = OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>; export type GetStockClassConversionRatioAdjustmentAsOcfParams = GetByContractIdParams; export interface GetStockClassConversionRatioAdjustmentAsOcfResult { - event: OcfStockClassConversionRatioAdjustmentEvent; - contractId: string; -} - -/** Type alias for the exact generated contract create argument. */ -type StockClassConversionRatioAdjustmentCreateArgument = - Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment; - -/** Validate the complete generated template wrapper before exposing its adjustment data. */ -export function decodeStockClassConversionRatioAdjustmentCreateArgument( - createArgument: unknown, - source: string -): StockClassConversionRatioAdjustmentCreateArgument { - extractGeneratedCreateArgumentData(createArgument, source, { dataField: 'adjustment_data' }); - const template = Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment; - return decodeGeneratedDaml( - createArgument, - { - decode: (value) => template.decoder.runWithException(value), - encode: (value) => template.encode(value), - }, - source, - { - classification: 'invalid_generated_create_argument', - context: { expectedTemplateId: template.templateId }, - } - ); + readonly event: OcfStockClassConversionRatioAdjustmentEvent; + readonly contractId: string; } export async function getStockClassConversionRatioAdjustmentAsOcf( client: LedgerJsonApiClient, params: GetStockClassConversionRatioAdjustmentAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockClassConversionRatioAdjustmentAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockClassConversionRatioAdjustment, }); const data = extractAndDecodeDamlEntityData('stockClassConversionRatioAdjustment', createArgument); const event = damlStockClassConversionRatioAdjustmentToNative(data); - decodeStockClassConversionRatioAdjustmentCreateArgument( - createArgument, - 'StockClassConversionRatioAdjustment.createArgument' - ); - return { event, contractId: params.contractId }; + return Object.freeze({ event, contractId }); } diff --git a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts index 08c93701..3338211f 100644 --- a/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassConversionRatioAdjustment/stockClassConversionRatioAdjustmentDataToDaml.ts @@ -8,11 +8,15 @@ import { assertCanonicalJsonGraph, assertExactObjectFields, assertNotRuntimeProxy, - optionalStringArrayToDaml, requireCurrencyCode, requireNonnegativeDecimal, requirePositiveDecimal, } from '../shared/ocfValues'; +import { validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { + requireStockCorporateActionText, + stockCorporateActionCommentsToDaml, +} from '../shared/stockCorporateActionValues'; type DamlRatioAdjustment = Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustmentOcfData; @@ -147,10 +151,10 @@ export function stockClassConversionRatioAdjustmentDataToDaml( assertExactObjectFields(ratio, RATIO_FIELDS, ratioField); assertCanonicalJsonGraph(input, field, { rejectUndefined: true }); - return { - id: requireString(data.id, `${field}.id`), + const result = { + id: requireStockCorporateActionText(data.id, `${field}.id`), date: requiredDateToDaml(data.date, `${field}.date`), - stock_class_id: requireString(data.stock_class_id, `${field}.stock_class_id`), + stock_class_id: requireStockCorporateActionText(data.stock_class_id, `${field}.stock_class_id`), new_ratio_conversion_mechanism: { conversion_price: requiredMonetary(monetary, monetaryField), ratio: { @@ -159,6 +163,14 @@ export function stockClassConversionRatioAdjustmentDataToDaml( }, rounding_type: roundingTypeToDaml(mechanism.rounding_type), }, - comments: optionalStringArrayToDaml(data.comments, `${field}.comments`), - }; + comments: stockCorporateActionCommentsToDaml(data.comments, `${field}.comments`), + } satisfies DamlRatioAdjustment; + + validateCanonicalWriterInput( + 'stockClassConversionRatioAdjustment', + 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', + data, + field + ); + return result; } diff --git a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts index 78b381d1..e94b5a8b 100644 --- a/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts +++ b/src/functions/OpenCapTable/stockClassSplit/damlToStockClassSplit.ts @@ -4,9 +4,14 @@ import type { OcfStockClassSplit } from '../../../types/native'; import { damlTimeToDateString } from '../../../utils/typeConversions'; -import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../capTable/batchTypes'; import { decodeDamlEntityData } from '../capTable/damlEntityData'; import { requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; +import { + freezeStockCorporateActionEvent, + requireStockCorporateActionComments, + requireStockCorporateActionText, +} from '../shared/stockCorporateActionValues'; /** DAML StockClassSplitOcfData structure */ export type DamlStockClassSplitData = DamlDataTypeFor<'stockClassSplit'>; @@ -16,20 +21,27 @@ export type DamlStockClassSplitData = DamlDataTypeFor<'stockClassSplit'>; * * Handles the nested OcfRatio structure and normalizes numeric strings. */ -export function damlStockClassSplitToNative(input: DamlStockClassSplitData): OcfStockClassSplit { +export function damlStockClassSplitToNative(input: DamlStockClassSplitData): OcfReadDataTypeFor<'stockClassSplit'> { const data = decodeDamlEntityData('stockClassSplit', input); - return { + const comments = requireStockCorporateActionComments(data.comments, 'stockClassSplit.comments'); + const event: OcfStockClassSplit = { object_type: 'TX_STOCK_CLASS_SPLIT', - id: data.id, + id: requireStockCorporateActionText(data.id, 'stockClassSplit.id'), date: damlTimeToDateString(data.date, 'stockClassSplit.date'), - stock_class_id: data.stock_class_id, + stock_class_id: requireStockCorporateActionText(data.stock_class_id, 'stockClassSplit.stock_class_id'), split_ratio: { - numerator: requireGeneratedDamlNumeric10(data.split_ratio.numerator, 'stockClassSplit.split_ratio.numerator'), + numerator: requireGeneratedDamlNumeric10( + data.split_ratio.numerator, + 'stockClassSplit.split_ratio.numerator', + 'positive' + ), denominator: requireGeneratedDamlNumeric10( data.split_ratio.denominator, - 'stockClassSplit.split_ratio.denominator' + 'stockClassSplit.split_ratio.denominator', + 'positive' ), }, - ...(data.comments.length > 0 ? { comments: data.comments } : {}), + ...(comments.length > 0 ? { comments } : {}), }; + return freezeStockCorporateActionEvent(event); } diff --git a/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts b/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts index 01458235..d60a5957 100644 --- a/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts +++ b/src/functions/OpenCapTable/stockClassSplit/getStockClassSplitAsOcf.ts @@ -1,28 +1,27 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStockClassSplit } from '../../../types/native'; -import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfReadDataTypeFor } from '../capTable/batchTypes'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockClassSplitToNative } from './damlToStockClassSplit'; -export type OcfStockClassSplitEvent = OcfStockClassSplit; +export type OcfStockClassSplitEvent = OcfReadDataTypeFor<'stockClassSplit'>; export type GetStockClassSplitAsOcfParams = GetByContractIdParams; export interface GetStockClassSplitAsOcfResult { - event: OcfStockClassSplitEvent; - contractId: string; + readonly event: OcfStockClassSplitEvent; + readonly contractId: string; } export async function getStockClassSplitAsOcf( client: LedgerJsonApiClient, params: GetStockClassSplitAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockClassSplitAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockClassSplit, }); const data = extractAndDecodeDamlEntityData('stockClassSplit', createArgument); const event = damlStockClassSplitToNative(data); - return { event, contractId: params.contractId }; + return Object.freeze({ event, contractId }); } diff --git a/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts b/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts index c62aff55..5710635b 100644 --- a/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts +++ b/src/functions/OpenCapTable/stockClassSplit/stockClassSplitDataToDaml.ts @@ -5,9 +5,12 @@ import type { OcfStockClassSplit } from '../../../types/native'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; -import { requiredTextToDaml } from '../shared/damlText'; -import { requireDecimalString } from '../shared/ocfValues'; -import { commentsToDaml, requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { requirePositiveDecimal } from '../shared/ocfValues'; +import { requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { + requireStockCorporateActionText, + stockCorporateActionCommentsToDaml, +} from '../shared/stockCorporateActionValues'; const ROOT_FIELDS = ['comments', 'date', 'id', 'object_type', 'split_ratio', 'stock_class_id'] as const; const RATIO_FIELDS = ['denominator', 'numerator'] as const; @@ -24,14 +27,14 @@ export function stockClassSplitDataToDaml(d: OcfStockClassSplit): DamlDataTypeFo const ratio = requireExactWriterInput(input.split_ratio, ratioPath, RATIO_FIELDS); const result = { - id: requiredTextToDaml(input.id, `${path}.id`), + id: requireStockCorporateActionText(input.id, `${path}.id`), date: dateStringToDAMLTime(input.date, `${path}.date`), - stock_class_id: requiredTextToDaml(input.stock_class_id, `${path}.stock_class_id`), + stock_class_id: requireStockCorporateActionText(input.stock_class_id, `${path}.stock_class_id`), split_ratio: { - numerator: requireDecimalString(ratio.numerator, `${ratioPath}.numerator`), - denominator: requireDecimalString(ratio.denominator, `${ratioPath}.denominator`), + numerator: requirePositiveDecimal(ratio.numerator, `${ratioPath}.numerator`), + denominator: requirePositiveDecimal(ratio.denominator, `${ratioPath}.denominator`), }, - comments: commentsToDaml(input.comments, `${path}.comments`), + comments: stockCorporateActionCommentsToDaml(input.comments, `${path}.comments`), } satisfies DamlDataTypeFor<'stockClassSplit'>; validateCanonicalWriterInput('stockClassSplit', 'TX_STOCK_CLASS_SPLIT', input, path); diff --git a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts index 5a769fde..cfa061c2 100644 --- a/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts +++ b/src/functions/OpenCapTable/stockConsolidation/damlToStockConsolidation.ts @@ -1,26 +1,40 @@ /** DAML to OCF converter for StockConsolidation. */ import type { OcfStockConsolidation } from '../../../types/native'; -import { damlTimeToDateString, toNonEmptyStringArray } from '../../../utils/typeConversions'; -import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import { damlTimeToDateString } from '../../../utils/typeConversions'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../capTable/batchTypes'; import { decodeDamlEntityData } from '../capTable/damlEntityData'; +import { + freezeStockCorporateActionEvent, + optionalStockCorporateActionText, + requireStockCorporateActionComments, + requireStockCorporateActionIdentifiers, + requireStockCorporateActionText, +} from '../shared/stockCorporateActionValues'; /** Exact generated DAML StockConsolidation payload. */ export type DamlStockConsolidationData = DamlDataTypeFor<'stockConsolidation'>; /** Convert exact generated DAML StockConsolidation data to canonical OCF. */ -export function damlStockConsolidationToNative(input: DamlStockConsolidationData): OcfStockConsolidation { +export function damlStockConsolidationToNative( + input: DamlStockConsolidationData +): OcfReadDataTypeFor<'stockConsolidation'> { const rootPath = 'stockConsolidation'; const data = decodeDamlEntityData('stockConsolidation', input); - const reasonText = data.reason_text ?? undefined; + const reasonText = optionalStockCorporateActionText(data.reason_text, `${rootPath}.reason_text`); + const comments = requireStockCorporateActionComments(data.comments, `${rootPath}.comments`); - return { + const event: OcfStockConsolidation = { object_type: 'TX_STOCK_CONSOLIDATION', - id: data.id, + id: requireStockCorporateActionText(data.id, `${rootPath}.id`), date: damlTimeToDateString(data.date, `${rootPath}.date`), - security_ids: toNonEmptyStringArray(data.security_ids, `${rootPath}.security_ids`, { uniqueItems: true }), - resulting_security_id: data.resulting_security_id, + security_ids: requireStockCorporateActionIdentifiers(data.security_ids, `${rootPath}.security_ids`), + resulting_security_id: requireStockCorporateActionText( + data.resulting_security_id, + `${rootPath}.resulting_security_id` + ), ...(reasonText !== undefined ? { reason_text: reasonText } : {}), - ...(data.comments.length > 0 ? { comments: data.comments } : {}), + ...(comments.length > 0 ? { comments } : {}), }; + return freezeStockCorporateActionEvent(event); } diff --git a/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts b/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts index 1d0499f2..56b79e54 100644 --- a/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts +++ b/src/functions/OpenCapTable/stockConsolidation/getStockConsolidationAsOcf.ts @@ -1,28 +1,27 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStockConsolidation } from '../../../types/native'; -import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfReadDataTypeFor } from '../capTable/batchTypes'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockConsolidationToNative } from './damlToStockConsolidation'; -export type OcfStockConsolidationEvent = OcfStockConsolidation; +export type OcfStockConsolidationEvent = OcfReadDataTypeFor<'stockConsolidation'>; export type GetStockConsolidationAsOcfParams = GetByContractIdParams; export interface GetStockConsolidationAsOcfResult { - event: OcfStockConsolidationEvent; - contractId: string; + readonly event: OcfStockConsolidationEvent; + readonly contractId: string; } export async function getStockConsolidationAsOcf( client: LedgerJsonApiClient, params: GetStockConsolidationAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockConsolidationAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockConsolidation, }); const data = extractAndDecodeDamlEntityData('stockConsolidation', createArgument); const event = damlStockConsolidationToNative(data); - return { event, contractId: params.contractId }; + return Object.freeze({ event, contractId }); } diff --git a/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts b/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts index 0815891e..e6096729 100644 --- a/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts +++ b/src/functions/OpenCapTable/stockConsolidation/stockConsolidationDataToDaml.ts @@ -3,10 +3,15 @@ */ import type { OcfStockConsolidation } from '../../../types/native'; -import { dateStringToDAMLTime, toNonEmptyStringArray } from '../../../utils/typeConversions'; +import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; -import { canonicalOptionalTextToDaml, requiredTextToDaml } from '../shared/damlText'; -import { commentsToDaml, requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { + optionalStockCorporateActionTextToDaml, + requireStockCorporateActionIdentifiers, + requireStockCorporateActionText, + stockCorporateActionCommentsToDaml, +} from '../shared/stockCorporateActionValues'; const ROOT_FIELDS = [ 'comments', @@ -27,12 +32,15 @@ export function stockConsolidationDataToDaml(d: OcfStockConsolidation): DamlData const path = 'stockConsolidation'; const input = requireExactWriterInput(d, path, ROOT_FIELDS); const result = { - id: requiredTextToDaml(input.id, `${path}.id`), + id: requireStockCorporateActionText(input.id, `${path}.id`), date: dateStringToDAMLTime(input.date, `${path}.date`), - security_ids: toNonEmptyStringArray(input.security_ids, `${path}.security_ids`, { uniqueItems: true }), - resulting_security_id: requiredTextToDaml(input.resulting_security_id, `${path}.resulting_security_id`), - reason_text: canonicalOptionalTextToDaml(input.reason_text, `${path}.reason_text`), - comments: commentsToDaml(input.comments, `${path}.comments`), + security_ids: requireStockCorporateActionIdentifiers(input.security_ids, `${path}.security_ids`), + resulting_security_id: requireStockCorporateActionText( + input.resulting_security_id, + `${path}.resulting_security_id` + ), + reason_text: optionalStockCorporateActionTextToDaml(input.reason_text, `${path}.reason_text`), + comments: stockCorporateActionCommentsToDaml(input.comments, `${path}.comments`), } satisfies DamlDataTypeFor<'stockConsolidation'>; validateCanonicalWriterInput('stockConsolidation', 'TX_STOCK_CONSOLIDATION', input, path); diff --git a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts index bdbebb37..427b9e92 100644 --- a/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts +++ b/src/functions/OpenCapTable/stockReissuance/damlToStockReissuance.ts @@ -4,8 +4,15 @@ import type { OcfStockReissuance } from '../../../types/native'; import { damlTimeToDateString } from '../../../utils/typeConversions'; -import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../capTable/batchTypes'; import { decodeDamlEntityData } from '../capTable/damlEntityData'; +import { + freezeStockCorporateActionEvent, + optionalStockCorporateActionText, + requireStockCorporateActionComments, + requireStockCorporateActionIdentifiers, + requireStockCorporateActionText, +} from '../shared/stockCorporateActionValues'; /** DAML StockReissuanceOcfData structure */ export type DamlStockReissuanceData = DamlDataTypeFor<'stockReissuance'>; @@ -13,18 +20,26 @@ export type DamlStockReissuanceData = DamlDataTypeFor<'stockReissuance'>; /** * Convert DAML StockReissuance data to native OCF format. */ -export function damlStockReissuanceToNative(input: DamlStockReissuanceData): OcfStockReissuance { +export function damlStockReissuanceToNative(input: DamlStockReissuanceData): OcfReadDataTypeFor<'stockReissuance'> { const data = decodeDamlEntityData('stockReissuance', input); - const reasonText = data.reason_text ?? undefined; - const splitTransactionId = data.split_transaction_id ?? undefined; - return { + const reasonText = optionalStockCorporateActionText(data.reason_text, 'stockReissuance.reason_text'); + const splitTransactionId = optionalStockCorporateActionText( + data.split_transaction_id, + 'stockReissuance.split_transaction_id' + ); + const comments = requireStockCorporateActionComments(data.comments, 'stockReissuance.comments'); + const event: OcfStockReissuance = { object_type: 'TX_STOCK_REISSUANCE', - id: data.id, + id: requireStockCorporateActionText(data.id, 'stockReissuance.id'), date: damlTimeToDateString(data.date, 'stockReissuance.date'), - security_id: data.security_id, - resulting_security_ids: data.resulting_security_ids, + security_id: requireStockCorporateActionText(data.security_id, 'stockReissuance.security_id'), + resulting_security_ids: requireStockCorporateActionIdentifiers( + data.resulting_security_ids, + 'stockReissuance.resulting_security_ids' + ), ...(reasonText !== undefined ? { reason_text: reasonText } : {}), ...(splitTransactionId !== undefined ? { split_transaction_id: splitTransactionId } : {}), - ...(data.comments.length > 0 ? { comments: data.comments } : {}), + ...(comments.length > 0 ? { comments } : {}), }; + return freezeStockCorporateActionEvent(event); } diff --git a/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts b/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts index 8539217d..d4e8a1db 100644 --- a/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts +++ b/src/functions/OpenCapTable/stockReissuance/getStockReissuanceAsOcf.ts @@ -1,28 +1,27 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStockReissuance } from '../../../types/native'; -import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfReadDataTypeFor } from '../capTable/batchTypes'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockReissuanceToNative } from './damlToStockReissuance'; -export type OcfStockReissuanceEvent = OcfStockReissuance; +export type OcfStockReissuanceEvent = OcfReadDataTypeFor<'stockReissuance'>; export type GetStockReissuanceAsOcfParams = GetByContractIdParams; export interface GetStockReissuanceAsOcfResult { - event: OcfStockReissuanceEvent; - contractId: string; + readonly event: OcfStockReissuanceEvent; + readonly contractId: string; } export async function getStockReissuanceAsOcf( client: LedgerJsonApiClient, params: GetStockReissuanceAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockReissuanceAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockReissuance, }); const data = extractAndDecodeDamlEntityData('stockReissuance', createArgument); const event = damlStockReissuanceToNative(data); - return { event, contractId: params.contractId }; + return Object.freeze({ event, contractId }); } diff --git a/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts b/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts index 9c5fe6a9..e3b39e31 100644 --- a/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts +++ b/src/functions/OpenCapTable/stockReissuance/stockReissuanceDataToDaml.ts @@ -5,9 +5,13 @@ import type { OcfStockReissuance } from '../../../types/native'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; -import { canonicalOptionalTextToDaml, requiredTextToDaml } from '../shared/damlText'; -import { requireStringArray } from '../shared/ocfValues'; -import { commentsToDaml, requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { + optionalStockCorporateActionTextToDaml, + requireStockCorporateActionIdentifiers, + requireStockCorporateActionText, + stockCorporateActionCommentsToDaml, +} from '../shared/stockCorporateActionValues'; const ROOT_FIELDS = [ 'comments', @@ -27,13 +31,19 @@ export function stockReissuanceDataToDaml(d: OcfStockReissuance): DamlDataTypeFo const path = 'stockReissuance'; const input = requireExactWriterInput(d, path, ROOT_FIELDS); const result = { - id: requiredTextToDaml(input.id, `${path}.id`), + id: requireStockCorporateActionText(input.id, `${path}.id`), date: dateStringToDAMLTime(input.date, `${path}.date`), - security_id: requiredTextToDaml(input.security_id, `${path}.security_id`), - resulting_security_ids: requireStringArray(input.resulting_security_ids, `${path}.resulting_security_ids`), - reason_text: canonicalOptionalTextToDaml(input.reason_text, `${path}.reason_text`), - split_transaction_id: canonicalOptionalTextToDaml(input.split_transaction_id, `${path}.split_transaction_id`), - comments: commentsToDaml(input.comments, `${path}.comments`), + security_id: requireStockCorporateActionText(input.security_id, `${path}.security_id`), + resulting_security_ids: requireStockCorporateActionIdentifiers( + input.resulting_security_ids, + `${path}.resulting_security_ids` + ), + reason_text: optionalStockCorporateActionTextToDaml(input.reason_text, `${path}.reason_text`), + split_transaction_id: optionalStockCorporateActionTextToDaml( + input.split_transaction_id, + `${path}.split_transaction_id` + ), + comments: stockCorporateActionCommentsToDaml(input.comments, `${path}.comments`), } satisfies DamlDataTypeFor<'stockReissuance'>; validateCanonicalWriterInput('stockReissuance', 'TX_STOCK_REISSUANCE', input, path); diff --git a/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts b/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts index 522df5dc..b937a817 100644 --- a/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts +++ b/src/functions/OpenCapTable/stockRepurchase/damlToOcf.ts @@ -4,9 +4,15 @@ import type { OcfStockRepurchase } from '../../../types'; import { damlTimeToDateString } from '../../../utils/typeConversions'; -import type { DamlDataTypeFor } from '../capTable/batchTypes'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../capTable/batchTypes'; import { decodeDamlEntityData } from '../capTable/damlEntityData'; import { requireGeneratedDamlMonetary, requireGeneratedDamlNumeric10 } from '../shared/generatedDamlValues'; +import { + freezeStockCorporateActionEvent, + optionalStockCorporateActionText, + requireStockCorporateActionComments, + requireStockCorporateActionText, +} from '../shared/stockCorporateActionValues'; /** * DAML StockRepurchase data structure. @@ -20,19 +26,27 @@ export type DamlStockRepurchaseData = DamlDataTypeFor<'stockRepurchase'>; * @param d - The DAML stock repurchase data object * @returns The native OCF StockRepurchase object */ -export function damlStockRepurchaseToNative(input: DamlStockRepurchaseData): OcfStockRepurchase { +export function damlStockRepurchaseToNative(input: DamlStockRepurchaseData): OcfReadDataTypeFor<'stockRepurchase'> { const data = decodeDamlEntityData('stockRepurchase', input); - const balanceSecurityId = data.balance_security_id ?? undefined; - const considerationText = data.consideration_text ?? undefined; - return { + const balanceSecurityId = optionalStockCorporateActionText( + data.balance_security_id, + 'stockRepurchase.balance_security_id' + ); + const considerationText = optionalStockCorporateActionText( + data.consideration_text, + 'stockRepurchase.consideration_text' + ); + const comments = requireStockCorporateActionComments(data.comments, 'stockRepurchase.comments'); + const event: OcfStockRepurchase = { object_type: 'TX_STOCK_REPURCHASE', - id: data.id, + id: requireStockCorporateActionText(data.id, 'stockRepurchase.id'), date: damlTimeToDateString(data.date, 'stockRepurchase.date'), - security_id: data.security_id, - quantity: requireGeneratedDamlNumeric10(data.quantity, 'stockRepurchase.quantity'), + security_id: requireStockCorporateActionText(data.security_id, 'stockRepurchase.security_id'), + quantity: requireGeneratedDamlNumeric10(data.quantity, 'stockRepurchase.quantity', 'positive'), price: requireGeneratedDamlMonetary(data.price, 'stockRepurchase.price'), ...(balanceSecurityId !== undefined ? { balance_security_id: balanceSecurityId } : {}), ...(considerationText !== undefined ? { consideration_text: considerationText } : {}), - ...(data.comments.length > 0 ? { comments: data.comments } : {}), + ...(comments.length > 0 ? { comments } : {}), }; + return freezeStockCorporateActionEvent(event); } diff --git a/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts b/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts index bb65540a..cb5e19ab 100644 --- a/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts +++ b/src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf.ts @@ -1,7 +1,6 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import type { GetByContractIdParams } from '../../../types/common'; -import type { OcfStockRepurchase } from '../../../types/native'; -import { ENTITY_TEMPLATE_ID_MAP } from '../capTable/batchTypes'; +import { ENTITY_TEMPLATE_ID_MAP, type OcfReadDataTypeFor } from '../capTable/batchTypes'; import { extractAndDecodeDamlEntityData } from '../capTable/damlEntityData'; import { readSingleContract } from '../shared/singleContractRead'; import { damlStockRepurchaseToNative } from './damlToOcf'; @@ -10,24 +9,24 @@ import { damlStockRepurchaseToNative } from './damlToOcf'; * OCF Stock Repurchase Event with object_type discriminator OCF: * https://raw.githubusercontent.com/Open-Cap-Table-Coalition/Open-Cap-Format-OCF/main/schema/objects/transactions/repurchase/StockRepurchase.schema.json */ -export type OcfStockRepurchaseEvent = OcfStockRepurchase; +export type OcfStockRepurchaseEvent = OcfReadDataTypeFor<'stockRepurchase'>; export type GetStockRepurchaseAsOcfParams = GetByContractIdParams; export interface GetStockRepurchaseAsOcfResult { - event: OcfStockRepurchaseEvent; - contractId: string; + readonly event: OcfStockRepurchaseEvent; + readonly contractId: string; } export async function getStockRepurchaseAsOcf( client: LedgerJsonApiClient, params: GetStockRepurchaseAsOcfParams ): Promise { - const { createArgument } = await readSingleContract(client, params, { + const { contractId, createArgument } = await readSingleContract(client, params, { operation: 'getStockRepurchaseAsOcf', expectedTemplateId: ENTITY_TEMPLATE_ID_MAP.stockRepurchase, }); const data = extractAndDecodeDamlEntityData('stockRepurchase', createArgument); const event = damlStockRepurchaseToNative(data); - return { event, contractId: params.contractId }; + return Object.freeze({ event, contractId }); } diff --git a/src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml.ts b/src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml.ts index b873ab1c..35d5d472 100644 --- a/src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml.ts +++ b/src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml.ts @@ -1,9 +1,13 @@ import type { OcfStockRepurchase } from '../../../types'; import { dateStringToDAMLTime } from '../../../utils/typeConversions'; import type { DamlDataTypeFor } from '../capTable/batchTypes'; -import { canonicalOptionalTextToDaml, requiredTextToDaml } from '../shared/damlText'; -import { requireDecimalString, requireMonetary } from '../shared/ocfValues'; -import { commentsToDaml, requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { requireMonetary, requirePositiveDecimal } from '../shared/ocfValues'; +import { requireExactWriterInput, validateCanonicalWriterInput } from '../shared/ocfWriterValidation'; +import { + optionalStockCorporateActionTextToDaml, + requireStockCorporateActionText, + stockCorporateActionCommentsToDaml, +} from '../shared/stockCorporateActionValues'; const ROOT_FIELDS = [ 'balance_security_id', @@ -21,14 +25,17 @@ export function stockRepurchaseDataToDaml(d: OcfStockRepurchase): DamlDataTypeFo const path = 'stockRepurchase'; const input = requireExactWriterInput(d, path, ROOT_FIELDS); const result = { - id: requiredTextToDaml(input.id, `${path}.id`), + id: requireStockCorporateActionText(input.id, `${path}.id`), date: dateStringToDAMLTime(input.date, `${path}.date`), - security_id: requiredTextToDaml(input.security_id, `${path}.security_id`), - quantity: requireDecimalString(input.quantity, `${path}.quantity`), + security_id: requireStockCorporateActionText(input.security_id, `${path}.security_id`), + quantity: requirePositiveDecimal(input.quantity, `${path}.quantity`), price: requireMonetary(input.price, `${path}.price`), - balance_security_id: canonicalOptionalTextToDaml(input.balance_security_id, `${path}.balance_security_id`), - consideration_text: canonicalOptionalTextToDaml(input.consideration_text, `${path}.consideration_text`), - comments: commentsToDaml(input.comments, `${path}.comments`), + balance_security_id: optionalStockCorporateActionTextToDaml( + input.balance_security_id, + `${path}.balance_security_id` + ), + consideration_text: optionalStockCorporateActionTextToDaml(input.consideration_text, `${path}.consideration_text`), + comments: stockCorporateActionCommentsToDaml(input.comments, `${path}.comments`), } satisfies DamlDataTypeFor<'stockRepurchase'>; validateCanonicalWriterInput('stockRepurchase', 'TX_STOCK_REPURCHASE', input, path); diff --git a/src/types/common.ts b/src/types/common.ts index 087f8c9b..3195d358 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -115,3 +115,12 @@ export interface ContractResult { * ``` */ export type WithObjectType = T & { readonly object_type: OT }; + +/** Recursively make an SDK result immutable while preserving tuples and discriminated unions. */ +export type DeepReadonly = T extends (...args: never[]) => unknown + ? T + : T extends readonly unknown[] + ? { readonly [Index in keyof T]: DeepReadonly } + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; diff --git a/src/types/native.ts b/src/types/native.ts index 6f7c4e16..6dc0d7a7 100644 --- a/src/types/native.ts +++ b/src/types/native.ts @@ -1764,7 +1764,7 @@ export interface OcfStockReissuance extends OcfObjectBase<'TX_STOCK_REISSUANCE'> /** Identifier for the security being reissued */ security_id: string; /** Array of identifiers for new securities resulting from the reissuance */ - resulting_security_ids: string[]; + resulting_security_ids: NonEmptyArray; /** Reason for the reissuance */ reason_text?: string; /** Reference to a related split transaction */ diff --git a/src/utils/cantonOcfExtractor.ts b/src/utils/cantonOcfExtractor.ts index 44cb2710..df92e0e4 100644 --- a/src/utils/cantonOcfExtractor.ts +++ b/src/utils/cantonOcfExtractor.ts @@ -899,7 +899,10 @@ export async function extractCantonOcfManifest( default: { // This assignment is deliberately exhaustive: adding a new non-transaction // entity to the SDK must also add an explicit manifest category above. - const transaction: OcfTransaction = data; + // Read-only entity snapshots are safe inputs to the sorter and + // manifest collector. The manifest's legacy mutable annotation + // is retained until every reader family has migrated. + const transaction = data as OcfTransaction; appendValidatedTransaction(result.transactions, transaction); } } diff --git a/test/batch/stockCorporateActionOperations.test.ts b/test/batch/stockCorporateActionOperations.test.ts index e2bd9c31..a377cd86 100644 --- a/test/batch/stockCorporateActionOperations.test.ts +++ b/test/batch/stockCorporateActionOperations.test.ts @@ -36,106 +36,106 @@ const corporateActionCases = [ entityType: 'stockClassConversionRatioAdjustment', data: { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: '', + id: 'ratio-adjustment-1', date: '2026-07-10', - stock_class_id: '', + stock_class_id: 'preferred-1', new_ratio_conversion_mechanism: { type: 'RATIO_CONVERSION', - conversion_price: { amount: '+0001.2500000000', currency: 'USD' }, - ratio: { numerator: '+0002.0000000000', denominator: '+0001.0000000000' }, + conversion_price: { amount: '1.2500000000', currency: 'USD' }, + ratio: { numerator: '2.0000000000', denominator: '1.0000000000' }, rounding_type: 'FLOOR', }, - comments: [''], + comments: ['repriced'], }, expected: { - id: '', - stock_class_id: '', + id: 'ratio-adjustment-1', + stock_class_id: 'preferred-1', new_ratio_conversion_mechanism: { conversion_price: { amount: '1.25', currency: 'USD' }, ratio: { numerator: '2', denominator: '1' }, rounding_type: 'OcfRoundingFloor', }, - comments: [''], + comments: ['repriced'], }, }, { entityType: 'stockClassSplit', data: { object_type: 'TX_STOCK_CLASS_SPLIT', - id: '', + id: 'split-1', date: '2026-07-10', - stock_class_id: '', - split_ratio: { numerator: '+0004.0000000000', denominator: '1.0000000000' }, - comments: [''], + stock_class_id: 'common-1', + split_ratio: { numerator: '4.0000000000', denominator: '1.0000000000' }, + comments: ['split'], }, expected: { - id: '', - stock_class_id: '', + id: 'split-1', + stock_class_id: 'common-1', split_ratio: { numerator: '4', denominator: '1' }, - comments: [''], + comments: ['split'], }, }, { entityType: 'stockConsolidation', data: { object_type: 'TX_STOCK_CONSOLIDATION', - id: '', + id: 'consolidation-1', date: '2026-07-10', - security_ids: [''], - resulting_security_id: '', - reason_text: '', - comments: [''], + security_ids: ['security-old-1'], + resulting_security_id: 'security-new-1', + reason_text: 'cleanup', + comments: ['consolidated'], }, expected: { - id: '', - security_ids: [''], - resulting_security_id: '', - reason_text: '', - comments: [''], + id: 'consolidation-1', + security_ids: ['security-old-1'], + resulting_security_id: 'security-new-1', + reason_text: 'cleanup', + comments: ['consolidated'], }, }, { entityType: 'stockReissuance', data: { object_type: 'TX_STOCK_REISSUANCE', - id: '', + id: 'reissuance-1', date: '2026-07-10', - security_id: '', - resulting_security_ids: ['', 'duplicate', 'duplicate'], - reason_text: '', - split_transaction_id: '', - comments: [''], + security_id: 'security-old-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'replacement', + split_transaction_id: 'split-1', + comments: ['reissued'], }, expected: { - id: '', - security_id: '', - resulting_security_ids: ['', 'duplicate', 'duplicate'], - reason_text: '', - split_transaction_id: '', - comments: [''], + id: 'reissuance-1', + security_id: 'security-old-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'replacement', + split_transaction_id: 'split-1', + comments: ['reissued'], }, }, { entityType: 'stockRepurchase', data: { object_type: 'TX_STOCK_REPURCHASE', - id: '', + id: 'repurchase-1', date: '2026-07-10', - security_id: '', - quantity: '-0', - price: { amount: '+0001.2500000000', currency: 'USD' }, - balance_security_id: '', - consideration_text: '', - comments: [''], + security_id: 'security-1', + quantity: '12.5000000000', + price: { amount: '1.2500000000', currency: 'USD' }, + balance_security_id: 'security-balance-1', + consideration_text: 'cash', + comments: ['repurchased'], }, expected: { - id: '', - security_id: '', - quantity: '0', + id: 'repurchase-1', + security_id: 'security-1', + quantity: '12.5', price: { amount: '1.25', currency: 'USD' }, - balance_security_id: '', - consideration_text: '', - comments: [''], + balance_security_id: 'security-balance-1', + consideration_text: 'cash', + comments: ['repurchased'], }, }, ] as const satisfies readonly CorporateActionCase[]; @@ -225,7 +225,7 @@ describe('stock corporate-action operation boundaries', () => { expect(error).toBeInstanceOf(OcpValidationError); const validationError = error as OcpValidationError; expect(validationError.fieldPath).toBe( - securityIds.length === 0 ? 'stockConsolidation.security_ids' : 'stockConsolidation.security_ids.1' + securityIds.length === 0 ? 'stockConsolidation.security_ids' : 'stockConsolidation.security_ids[1]' ); expect(validationError.code).toBe( securityIds.length === 0 ? OcpErrorCodes.OUT_OF_RANGE : OcpErrorCodes.INVALID_FORMAT @@ -234,16 +234,47 @@ describe('stock corporate-action operation boundaries', () => { } }); - it('preserves an empty reissuance result list without imposing consolidation cardinality', () => { + it('rejects an empty reissuance result list across tuple and operation writers', () => { const base = corporateActionCases[3]; const testCase: CorporateActionCase = { ...base, - data: { ...base.data, resulting_security_ids: [] }, + data: { ...base.data, resulting_security_ids: [] } as unknown as OcfDataTypeFor, }; - expect(convertToDaml(...argsFor(testCase))).toMatchObject({ resulting_security_ids: [] }); - expect(buildOcfCreateDataFromOperation(createOperationFor(testCase)).value).toMatchObject({ - resulting_security_ids: [], - }); + for (const action of [ + () => convertToDaml(...argsFor(testCase)), + () => convertOperationToDaml(createOperationFor(testCase)), + () => buildOcfCreateData(...argsFor(testCase)), + () => buildOcfEditDataFromOperation(editOperationFor(testCase)), + ]) { + const error = captureError(action) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.OUT_OF_RANGE); + expect(error.fieldPath).toBe('stockReissuance.resulting_security_ids'); + } + }); + + it('rejects non-positive writer Numeric values at their exact paths', () => { + const cases = [ + { base: corporateActionCases[0], fieldPath: 'new_ratio_conversion_mechanism.ratio.numerator' }, + { base: corporateActionCases[1], fieldPath: 'split_ratio.numerator' }, + { base: corporateActionCases[4], fieldPath: 'quantity' }, + ] as const; + + for (const { base, fieldPath } of cases) { + const parts = fieldPath.split('.'); + const data = JSON.parse(JSON.stringify(base.data)) as Record; + let target = data; + for (const key of parts.slice(0, -1)) target = target[key] as Record; + const field = parts[parts.length - 1]; + if (field === undefined) throw new Error('Expected a Numeric field'); + target[field] = '0'; + const testCase = { ...base, data: data as unknown as OcfDataTypeFor }; + + const error = captureError(() => convertToDaml(...argsFor(testCase))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.OUT_OF_RANGE); + expect(error.fieldPath).toBe(`${base.entityType}.${fieldPath}`); + } }); it.each(corporateActionCases)( diff --git a/test/client/conversionExerciseReaders.test.ts b/test/client/conversionExerciseReaders.test.ts index ae793147..67e188de 100644 --- a/test/client/conversionExerciseReaders.test.ts +++ b/test/client/conversionExerciseReaders.test.ts @@ -1,6 +1,5 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpClient } from '../../src/OcpClient'; -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; import { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP, @@ -169,23 +168,22 @@ describe('OcpClient conversion and exercise readers', () => { ); it.each([ - [clientReaderCases[0], 'quantity_converted', 'convertibleConversion.quantity_converted'], - [clientReaderCases[1], 'quantity_converted', 'stockConversion.quantity_converted'], - [clientReaderCases[2], 'quantity', 'equityCompensationExercise.quantity'], - [clientReaderCases[3], 'quantity', 'warrantExercise.quantity'], + [clientReaderCases[0], 'quantity_converted'], + [clientReaderCases[1], 'quantity_converted'], + [clientReaderCases[2], 'quantity'], + [clientReaderCases[3], 'quantity'], ] as const)( - '$0.entityType reports exact Numeric diagnostics at the public client boundary', - async (testCase, field, fieldPath) => { + '$0.entityType canonicalizes generated exponent syntax at the public client boundary', + async (testCase, field) => { const ocp = new OcpClient({ ledger: ledgerFor(testCase, { ...testCase.data, [field]: '1e3' }) }); - await expect(namespaceRead(ocp, testCase)).rejects.toBeInstanceOf(OcpValidationError); - try { - await ocp.OpenCapTable.getByObjectType({ objectType: testCase.objectType, contractId: testCase.contractId }); - throw new Error('Expected getByObjectType to reject malformed Numeric data'); - } catch (error) { - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath }); - } + const namespaceData = await namespaceRead(ocp, testCase); + const objectTypeData = ( + await ocp.OpenCapTable.getByObjectType({ objectType: testCase.objectType, contractId: testCase.contractId }) + ).data as unknown as Record; + const expected = testCase.entityType === 'warrantExercise' ? undefined : '1000'; + expect(namespaceData[field]).toBe(expected); + expect(objectTypeData[field]).toBe(expected); } ); }); diff --git a/test/client/stockCorporateActionReaders.test.ts b/test/client/stockCorporateActionReaders.test.ts index 91ef7c53..8d527b2c 100644 --- a/test/client/stockCorporateActionReaders.test.ts +++ b/test/client/stockCorporateActionReaders.test.ts @@ -1,6 +1,6 @@ import type { LedgerJsonApiClient } from '@fairmint/canton-node-sdk'; import { OcpClient } from '../../src/OcpClient'; -import { OcpErrorCodes, OcpValidationError } from '../../src/errors'; +import { OcpValidationError } from '../../src/errors'; import { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP, @@ -42,27 +42,27 @@ const clientReaderCases = [ contractId: 'client-ratio-adjustment', data: stockClassConversionRatioAdjustmentDataToDaml({ object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: '', + id: 'ratio-adjustment-1', date: '2026-07-10', - stock_class_id: '', + stock_class_id: 'preferred-1', new_ratio_conversion_mechanism: { type: 'RATIO_CONVERSION', conversion_price: { amount: '1.2500000000', currency: 'USD' }, ratio: { numerator: '2.0000000000', denominator: '1.0000000000' }, rounding_type: 'CEILING', }, - comments: [''], + comments: ['repriced'], }), expected: { object_type: 'TX_STOCK_CLASS_CONVERSION_RATIO_ADJUSTMENT', - id: '', - stock_class_id: '', + id: 'ratio-adjustment-1', + stock_class_id: 'preferred-1', new_ratio_conversion_mechanism: { conversion_price: { amount: '1.25', currency: 'USD' }, ratio: { numerator: '2', denominator: '1' }, rounding_type: 'CEILING', }, - comments: [''], + comments: ['repriced'], }, }, { @@ -71,18 +71,18 @@ const clientReaderCases = [ contractId: 'client-stock-class-split', data: stockClassSplitDataToDaml({ object_type: 'TX_STOCK_CLASS_SPLIT', - id: '', + id: 'split-1', date: '2026-07-10', - stock_class_id: '', + stock_class_id: 'common-1', split_ratio: { numerator: '4.0000000000', denominator: '1.0000000000' }, - comments: [''], + comments: ['split'], }), expected: { object_type: 'TX_STOCK_CLASS_SPLIT', - id: '', - stock_class_id: '', + id: 'split-1', + stock_class_id: 'common-1', split_ratio: { numerator: '4', denominator: '1' }, - comments: [''], + comments: ['split'], }, }, { @@ -91,20 +91,20 @@ const clientReaderCases = [ contractId: 'client-stock-consolidation', data: stockConsolidationDataToDaml({ object_type: 'TX_STOCK_CONSOLIDATION', - id: '', + id: 'consolidation-1', date: '2026-07-10', - security_ids: [''], - resulting_security_id: '', - reason_text: '', - comments: [''], + security_ids: ['security-old-1'], + resulting_security_id: 'security-new-1', + reason_text: 'cleanup', + comments: ['consolidated'], }), expected: { object_type: 'TX_STOCK_CONSOLIDATION', - id: '', - security_ids: [''], - resulting_security_id: '', - reason_text: '', - comments: [''], + id: 'consolidation-1', + security_ids: ['security-old-1'], + resulting_security_id: 'security-new-1', + reason_text: 'cleanup', + comments: ['consolidated'], }, }, { @@ -113,22 +113,22 @@ const clientReaderCases = [ contractId: 'client-stock-reissuance', data: stockReissuanceDataToDaml({ object_type: 'TX_STOCK_REISSUANCE', - id: '', + id: 'reissuance-1', date: '2026-07-10', - security_id: '', - resulting_security_ids: ['', 'duplicate', 'duplicate'], - reason_text: '', - split_transaction_id: '', - comments: [''], + security_id: 'security-old-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'replacement', + split_transaction_id: 'split-1', + comments: ['reissued'], }), expected: { object_type: 'TX_STOCK_REISSUANCE', - id: '', - security_id: '', - resulting_security_ids: ['', 'duplicate', 'duplicate'], - reason_text: '', - split_transaction_id: '', - comments: [''], + id: 'reissuance-1', + security_id: 'security-old-1', + resulting_security_ids: ['security-new-1', 'security-new-2'], + reason_text: 'replacement', + split_transaction_id: 'split-1', + comments: ['reissued'], }, }, { @@ -137,24 +137,24 @@ const clientReaderCases = [ contractId: 'client-stock-repurchase', data: stockRepurchaseDataToDaml({ object_type: 'TX_STOCK_REPURCHASE', - id: '', + id: 'repurchase-1', date: '2026-07-10', - security_id: '', + security_id: 'security-1', quantity: '12.5000000000', price: { amount: '1.2500000000', currency: 'USD' }, - balance_security_id: '', - consideration_text: '', - comments: [''], + balance_security_id: 'security-balance-1', + consideration_text: 'cash', + comments: ['repurchased'], }), expected: { object_type: 'TX_STOCK_REPURCHASE', - id: '', - security_id: '', + id: 'repurchase-1', + security_id: 'security-1', quantity: '12.5', price: { amount: '1.25', currency: 'USD' }, - balance_security_id: '', - consideration_text: '', - comments: [''], + balance_security_id: 'security-balance-1', + consideration_text: 'cash', + comments: ['repurchased'], }, }, ] as const satisfies readonly ClientReaderCase[]; @@ -201,7 +201,7 @@ function clonePlain(value: unknown): unknown { describe('OcpClient stock corporate-action readers', () => { it.each(clientReaderCases)( - '$entityType preserves exact Text, Numeric, and cardinality through namespace and object-type reads', + '$entityType preserves canonical data through namespace and object-type reads', async (testCase) => { const ocp = new OcpClient({ ledger: ledgerFor(testCase) }); const namespaceData = await namespaceRead(ocp, testCase); @@ -214,11 +214,12 @@ describe('OcpClient stock corporate-action readers', () => { for (const data of [namespaceData, objectTypeData]) { expect(data).toMatchObject(testCase.expected); + expect(Object.isFrozen(data)).toBe(true); } } ); - it('keeps consolidation and reissuance cardinality distinct at both public reader surfaces', async () => { + it('enforces consolidation and reissuance cardinality at both public reader surfaces', async () => { const consolidation = clientReaderCases[2]; const consolidationClient = new OcpClient({ ledger: ledgerFor(consolidation, { ...consolidation.data, security_ids: [] }), @@ -229,13 +230,13 @@ describe('OcpClient stock corporate-action readers', () => { const reissuanceClient = new OcpClient({ ledger: ledgerFor(reissuance, { ...reissuance.data, resulting_security_ids: [] }), }); - await expect(namespaceRead(reissuanceClient, reissuance)).resolves.toMatchObject({ resulting_security_ids: [] }); + await expect(namespaceRead(reissuanceClient, reissuance)).rejects.toBeInstanceOf(OcpValidationError); await expect( reissuanceClient.OpenCapTable.getByObjectType({ objectType: reissuance.objectType, contractId: reissuance.contractId, }) - ).resolves.toMatchObject({ data: { resulting_security_ids: [] } }); + ).rejects.toBeInstanceOf(OcpValidationError); }); it.each([ @@ -243,7 +244,7 @@ describe('OcpClient stock corporate-action readers', () => { [clientReaderCases[1], ['split_ratio', 'numerator']], [clientReaderCases[4], ['quantity']], ] as const)( - '$0.entityType reports exact Numeric diagnostics at the public client boundary', + '$0.entityType accepts and canonicalizes generated Numeric exponents at the public client boundary', async (testCase, path) => { const data = clonePlain(testCase.data) as Record; let target = data; @@ -253,15 +254,20 @@ describe('OcpClient stock corporate-action readers', () => { target[finalKey] = '1e3'; const ocp = new OcpClient({ ledger: ledgerFor(testCase, data) }); - try { - await namespaceRead(ocp, testCase); - throw new Error('Expected malformed Numeric data to be rejected'); - } catch (error: unknown) { - expect(error).toBeInstanceOf(OcpValidationError); - const validationError = error as OcpValidationError; - expect(validationError.code).toBe(OcpErrorCodes.INVALID_FORMAT); - expect(validationError.fieldPath).toBe(`${testCase.entityType}.${path.join('.')}`); - } + let result: unknown = await namespaceRead(ocp, testCase); + for (const key of path) result = (result as Record)[key]; + expect(result).toBe('1000'); } ); + + it.each(clientReaderCases)('$entityType freezes the public ContractResult wrapper', async (testCase) => { + const ocp = new OcpClient({ ledger: ledgerFor(testCase) }); + const namespace = ocp.OpenCapTable[testCase.entityType] as unknown as { + get(params: { contractId: string }): Promise<{ data: Record }>; + }; + + const result = await namespace.get({ contractId: testCase.contractId }); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.data)).toBe(true); + }); }); diff --git a/test/converters/conversionDescriptorBoundaries.test.ts b/test/converters/conversionDescriptorBoundaries.test.ts index b94ac061..3a08e71c 100644 --- a/test/converters/conversionDescriptorBoundaries.test.ts +++ b/test/converters/conversionDescriptorBoundaries.test.ts @@ -147,6 +147,19 @@ function generatedIssuanceEntityForPath(fieldPath: string): 'convertibleIssuance } function expectDamlReaderBoundary(action: () => unknown, fieldPath: string): void { + if ( + fieldPath === 'stockClassConversionRatioAdjustment' || + fieldPath.startsWith('stockClassConversionRatioAdjustment.') + ) { + const error = captureError(action); + expect(error).toBeInstanceOf(OcpParseError); + expect(error).toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + source: `damlToOcf.${fieldPath}`, + }); + return; + } const entityType = generatedIssuanceEntityForPath(fieldPath); if (entityType === undefined) { expectBoundaryError(action, fieldPath); @@ -156,6 +169,14 @@ function expectDamlReaderBoundary(action: () => unknown, fieldPath: string): voi } function expectDamlReaderProxyBoundary(action: () => unknown, fieldPath: string, fixture: ProxyFixture): void { + if ( + fieldPath === 'stockClassConversionRatioAdjustment' || + fieldPath.startsWith('stockClassConversionRatioAdjustment.') + ) { + expectDamlReaderBoundary(action, fieldPath); + expect(fixture.trapCalls()).toBe(0); + return; + } const entityType = generatedIssuanceEntityForPath(fieldPath); if (entityType === undefined) { expectProxyBoundary(action, fieldPath, fixture); diff --git a/test/converters/conversionWriterBoundaries.test.ts b/test/converters/conversionWriterBoundaries.test.ts index 91d5b2e9..91948e66 100644 --- a/test/converters/conversionWriterBoundaries.test.ts +++ b/test/converters/conversionWriterBoundaries.test.ts @@ -549,7 +549,7 @@ describe.each([ ratio: { numerator: '+003.0000000000', denominator: '02.0000000000' }, rounding_type: 'FLOOR', }, - comments: ['', 'kept verbatim'], + comments: ['kept verbatim'], }); expect(daml).toMatchObject({ @@ -558,7 +558,7 @@ describe.each([ ratio: { numerator: '3', denominator: '2' }, rounding_type: 'OcfRoundingFloor', }, - comments: ['', 'kept verbatim'], + comments: ['kept verbatim'], }); expect(damlStockClassConversionRatioAdjustmentToNative(daml)).toMatchObject({ new_ratio_conversion_mechanism: { @@ -567,7 +567,7 @@ describe.each([ ratio: { numerator: '3', denominator: '2' }, rounding_type: 'FLOOR', }, - comments: ['', 'kept verbatim'], + comments: ['kept verbatim'], }); }); diff --git a/test/converters/exerciseConversionConverters.test.ts b/test/converters/exerciseConversionConverters.test.ts index c53054f3..a233202b 100644 --- a/test/converters/exerciseConversionConverters.test.ts +++ b/test/converters/exerciseConversionConverters.test.ts @@ -406,7 +406,6 @@ describe('Exercise and Conversion Type Converters', () => { ['JavaScript number', 0, OcpErrorCodes.INVALID_TYPE], ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], - ['non-fixed-point string', '1e3', OcpErrorCodes.INVALID_FORMAT], ] as const)( 'rejects quantity_converted with %s and contextual diagnostics', async (_case, quantityConverted, code) => { @@ -422,6 +421,7 @@ describe('Exercise and Conversion Type Converters', () => { test.each([ ['negative zero', '-0', '0'], + ['generated exponent syntax', '1e3', '1000'], ['maximum Numeric(10) boundary', `${'9'.repeat(28)}.1234567890`, `${'9'.repeat(28)}.123456789`], ] as const)('canonicalizes quantity_converted at the %s', async (_case, quantityConverted, expected) => { const result = await getConvertibleConversionAsOcf(clientWithQuantity(quantityConverted), { diff --git a/test/converters/genericConversionReadBoundaries.test.ts b/test/converters/genericConversionReadBoundaries.test.ts index 8186f053..c1c82f82 100644 --- a/test/converters/genericConversionReadBoundaries.test.ts +++ b/test/converters/genericConversionReadBoundaries.test.ts @@ -844,7 +844,16 @@ describe('lossless direct and dedicated generated DAML readers', () => { getStockClassConversionRatioAdjustmentAsOcf(mockLedger('stockClassConversionRatioAdjustment', data), { contractId: 'contract-id', }) - ).rejects.toMatchObject(expected); + ).rejects.toMatchObject({ + name: 'OcpParseError', + code: OcpErrorCodes.SCHEMA_MISMATCH, + classification: 'lossy_daml_decode', + source: + 'damlToOcf.stockClassConversionRatioAdjustment.createArgument.adjustment_data.new_ratio_conversion_mechanism.future', + context: { + decoderPath: 'input.adjustment_data.new_ratio_conversion_mechanism.future', + }, + }); }); it.each([ diff --git a/test/converters/schemaCardinalityBoundaries.test.ts b/test/converters/schemaCardinalityBoundaries.test.ts index 63c979ca..ad32884d 100644 --- a/test/converters/schemaCardinalityBoundaries.test.ts +++ b/test/converters/schemaCardinalityBoundaries.test.ts @@ -235,7 +235,7 @@ describe('schema-cardinality stock consolidation boundaries', () => { }); expect(captureValidationError(() => invoke(['duplicate', 'duplicate']))).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'stockConsolidation.security_ids.1', + fieldPath: 'stockConsolidation.security_ids[1]', receivedValue: 'duplicate', }); const wrongTypeError = captureError(() => invoke(['valid', 42])); @@ -248,7 +248,7 @@ describe('schema-cardinality stock consolidation boundaries', () => { expect(wrongTypeError).toBeInstanceOf(OcpValidationError); const validationError = wrongTypeError as OcpValidationError; expect(validationError.code).toBe(OcpErrorCodes.INVALID_TYPE); - expect(validationError.fieldPath).toBe('stockConsolidation.security_ids.1'); + expect(validationError.fieldPath).toBe('stockConsolidation.security_ids[1]'); expect(validationError.receivedValue).toBe(42); } }); diff --git a/test/converters/stockClassAdjustmentConverters.test.ts b/test/converters/stockClassAdjustmentConverters.test.ts index 242d618e..4a03efb0 100644 --- a/test/converters/stockClassAdjustmentConverters.test.ts +++ b/test/converters/stockClassAdjustmentConverters.test.ts @@ -128,8 +128,8 @@ describe('Stock Class Adjustment Converters', () => { expect(result.comments).toEqual(['2-for-1 stock split', 'Approved by board']); }); - test('preserves a schema-valid empty id', () => { - expect(convertToDaml('stockClassSplit', { ...baseData, id: '' }).id).toBe(''); + test('rejects an empty id required by DAML', () => { + expect(() => convertToDaml('stockClassSplit', { ...baseData, id: '' })).toThrow(OcpValidationError); }); }); @@ -250,8 +250,10 @@ describe('Stock Class Adjustment Converters', () => { expect(result.comments).toEqual(['Ratio adjusted for anti-dilution']); }); - test('preserves a schema-valid empty id', () => { - expect(convertToDaml('stockClassConversionRatioAdjustment', { ...baseData, id: '' }).id).toBe(''); + test('rejects an empty id required by DAML', () => { + expect(() => convertToDaml('stockClassConversionRatioAdjustment', { ...baseData, id: '' })).toThrow( + OcpValidationError + ); }); test('rejects a missing conversion mechanism at runtime', () => { @@ -405,8 +407,8 @@ describe('Stock Class Adjustment Converters', () => { expect(result.resulting_security_id).toBe('new-sec-001'); }); - test('preserves a schema-valid empty id', () => { - expect(convertToDaml('stockConsolidation', { ...baseData, id: '' }).id).toBe(''); + test('rejects an empty id required by DAML', () => { + expect(() => convertToDaml('stockConsolidation', { ...baseData, id: '' })).toThrow(OcpValidationError); }); }); @@ -445,7 +447,7 @@ describe('Stock Class Adjustment Converters', () => { }); test('handles single resulting security', () => { - const dataWithSingleResult = { + const dataWithSingleResult: OcfStockReissuance = { ...baseData, resulting_security_ids: ['sec-new-single'], }; @@ -455,8 +457,8 @@ describe('Stock Class Adjustment Converters', () => { expect(result.resulting_security_ids).toEqual(['sec-new-single']); }); - test('preserves a schema-valid empty id', () => { - expect(convertToDaml('stockReissuance', { ...baseData, id: '' }).id).toBe(''); + test('rejects an empty id required by DAML', () => { + expect(() => convertToDaml('stockReissuance', { ...baseData, id: '' })).toThrow(OcpValidationError); }); }); }); @@ -571,21 +573,21 @@ describe('Stock Class Adjustment Converters', () => { 'malformed price amount', { amount: 'not-a-number', currency: 'USD' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', - 'DAML Numeric(10) decimal string', + 'nonnegative DAML Numeric(10) string', 'not-a-number', ], [ 'price amount beyond Numeric 10 scale', { amount: '1.12345678901', currency: 'USD' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.amount', - 'DAML Numeric(10) decimal string', + 'nonnegative DAML Numeric(10) string', '1.12345678901', ], [ 'malformed currency', { amount: '1', currency: 'usd' }, 'stockClassConversionRatioAdjustment.new_ratio_conversion_mechanism.conversion_price.currency', - 'three-letter uppercase currency code', + 'three-letter uppercase ISO 4217 currency code', 'usd', ], ] as const)( @@ -738,7 +740,7 @@ describe('Stock Class Adjustment Converters', () => { }); const { decoder } = Fairmint.OpenCapTable.OCF.StockClassConversionRatioAdjustment.StockClassConversionRatioAdjustment; - const decodeSpy = jest.spyOn(decoder, 'runWithException'); + const decodeSpy = jest.spyOn(decoder, 'run'); try { await expect(read({ getEventsByContractId } as unknown as LedgerJsonApiClient)).resolves.toBeDefined(); expect(decodeSpy).toHaveBeenCalledWith(createArgument); diff --git a/test/createOcf/falsyFieldRoundtrip.test.ts b/test/createOcf/falsyFieldRoundtrip.test.ts index 21380a2d..6b58da81 100644 --- a/test/createOcf/falsyFieldRoundtrip.test.ts +++ b/test/createOcf/falsyFieldRoundtrip.test.ts @@ -228,7 +228,6 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { ['JavaScript number', 0, OcpErrorCodes.INVALID_TYPE], ['eleven fractional digits', '0.00000000001', OcpErrorCodes.INVALID_FORMAT], ['twenty-nine integral digits', '1'.repeat(29), OcpErrorCodes.INVALID_FORMAT], - ['non-fixed-point string', '1e3', OcpErrorCodes.INVALID_FORMAT], ] as const)('rejects read-side quantity_converted with %s', (_case, quantityConverted, code) => { try { damlConvertibleConversionToNative({ @@ -254,6 +253,7 @@ describe('falsy field preservation in DAML-to-OCF converters', () => { test.each([ ['negative zero', '-0', '0'], + ['generated exponent syntax', '1e3', '1000'], ['maximum Numeric(10) boundary', `${'9'.repeat(28)}.1234567890`, `${'9'.repeat(28)}.123456789`], ] as const)('canonicalizes read-side quantity_converted at the %s', (_case, quantityConverted, expected) => { const result = damlConvertibleConversionToNative({ diff --git a/test/declarations/stockCorporateActionReaders.types.ts b/test/declarations/stockCorporateActionReaders.types.ts index 5ce79846..64373ed5 100644 --- a/test/declarations/stockCorporateActionReaders.types.ts +++ b/test/declarations/stockCorporateActionReaders.types.ts @@ -1,6 +1,6 @@ /** Built-declaration contracts for stock corporate-action readers and converters. */ -import type { DamlDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; import type { DamlStockClassConversionRatioAdjustmentData, damlStockClassConversionRatioAdjustmentToNative, @@ -44,29 +44,45 @@ type ConsolidationEvent = GetStockConsolidationAsOcfResult['event']; type ReissuanceEvent = GetStockReissuanceAsOcfResult['event']; type RepurchaseEvent = GetStockRepurchaseAsOcfResult['event']; -const conversionRatioEventIsExact: Assert> = - true; -const splitEventIsExact: Assert> = true; -const consolidationEventIsExact: Assert> = true; -const reissuanceEventIsExact: Assert> = true; -const repurchaseEventIsExact: Assert> = true; +const conversionRatioEventIsExact: Assert< + IsExactly> +> = true; +const splitEventIsExact: Assert>> = true; +const consolidationEventIsExact: Assert>> = true; +const reissuanceEventIsExact: Assert>> = true; +const repurchaseEventIsExact: Assert>> = true; const conversionRatioResultIsExact: Assert< IsExactly< GetStockClassConversionRatioAdjustmentAsOcfResult, - { event: OcfStockClassConversionRatioAdjustment; contractId: string } + { + readonly event: OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>; + readonly contractId: string; + } > > = true; const splitResultIsExact: Assert< - IsExactly + IsExactly< + GetStockClassSplitAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockClassSplit'>; readonly contractId: string } + > > = true; const consolidationResultIsExact: Assert< - IsExactly + IsExactly< + GetStockConsolidationAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockConsolidation'>; readonly contractId: string } + > > = true; const reissuanceResultIsExact: Assert< - IsExactly + IsExactly< + GetStockReissuanceAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockReissuance'>; readonly contractId: string } + > > = true; const repurchaseResultIsExact: Assert< - IsExactly + IsExactly< + GetStockRepurchaseAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockRepurchase'>; readonly contractId: string } + > > = true; const conversionRatioEventIsNotAny: Assert, false>> = true; @@ -133,7 +149,20 @@ declare const reissuanceDaml: DamlStockReissuanceData; declare const repurchaseDaml: DamlStockRepurchaseData; const firstConsolidatedSecurityId: string = consolidationResult.event.security_ids[0]; -const firstReissuedSecurityId: string | undefined = reissuanceResult.event.resulting_security_ids[0]; +const firstReissuedSecurityId: string = reissuanceResult.event.resulting_security_ids[0]; + +// @ts-expect-error built corporate-action result wrappers are readonly +conversionRatioResult.contractId = 'other'; +// @ts-expect-error built corporate-action event fields are readonly +splitResult.event.id = 'other'; +// @ts-expect-error built corporate-action nested objects are recursively readonly +conversionRatioResult.event.new_ratio_conversion_mechanism.ratio.numerator = '4'; +// @ts-expect-error built corporate-action identifier tuples are recursively readonly +consolidationResult.event.security_ids.push('other'); +// @ts-expect-error built reissuance identifier tuples are recursively readonly +reissuanceResult.event.resulting_security_ids[0] = 'other'; +// @ts-expect-error built nested Monetary objects are recursively readonly +repurchaseResult.event.price.amount = '4'; // @ts-expect-error built conversion-ratio DAML cannot be passed to the split converter const wrongSplitDamlInput: Parameters[0] = conversionRatioDaml; diff --git a/test/declarations/stockCorporateActionSurfaces.types.ts b/test/declarations/stockCorporateActionSurfaces.types.ts index a7d9128b..f558b897 100644 --- a/test/declarations/stockCorporateActionSurfaces.types.ts +++ b/test/declarations/stockCorporateActionSurfaces.types.ts @@ -5,6 +5,7 @@ import type { DamlDataTypeFor, OcfCreateDataFor, OcfEditDataFor, + OcfReadDataTypeFor, } from '../../dist/functions/OpenCapTable/capTable/batchTypes'; import { buildOcfCreateData, @@ -108,11 +109,11 @@ const builtBatchTypesAreExact: Assert< const builtClientTypesAreExact: Assert< EveryTrue< [ - IsExactly['data'], OcfStockClassConversionRatioAdjustment>, - IsExactly['data'], OcfStockClassSplit>, - IsExactly['data'], OcfStockConsolidation>, - IsExactly['data'], OcfStockReissuance>, - IsExactly['data'], OcfStockRepurchase>, + IsExactly['data'], OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockClassSplit'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockConsolidation'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockReissuance'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockRepurchase'>>, IsExactly['data']>, false>, ] > @@ -122,7 +123,7 @@ const builtCardinalityAndScalarTypesAreExact: Assert< [ IsExactly, IsExactly, - IsExactly, + IsExactly, IsExactly>, never>, IsExactly>, never>, IsExactly>, never>, @@ -132,15 +133,22 @@ const builtCardinalityAndScalarTypesAreExact: Assert< > > = true; +// @ts-expect-error built reissuance resulting_security_ids are statically non-empty const emptyReissuanceResults: OcfStockReissuance['resulting_security_ids'] = []; // @ts-expect-error built consolidation security_ids are statically non-empty const emptyConsolidationSources: OcfStockConsolidation['security_ids'] = []; +declare const clientRatioData: Awaited['data']; +declare const clientReissuanceData: Awaited['data']; // @ts-expect-error built generic writers preserve entity/data correlation convertToDaml('stockClassSplit', consolidation); // @ts-expect-error built operation writers preserve entity/data correlation convertOperationToDaml({ type: 'stockRepurchase', data: reissuance }); // @ts-expect-error built batch writers preserve entity/data correlation buildOcfCreateData('stockConsolidation', split); +// @ts-expect-error built public namespace reads are recursively readonly +clientRatioData.new_ratio_conversion_mechanism.conversion_price.amount = '2'; +// @ts-expect-error built public object-type reads expose readonly non-empty tuples +clientReissuanceData.resulting_security_ids.push('other'); void builtWriterTypesAreExact; void builtBatchTypesAreExact; diff --git a/test/functions/conversionExerciseReaders.test.ts b/test/functions/conversionExerciseReaders.test.ts index d582440f..3cd521f1 100644 --- a/test/functions/conversionExerciseReaders.test.ts +++ b/test/functions/conversionExerciseReaders.test.ts @@ -562,17 +562,16 @@ describe('preserved conversion and exercise semantic invariants', () => { }); it.each([ - [0, 'quantity_converted', 'convertibleConversion.quantity_converted'], - [1, 'quantity_converted', 'stockConversion.quantity_converted'], - [2, 'quantity', 'equityCompensationExercise.quantity'], - [3, 'quantity', 'warrantExercise.quantity'], - ] as const)('reader case %i rejects a semantically invalid %s', async (caseIndex, field, fieldPath) => { + [0, 'quantity_converted'], + [1, 'quantity_converted'], + [2, 'quantity'], + [3, 'quantity'], + ] as const)('reader case %i canonicalizes generated exponent syntax for %s', async (caseIndex, field) => { const testCase = readerCases[caseIndex]; if (!testCase) throw new Error(`Missing reader case ${caseIndex}`); const { client } = createMockClient(testCase, { ...testCase.validData(), [field]: '1e3' }); - const error = await captureRejection(async () => testCase.invoke(client)); - expect(error).toBeInstanceOf(OcpValidationError); - expect(error).toMatchObject({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath }); + const result = await testCase.invoke(client); + expect((result.event as unknown as Record)[field]).toBe(caseIndex === 3 ? undefined : '1000'); }); }); diff --git a/test/functions/generatedDamlNumericReaders.test.ts b/test/functions/generatedDamlNumericReaders.test.ts index fac805c6..b3186cd3 100644 --- a/test/functions/generatedDamlNumericReaders.test.ts +++ b/test/functions/generatedDamlNumericReaders.test.ts @@ -251,23 +251,25 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { }); it.each([ - ['quantity', { quantity: '1e2' }, 'stockIssuance.quantity'], - ['share price', { share_price: { amount: '-0e20', currency: 'USD' } }, 'stockIssuance.share_price.amount'], - ['cost basis', { cost_basis: { amount: '12.34e-1', currency: 'EUR' } }, 'stockIssuance.cost_basis.amount'], + ['quantity', { quantity: '1e2' }, ['quantity'], '100'], + ['share price', { share_price: { amount: '-0e20', currency: 'USD' } }, ['share_price', 'amount'], '0'], + ['cost basis', { cost_basis: { amount: '12.34e-1', currency: 'EUR' } }, ['cost_basis', 'amount'], '1.234'], [ 'vesting amount', { vestings: [{ date: '2026-02-01T00:00:00Z', amount: '1e-10' }] }, - 'stockIssuance.vestings[0].amount', + ['vestings', '0', 'amount'], + '0.0000000001', ], [ 'share number', { share_numbers_issued: [{ starting_share_number: '1e0', ending_share_number: '2' }] }, - 'stockIssuance.share_numbers_issued[0].starting_share_number', + ['share_numbers_issued', '0', 'starting_share_number'], + '1', ], - ] as const)('rejects exponent-form stock issuance %s', (_name, override, fieldPath) => { - expect(() => damlStockIssuanceDataToNative(invalidStockIssuance(override))).toThrow( - expect.objectContaining({ name: OcpValidationError.name, code: OcpErrorCodes.INVALID_FORMAT, fieldPath }) - ); + ] as const)('canonicalizes generated exponent-form stock issuance %s', (_name, override, path, expected) => { + let value: unknown = damlStockIssuanceDataToNative(invalidStockIssuance(override)); + for (const key of path) value = (value as Record)[key]; + expect(value).toBe(expected); }); it.each([ @@ -280,16 +282,11 @@ describe('generated DAML Numeric and Monetary reader boundaries', () => { ); }); - it('rejects an exponent-form valuation price', () => { - expect(() => + it('canonicalizes an exponent-form generated valuation price', () => { + expect( damlValuationToNative(invalidValuation({ price_per_share: { amount: '125e-2', currency: 'USD' } })) - ).toThrow( - expect.objectContaining({ - name: OcpValidationError.name, - code: OcpErrorCodes.INVALID_FORMAT, - fieldPath: 'valuation.price_per_share.amount', - }) - ); + .price_per_share.amount + ).toBe('1.25'); }); it.each([ diff --git a/test/functions/stockCorporateActionReaders.test.ts b/test/functions/stockCorporateActionReaders.test.ts index 0f9349b9..acf09d4d 100644 --- a/test/functions/stockCorporateActionReaders.test.ts +++ b/test/functions/stockCorporateActionReaders.test.ts @@ -6,6 +6,7 @@ import { ENTITY_DATA_FIELD_MAP, ENTITY_TEMPLATE_ID_MAP, type OcfEntityType, + type OcfReadDataTypeFor, } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { convertToOcf } from '../../src/functions/OpenCapTable/capTable/damlToOcf'; import { stockClassAuthorizedSharesAdjustmentDataToDaml } from '../../src/functions/OpenCapTable/stockClassAuthorizedSharesAdjustment/createStockClassAuthorizedSharesAdjustment'; @@ -24,14 +25,7 @@ import { stockReissuanceDataToDaml } from '../../src/functions/OpenCapTable/stoc import { damlStockRepurchaseToNative } from '../../src/functions/OpenCapTable/stockRepurchase/damlToOcf'; import { getStockRepurchaseAsOcf } from '../../src/functions/OpenCapTable/stockRepurchase/getStockRepurchaseAsOcf'; import { stockRepurchaseDataToDaml } from '../../src/functions/OpenCapTable/stockRepurchase/stockRepurchaseDataToDaml'; -import type { - OcfStockClassConversionRatioAdjustment, - OcfStockClassSplit, - OcfStockConsolidation, - OcfStockReissuance, - OcfStockRepurchase, - RoundingType, -} from '../../src/types/native'; +import type { RoundingType } from '../../src/types/native'; type StockCorporateActionEntityType = Extract< OcfEntityType, @@ -42,12 +36,7 @@ type StockCorporateActionEntityType = Extract< | 'stockRepurchase' >; -type StockCorporateAction = - | OcfStockClassConversionRatioAdjustment - | OcfStockClassSplit - | OcfStockConsolidation - | OcfStockReissuance - | OcfStockRepurchase; +type StockCorporateAction = OcfReadDataTypeFor; const canonicalContext = { issuer: 'issuer::party', @@ -351,6 +340,15 @@ function deletePath(data: Record, path: readonly string[]): Rec return data; } +function getPath(data: unknown, path: readonly string[]): unknown { + let value = data; + for (const key of path) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + value = (value as Record)[key]; + } + return value; +} + describe('decoder-backed stock corporate-action readers', () => { it.each(stockCorporateActionCases)( '$entityType returns the same exact event through direct and dispatcher conversion', @@ -465,7 +463,7 @@ describe('decoder-backed stock corporate-action readers', () => { expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); expect(parseError.context).toMatchObject({ entityType: testCase.entityType, - decoderPath: 'input.unexpected_field', + decoderPath: `input.${ENTITY_DATA_FIELD_MAP[testCase.entityType]}.unexpected_field`, decoderMessage: 'raw field was discarded by the generated codec', }); } @@ -524,25 +522,79 @@ describe('decoder-backed stock corporate-action readers', () => { }); }); + it.each(stockCorporateActionCases)('$entityType rejects an empty transaction id', async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), id: '' }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe(`${testCase.entityType}.id`); + }); + + it.each(stockCorporateActionCases)('$entityType rejects an empty required identifier', async (testCase) => { + const { client } = createMockClient(testCase, { + ...testCase.validData(), + [testCase.requiredScalar]: '', + }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe(`${testCase.entityType}.${testCase.requiredScalar}`); + }); + + it.each(stockCorporateActionCases)('$entityType rejects an empty comment item', async (testCase) => { + const { client } = createMockClient(testCase, { ...testCase.validData(), comments: [''] }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe(`${testCase.entityType}.comments[0]`); + }); + + it.each(stockCorporateActionCases)('$entityType validates both Canton Party IDs', async (testCase) => { + const { client } = createMockClient(testCase, testCase.validData(), { + createArgument: { + context: { issuer: 'issuer party', system_operator: canonicalContext.system_operator }, + [ENTITY_DATA_FIELD_MAP[testCase.entityType]]: testCase.validData(), + }, + }); + + const error = (await captureRejection(testCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.fieldPath).toBe(`damlToOcf.${testCase.entityType}.createArgument.context.issuer`); + }); + it.each(stockCorporateActionCases)( - '$entityType preserves schema-valid empty Text values and comment items', + '$entityType returns a detached, recursively frozen event and result', async (testCase) => { - const data = { - ...testCase.validData(), - id: '', - [testCase.requiredScalar]: '', - comments: [''], - }; + const data = testCase.validData(); const { client } = createMockClient(testCase, data); const result = await testCase.invoke(client); - const event = result.event as unknown as Record; - expect(event.id).toBe(''); - expect(event[testCase.requiredScalar]).toBe(''); - expect(event.comments).toEqual(['']); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.event)).toBe(true); + if (result.event.comments !== undefined) { + expect(Object.isFrozen(result.event.comments)).toBe(true); + } + for (const value of Object.values(result.event)) { + if (value !== null && typeof value === 'object') expect(Object.isFrozen(value)).toBe(true); + } + + const rawComments = data.comments as string[]; + rawComments[0] = 'mutated after read'; + expect(result.event.comments?.[0]).toBe(testCase.expectedEvent.comments?.[0]); } ); + it('bounds hostile direct-reader depth before generated decoding', () => { + let nested: Record = { leaf: true }; + for (let depth = 0; depth < 110; depth += 1) nested = { child: nested }; + const data = { ...stockClassSplitData(), hostile: nested }; + + expect(() => stockClassSplitCase.directRead(data)).toThrow(OcpParseError); + }); + it.each(stockCorporateActionCases)( '$entityType rejects proxied entity data without invoking traps', async (testCase) => { @@ -649,9 +701,16 @@ describe('numeric and nested stock corporate-action fields', () => { await expectDecoderRejection(testCase, setPath(testCase.validData(), path, 17), path[path.length - 1] ?? 'numeric'); }); - it.each(numericFields)('rejects an invalid numeric string for $label', async ({ testCase, path }) => { + it.each(numericFields)('accepts generated exponent syntax for $label', async ({ testCase, path }) => { const { client } = createMockClient(testCase, setPath(testCase.validData(), path, '1e3')); + const result = await testCase.invoke(client); + expect(getPath(result.event, path)).toBe('1000'); + }); + + it.each(numericFields)('rejects an invalid numeric string for $label', async ({ testCase, path }) => { + const { client } = createMockClient(testCase, setPath(testCase.validData(), path, 'not-a-number')); + const error = await captureRejection(testCase.invoke(client)); expect(error).toBeInstanceOf(OcpValidationError); const validationError = error as OcpValidationError; @@ -825,13 +884,13 @@ describe('optional stock corporate-action fields', () => { expect(field in event).toBe(false); }); - it.each(optionalFields)('$testCase.entityType preserves a present empty Text $field', async ({ testCase, field }) => { + it.each(optionalFields)('$testCase.entityType rejects a present empty Text $field', async ({ testCase, field }) => { const { client } = createMockClient(testCase, { ...testCase.validData(), [field]: '' }); - const result = await testCase.invoke(client); - const event = result.event as unknown as Record; - expect(event[field]).toBe(''); - expect(field in event).toBe(true); + const error = (await captureRejection(testCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe(`${testCase.entityType}.${field}`); }); it.each(optionalFields)( @@ -848,7 +907,7 @@ describe('optional stock corporate-action fields', () => { expect(parseError.code).toBe(OcpErrorCodes.SCHEMA_MISMATCH); expect(parseError.context).toMatchObject({ entityType: testCase.entityType, - decoderPath: `input.${field}`, + decoderPath: `input.${ENTITY_DATA_FIELD_MAP[testCase.entityType]}.${field}`, decoderMessage: 'raw object was decoded and encoded as null', }); } @@ -879,35 +938,32 @@ describe('stock consolidation, reissuance, and repurchase invariants', () => { expect(error).toBeInstanceOf(OcpValidationError); const validationError = error as OcpValidationError; expect(validationError.code).toBe(OcpErrorCodes.INVALID_FORMAT); - expect(validationError.fieldPath).toBe('stockConsolidation.security_ids.1'); + expect(validationError.fieldPath).toBe('stockConsolidation.security_ids[1]'); }); - it('allows a reissuance with no resulting securities', async () => { + it('rejects a reissuance with no resulting securities', async () => { const { client } = createMockClient(stockReissuanceCase, { ...stockReissuanceData(), resulting_security_ids: [], }); - const result = await stockReissuanceCase.invoke(client); - expect(result.event).toMatchObject({ - object_type: 'TX_STOCK_REISSUANCE', - resulting_security_ids: [], - }); + const error = (await captureRejection(stockReissuanceCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.OUT_OF_RANGE); + expect(error.fieldPath).toBe('stockReissuance.resulting_security_ids'); }); - it('allows a consolidation whose single source security is the empty Text value', async () => { + it('rejects a consolidation whose source security is an empty Text value', async () => { const { client } = createMockClient(stockConsolidationCase, { ...stockConsolidationData(), security_ids: [''], resulting_security_id: '', }); - const result = await stockConsolidationCase.invoke(client); - expect(result.event).toMatchObject({ - object_type: 'TX_STOCK_CONSOLIDATION', - security_ids: [''], - resulting_security_id: '', - }); + const error = (await captureRejection(stockConsolidationCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe('stockConsolidation.security_ids[0]'); }); it('rejects duplicate empty consolidation source securities at the duplicate index', async () => { @@ -920,17 +976,19 @@ describe('stock consolidation, reissuance, and repurchase invariants', () => { expect(error).toBeInstanceOf(OcpValidationError); const validationError = error as OcpValidationError; expect(validationError.code).toBe(OcpErrorCodes.INVALID_FORMAT); - expect(validationError.fieldPath).toBe('stockConsolidation.security_ids.1'); + expect(validationError.fieldPath).toBe('stockConsolidation.security_ids[0]'); }); - it('allows duplicate reissuance result security IDs', async () => { + it('rejects duplicate reissuance result security IDs', async () => { const { client } = createMockClient(stockReissuanceCase, { ...stockReissuanceData(), - resulting_security_ids: ['', 'duplicate', 'duplicate'], + resulting_security_ids: ['duplicate', 'duplicate'], }); - const result = await stockReissuanceCase.invoke(client); - expect(result.event).toMatchObject({ resulting_security_ids: ['', 'duplicate', 'duplicate'] }); + const error = (await captureRejection(stockReissuanceCase.invoke(client))) as OcpValidationError; + expect(error).toBeInstanceOf(OcpValidationError); + expect(error.code).toBe(OcpErrorCodes.INVALID_FORMAT); + expect(error.fieldPath).toBe('stockReissuance.resulting_security_ids[1]'); }); it('normalizes both repurchase quantity and price without conflating them', async () => { diff --git a/test/functions/stockIssuanceBoundaries.test.ts b/test/functions/stockIssuanceBoundaries.test.ts index f06679a6..6908ce1c 100644 --- a/test/functions/stockIssuanceBoundaries.test.ts +++ b/test/functions/stockIssuanceBoundaries.test.ts @@ -278,15 +278,22 @@ describe('stock issuance exact boundaries', () => { ); }); - test.each([ - ['quantity', { ...stockIssuanceDataToDaml(STOCK_ISSUANCE), quantity: '1e3' }], - [ - 'share_price.amount', - { ...stockIssuanceDataToDaml(STOCK_ISSUANCE), share_price: { amount: '0.00000000001', currency: 'USD' } }, - ], - ] as const)('rejects malformed generated Numeric(10) at %s', (field, data) => { - expect(() => damlStockIssuanceDataToNative(data as ReturnType)).toThrow( - expect.objectContaining({ code: OcpErrorCodes.INVALID_FORMAT, fieldPath: `stockIssuance.${field}` }) + test('accepts the exponent syntax supported by the generated Numeric(10) codec', () => { + expect( + damlStockIssuanceDataToNative({ ...stockIssuanceDataToDaml(STOCK_ISSUANCE), quantity: '1e3' }).quantity + ).toBe('1000'); + }); + + test('rejects malformed generated Numeric(10) beyond its scale', () => { + const data = { + ...stockIssuanceDataToDaml(STOCK_ISSUANCE), + share_price: { amount: '0.00000000001', currency: 'USD' }, + }; + expect(() => damlStockIssuanceDataToNative(data)).toThrow( + expect.objectContaining({ + code: OcpErrorCodes.INVALID_FORMAT, + fieldPath: 'stockIssuance.share_price.amount', + }) ); }); }); diff --git a/test/package-consumer/publicEntrypoint.types.ts b/test/package-consumer/publicEntrypoint.types.ts index f4abd4ac..4caeaafa 100644 --- a/test/package-consumer/publicEntrypoint.types.ts +++ b/test/package-consumer/publicEntrypoint.types.ts @@ -4,12 +4,10 @@ import type { OcfConvertibleConversion, OcfEquityCompensationExercise, OcfObject, - OcfStockClassConversionRatioAdjustment, - OcfStockClassSplit, + OcfReadDataTypeFor, OcfStockConsolidation, OcfStockConversion, OcfStockReissuance, - OcfStockRepurchase, OcfWarrantExercise, OcpClient, OcpValidationError, @@ -84,15 +82,15 @@ const packageEmptyStockResults: OcfStockConversion['resulting_security_ids'] = [ const packageEmptyEquityResults: OcfEquityCompensationExercise['resulting_security_ids'] = []; const packageEmptyWarrantResults: OcfWarrantExercise['resulting_security_ids'] = []; const packageCorporateReadersAreExact: Assert< - IsExactly['data'], OcfStockClassConversionRatioAdjustment> & - IsExactly['data'], OcfStockClassSplit> & - IsExactly['data'], OcfStockConsolidation> & - IsExactly['data'], OcfStockReissuance> & - IsExactly['data'], OcfStockRepurchase> + IsExactly['data'], OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>> & + IsExactly['data'], OcfReadDataTypeFor<'stockClassSplit'>> & + IsExactly['data'], OcfReadDataTypeFor<'stockConsolidation'>> & + IsExactly['data'], OcfReadDataTypeFor<'stockReissuance'>> & + IsExactly['data'], OcfReadDataTypeFor<'stockRepurchase'>> > = true; const packageFirstConsolidationSource: string = (null as unknown as OcfStockConsolidation).security_ids[0]; -const packageFirstReissuanceResult: string | undefined = (null as unknown as OcfStockReissuance) - .resulting_security_ids[0]; +const packageFirstReissuanceResult: string = (null as unknown as OcfStockReissuance).resulting_security_ids[0]; +// @ts-expect-error package-root reissuance results are statically non-empty const packageEmptyReissuanceResults: OcfStockReissuance['resulting_security_ids'] = []; // @ts-expect-error package-root consolidation sources are statically non-empty const packageEmptyConsolidationSources: OcfStockConsolidation['security_ids'] = []; diff --git a/test/schemaAlignment/canonicalOcfObjectInventory.json b/test/schemaAlignment/canonicalOcfObjectInventory.json index f2e2c224..1aceb487 100644 --- a/test/schemaAlignment/canonicalOcfObjectInventory.json +++ b/test/schemaAlignment/canonicalOcfObjectInventory.json @@ -1,5 +1,5 @@ { - "fingerprint": "aac6ba2a8f4174542f15872b8f1ff8827e86bffb6c52ca42f1a7b70651185bd7", + "fingerprint": "504b8cc41cb50d15729a4cb971503e9172b02a1ecd1f4917e9c5652721b77bff", "objects": [ { "discriminator": "CE_STAKEHOLDER_RELATIONSHIP", @@ -163,7 +163,7 @@ }, { "discriminator": "TX_STOCK_REISSUANCE", - "signature": "intersection(object(\"comments\"?:array(string);\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_STOCK_REISSUANCE\";\"reason_text\"?:string;\"resulting_security_ids\":array(string);\"security_id\":string;\"split_transaction_id\"?:string)&object(readonly \"object_type\":string:\"TX_STOCK_REISSUANCE\"))" + "signature": "intersection(object(\"comments\"?:array(string);\"date\":string;\"id\":string;readonly \"object_type\":string:\"TX_STOCK_REISSUANCE\";\"reason_text\"?:string;\"resulting_security_ids\":tuple(string,...string);\"security_id\":string;\"split_transaction_id\"?:string)&object(readonly \"object_type\":string:\"TX_STOCK_REISSUANCE\"))" }, { "discriminator": "TX_STOCK_REPURCHASE", diff --git a/test/schemaAlignment/schemaConformanceRegistry.ts b/test/schemaAlignment/schemaConformanceRegistry.ts index 500bfc47..65f4a28b 100644 --- a/test/schemaAlignment/schemaConformanceRegistry.ts +++ b/test/schemaAlignment/schemaConformanceRegistry.ts @@ -176,7 +176,13 @@ export const PINNED_CANONICAL_UNIQUE_ARRAYS: PinnedOcfUniqueArrayInventoryEntry[ ]; /** Deliberate SDK non-empty refinements beyond the pinned schema's array cardinality. */ -export const EXPECTED_NON_EMPTY_ARRAY_REFINEMENTS = [] as const; +export const EXPECTED_NON_EMPTY_ARRAY_REFINEMENTS = [ + { + discriminator: 'TX_STOCK_REISSUANCE', + kind: 'sdk-only', + property: 'resulting_security_ids', + }, +] as const; const CONDITIONAL_WITNESS_FILE = 'test/schemaAlignment/conditionalBranchWitnesses.test.ts'; diff --git a/test/types/stockCorporateActionReaders.types.ts b/test/types/stockCorporateActionReaders.types.ts index ccf3b303..c6ca0093 100644 --- a/test/types/stockCorporateActionReaders.types.ts +++ b/test/types/stockCorporateActionReaders.types.ts @@ -1,6 +1,6 @@ /** Compile-time contracts for stock corporate-action readers and converters. */ -import type { DamlDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; +import type { DamlDataTypeFor, OcfReadDataTypeFor } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import type { DamlStockClassConversionRatioAdjustmentData, damlStockClassConversionRatioAdjustmentToNative, @@ -44,29 +44,45 @@ type ConsolidationEvent = GetStockConsolidationAsOcfResult['event']; type ReissuanceEvent = GetStockReissuanceAsOcfResult['event']; type RepurchaseEvent = GetStockRepurchaseAsOcfResult['event']; -const conversionRatioEventIsExact: Assert> = - true; -const splitEventIsExact: Assert> = true; -const consolidationEventIsExact: Assert> = true; -const reissuanceEventIsExact: Assert> = true; -const repurchaseEventIsExact: Assert> = true; +const conversionRatioEventIsExact: Assert< + IsExactly> +> = true; +const splitEventIsExact: Assert>> = true; +const consolidationEventIsExact: Assert>> = true; +const reissuanceEventIsExact: Assert>> = true; +const repurchaseEventIsExact: Assert>> = true; const conversionRatioResultIsExact: Assert< IsExactly< GetStockClassConversionRatioAdjustmentAsOcfResult, - { event: OcfStockClassConversionRatioAdjustment; contractId: string } + { + readonly event: OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>; + readonly contractId: string; + } > > = true; const splitResultIsExact: Assert< - IsExactly + IsExactly< + GetStockClassSplitAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockClassSplit'>; readonly contractId: string } + > > = true; const consolidationResultIsExact: Assert< - IsExactly + IsExactly< + GetStockConsolidationAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockConsolidation'>; readonly contractId: string } + > > = true; const reissuanceResultIsExact: Assert< - IsExactly + IsExactly< + GetStockReissuanceAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockReissuance'>; readonly contractId: string } + > > = true; const repurchaseResultIsExact: Assert< - IsExactly + IsExactly< + GetStockRepurchaseAsOcfResult, + { readonly event: OcfReadDataTypeFor<'stockRepurchase'>; readonly contractId: string } + > > = true; const conversionRatioEventIsNotAny: Assert, false>> = true; @@ -133,7 +149,20 @@ declare const reissuanceDaml: DamlStockReissuanceData; declare const repurchaseDaml: DamlStockRepurchaseData; const firstConsolidatedSecurityId: string = consolidationResult.event.security_ids[0]; -const firstReissuedSecurityId: string | undefined = reissuanceResult.event.resulting_security_ids[0]; +const firstReissuedSecurityId: string = reissuanceResult.event.resulting_security_ids[0]; + +// @ts-expect-error corporate-action result wrappers are readonly +conversionRatioResult.contractId = 'other'; +// @ts-expect-error corporate-action event fields are readonly +splitResult.event.id = 'other'; +// @ts-expect-error corporate-action nested objects are recursively readonly +conversionRatioResult.event.new_ratio_conversion_mechanism.ratio.numerator = '4'; +// @ts-expect-error corporate-action identifier tuples are recursively readonly +consolidationResult.event.security_ids.push('other'); +// @ts-expect-error reissuance identifier tuples are recursively readonly +reissuanceResult.event.resulting_security_ids[0] = 'other'; +// @ts-expect-error nested Monetary objects are recursively readonly +repurchaseResult.event.price.amount = '4'; // @ts-expect-error conversion-ratio DAML cannot be passed to the split converter const wrongSplitDamlInput: Parameters[0] = conversionRatioDaml; diff --git a/test/types/stockCorporateActionSurfaces.types.ts b/test/types/stockCorporateActionSurfaces.types.ts index 97e0ee16..9da38104 100644 --- a/test/types/stockCorporateActionSurfaces.types.ts +++ b/test/types/stockCorporateActionSurfaces.types.ts @@ -5,6 +5,7 @@ import type { DamlDataTypeFor, OcfCreateDataFor, OcfEditDataFor, + OcfReadDataTypeFor, } from '../../src/functions/OpenCapTable/capTable/batchTypes'; import { buildOcfCreateData, @@ -116,11 +117,11 @@ const batchTypesAreExact: Assert< const clientTypesAreExact: Assert< EveryTrue< [ - IsExactly['data'], OcfStockClassConversionRatioAdjustment>, - IsExactly['data'], OcfStockClassSplit>, - IsExactly['data'], OcfStockConsolidation>, - IsExactly['data'], OcfStockReissuance>, - IsExactly['data'], OcfStockRepurchase>, + IsExactly['data'], OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockClassSplit'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockConsolidation'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockReissuance'>>, + IsExactly['data'], OcfReadDataTypeFor<'stockRepurchase'>>, IsExactly['data']>, false>, ] > @@ -130,7 +131,7 @@ const cardinalityAndDamlScalarTypesAreExact: Assert< [ IsExactly, IsExactly, - IsExactly, + IsExactly, IsExactly>, never>, IsExactly>, never>, IsExactly>, never>, @@ -140,9 +141,12 @@ const cardinalityAndDamlScalarTypesAreExact: Assert< > > = true; +// @ts-expect-error reissuance resulting_security_ids are statically non-empty const emptyReissuanceResults: OcfStockReissuance['resulting_security_ids'] = []; // @ts-expect-error consolidation security_ids are statically non-empty const emptyConsolidationSources: OcfStockConsolidation['security_ids'] = []; +declare const clientRatioData: Awaited['data']; +declare const clientReissuanceData: Awaited['data']; // @ts-expect-error generic writers preserve entity/data correlation convertToDaml('stockClassSplit', consolidation); // @ts-expect-error operation writers preserve entity/data correlation @@ -151,6 +155,10 @@ convertOperationToDaml({ type: 'stockRepurchase', data: reissuance }); buildOcfCreateData('stockConsolidation', split); // @ts-expect-error generated Numeric fields remain strings, never JavaScript numbers const invalidSplitNumerator: DamlDataTypeFor<'stockClassSplit'>['split_ratio']['numerator'] = 2; +// @ts-expect-error public namespace reads are recursively readonly +clientRatioData.new_ratio_conversion_mechanism.conversion_price.amount = '2'; +// @ts-expect-error public object-type reads expose readonly non-empty tuples +clientReissuanceData.resulting_security_ids.push('other'); void directWriterTypesAreExact; void dispatcherAndOperationTypesAreExact; From 206539f66ad6ec21e53ddabebb5c3e6ba6f4df74 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Sun, 12 Jul 2026 12:17:41 -0400 Subject: [PATCH 3/3] Fix clean-checkout package consumer lint --- .../publicEntrypoint.types.ts | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/test/package-consumer/publicEntrypoint.types.ts b/test/package-consumer/publicEntrypoint.types.ts index 0540b86d..9b9ae9e5 100644 --- a/test/package-consumer/publicEntrypoint.types.ts +++ b/test/package-consumer/publicEntrypoint.types.ts @@ -133,12 +133,20 @@ const packageEmptyStockResults: OcfStockConversion['resulting_security_ids'] = [ const packageEmptyEquityResults: OcfEquityCompensationExercise['resulting_security_ids'] = []; // @ts-expect-error package warrant exercises require at least one resulting security const packageEmptyWarrantResults: OcfWarrantExercise['resulting_security_ids'] = []; -const packageCorporateReadersAreExact: Assert< - IsExactly['data'], OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>> & - IsExactly['data'], OcfReadDataTypeFor<'stockClassSplit'>> & - IsExactly['data'], OcfReadDataTypeFor<'stockConsolidation'>> & - IsExactly['data'], OcfReadDataTypeFor<'stockReissuance'>> & - IsExactly['data'], OcfReadDataTypeFor<'stockRepurchase'>> +const packageCorporateRatioReaderIsExact: Assert< + IsExactly['data'], OcfReadDataTypeFor<'stockClassConversionRatioAdjustment'>> +> = true; +const packageCorporateSplitReaderIsExact: Assert< + IsExactly['data'], OcfReadDataTypeFor<'stockClassSplit'>> +> = true; +const packageCorporateConsolidationReaderIsExact: Assert< + IsExactly['data'], OcfReadDataTypeFor<'stockConsolidation'>> +> = true; +const packageCorporateReissuanceReaderIsExact: Assert< + IsExactly['data'], OcfReadDataTypeFor<'stockReissuance'>> +> = true; +const packageCorporateRepurchaseReaderIsExact: Assert< + IsExactly['data'], OcfReadDataTypeFor<'stockRepurchase'>> > = true; const packageFirstConsolidationSource: string = (null as unknown as OcfStockConsolidation).security_ids[0]; const packageFirstReissuanceResult: string = (null as unknown as OcfStockReissuance).resulting_security_ids[0]; @@ -179,7 +187,11 @@ void corporateSplitRead; void corporateConsolidationRead; void corporateReissuanceRead; void corporateRepurchaseRead; -void packageCorporateReadersAreExact; +void packageCorporateRatioReaderIsExact; +void packageCorporateSplitReaderIsExact; +void packageCorporateConsolidationReaderIsExact; +void packageCorporateReissuanceReaderIsExact; +void packageCorporateRepurchaseReaderIsExact; void packageFirstConsolidationSource; void packageFirstReissuanceResult; void packageEmptyReissuanceResults;